vsslctrl 0.1.3.dev1__tar.gz → 0.1.5.dev1__tar.gz
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.
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/PKG-INFO +78 -21
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/README.md +77 -20
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/pyproject.toml +1 -1
- vsslctrl-0.1.5.dev1/vsslctrl/__init__.py +6 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/api_alpha.py +2 -2
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/api_base.py +13 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/core.py +37 -51
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/data_structure.py +193 -56
- vsslctrl-0.1.5.dev1/vsslctrl/device.py +115 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/group.py +13 -7
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/io.py +12 -5
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/settings.py +1 -1
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/zone.py +3 -14
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl.egg-info/PKG-INFO +78 -21
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl.egg-info/SOURCES.txt +1 -0
- vsslctrl-0.1.3.dev1/vsslctrl/__init__.py +0 -4
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/LICENSE +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/setup.cfg +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/tests/test_event_bus.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/tests/test_integration.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/tests/test_settings.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/tests/test_zone.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/api_bravo.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/decorators.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/discovery.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/event_bus.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/exceptions.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/track.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/transport.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl/utils.py +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl.egg-info/dependency_links.txt +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl.egg-info/requires.txt +0 -0
- {vsslctrl-0.1.3.dev1 → vsslctrl-0.1.5.dev1}/vsslctrl.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: vsslctrl
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5.dev1
|
|
4
4
|
Summary: Package for controlling VSSL amplifiers
|
|
5
5
|
Author-email: vsslctrl <vsslcontrolled@proton.me>
|
|
6
6
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -28,37 +28,45 @@ Requires-Dist: zeroconf==0.132.2; extra == "optional"
|
|
|
28
28
|
|
|
29
29
|
I am looking for testers with any VSSL amplifier models, please get in touch if you interested in helping.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Important
|
|
32
|
+
-----------
|
|
33
|
+
Only tested on a VSSL **A.3x** software version **p15305.016.3701**.
|
|
34
|
+
|
|
35
|
+
**Notice:** VSSLs new iOS app titled `VSSL`can cause connection refused issues if running at the same time as `vsslctrl`. Best to not use the iOS app togeather. The `VSSL Legacy` app works fine
|
|
36
|
+
|
|
37
|
+
**Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
|
|
32
38
|
|
|
33
39
|
## TODOs
|
|
34
40
|
|
|
35
41
|
* Test on other models (hardware needed)
|
|
36
42
|
* Home Assistant integration. In progress, [here](https://github.com/vsslctrl/integration.home-assistant)
|
|
37
|
-
* Function scoping to supported feature / models
|
|
43
|
+
* ~~Function scoping to supported feature / models~~ (needs tests)
|
|
38
44
|
* Better test coverage
|
|
39
45
|
|
|
40
|
-
Important
|
|
41
|
-
-----------
|
|
42
|
-
**Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
|
|
43
|
-
|
|
44
|
-
|
|
45
46
|
Basic Usage
|
|
46
47
|
-----------
|
|
47
48
|
`vsslctrl` needs to be running inside a **[asyncio](https://docs.python.org/3/library/asyncio.html)** event loop.
|
|
48
49
|
|
|
49
50
|
```python
|
|
50
51
|
import asyncio
|
|
51
|
-
from vsslctrl import Vssl, Zone
|
|
52
|
+
from vsslctrl import Vssl, DeviceModels, Zone, ZoneIDs
|
|
52
53
|
|
|
53
54
|
async def main():
|
|
54
55
|
|
|
55
56
|
# Represents a physical VSSL amplifier
|
|
56
57
|
vssl = Vssl()
|
|
57
58
|
|
|
59
|
+
"""Optional to init Vssl with a device model
|
|
60
|
+
|
|
61
|
+
vssl = Vssl(DeviceModels.A3X)
|
|
62
|
+
|
|
63
|
+
If no DeviceModels is passed, vsslctrl will default to the feature set of the X series amps
|
|
64
|
+
"""
|
|
65
|
+
|
|
58
66
|
# Add each you wish to control
|
|
59
|
-
zone1 = vssl.add_zone(
|
|
60
|
-
zone2 = vssl.add_zone(
|
|
61
|
-
zone3 = vssl.add_zone(
|
|
67
|
+
zone1 = vssl.add_zone(ZoneIDs.ZONE_1, '192.168.1.10')
|
|
68
|
+
zone2 = vssl.add_zone(ZoneIDs.ZONE_2, '192.168.1.11')
|
|
69
|
+
zone3 = vssl.add_zone(ZoneIDs.ZONE_3, '192.168.1.12')
|
|
62
70
|
#... up to 6 zones
|
|
63
71
|
|
|
64
72
|
# Connect and initiate zones.
|
|
@@ -110,7 +118,7 @@ print(zone_name)
|
|
|
110
118
|
| ---------------------- | ----------- | ----------- |
|
|
111
119
|
| `sw_version` | Software version | `str` readonly
|
|
112
120
|
| `serial` | Serial number | `str` readonly
|
|
113
|
-
| `
|
|
121
|
+
| `model` | Device Model | `int` readonly
|
|
114
122
|
| `reboot()` | Reboot all zones | `func` |
|
|
115
123
|
|
|
116
124
|
```python
|
|
@@ -152,7 +160,7 @@ vssl.settings.power.adaptive = True
|
|
|
152
160
|
|
|
153
161
|
| Property | Description | Type | Values |
|
|
154
162
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
155
|
-
| `id` | Zone number / ID | `int` readonly | `
|
|
163
|
+
| `id` | Zone number / ID | `int` readonly | `ZoneIDs`
|
|
156
164
|
| `host` | IP address | `str` readonly
|
|
157
165
|
| `volume` | Volume | `int` | `0...100`
|
|
158
166
|
| `volume_raise([step=1])` | Raise volume by `step` | `func` | step: `int` `1...100`
|
|
@@ -219,7 +227,7 @@ zone1.transport.state = ZoneTransport.States.PAUSE
|
|
|
219
227
|
## `Zone.track`
|
|
220
228
|
|
|
221
229
|
* Not all sources have complete metadata - missing value will be set to defaults.
|
|
222
|
-
* Airplay track
|
|
230
|
+
* Airplay track `progress` is not avaiable.
|
|
223
231
|
|
|
224
232
|
| Property | Description | Type | Values |
|
|
225
233
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
@@ -256,20 +264,20 @@ Working on A.3x but offically unsupported in x series amplifiers.
|
|
|
256
264
|
|
|
257
265
|
| Property | Description | Type | Values |
|
|
258
266
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
259
|
-
| `source` | Zone ID of group master / source | `int` readonly | `
|
|
267
|
+
| `source` | Zone ID of group master / source | `int` readonly | `ZoneIDs`
|
|
260
268
|
| `is_master` | This zone is the group master | `bool` readonly
|
|
261
|
-
| `add_member()` | Add zone to group / create group | `func` | `
|
|
262
|
-
| `remove_member()` | Remove zone from group | `func` | `
|
|
269
|
+
| `add_member()` | Add zone to group / create group | `func` | `ZoneIDs`
|
|
270
|
+
| `remove_member()` | Remove zone from group | `func` | `ZoneIDs`
|
|
263
271
|
| `dissolve()` | Dissolve group / remove all members | `func` |
|
|
264
272
|
| `leave()` | Leave the group if a member | `func` |
|
|
265
273
|
|
|
266
274
|
```python
|
|
267
275
|
"""Examples"""
|
|
268
276
|
# Add group 2 to a group with zone 1 as master
|
|
269
|
-
zone1.group.add_member(
|
|
270
|
-
# Remove zone 2 from group
|
|
277
|
+
zone1.group.add_member(ZoneIDs.ZONE_2)
|
|
278
|
+
# Remove zone 2 from group
|
|
271
279
|
zone2.group.leave() # or
|
|
272
|
-
zone1.group.remove_member(
|
|
280
|
+
zone1.group.remove_member(ZoneIDs.ZONE_2)
|
|
273
281
|
# If zone 1 is a master, remove all members
|
|
274
282
|
zone1.group.dissolve()
|
|
275
283
|
```
|
|
@@ -371,6 +379,53 @@ EQ to be set in [decibel](https://en.wikipedia.org/wiki/Decibel) using a range `
|
|
|
371
379
|
zone1.settings.eq.khz1_db = -2
|
|
372
380
|
```
|
|
373
381
|
|
|
382
|
+
## Another (Lite) Way
|
|
383
|
+
|
|
384
|
+
If you perfer to not run the complete intergration, you can send basic HEX commands to the VSSL device using [Netcat](https://nc110.sourceforge.io/) (or any network tool) on port `50002`.
|
|
385
|
+
|
|
386
|
+
| HEX | Description |
|
|
387
|
+
| ---------------------- | ----------- |
|
|
388
|
+
| `\x10\x05\x03\x0{Zone Number}\xff\x03` | Volume Up
|
|
389
|
+
| `\x10\x05\x03\x0{Zone Number}\xfe\x03` | Volume Down
|
|
390
|
+
| `\x10\x11\x02\x0{Zone Number}\x01` | Mute
|
|
391
|
+
| All commands can be found by looking [here](https://github.com/vsslctrl/vsslctrl/blob/2c43c2f2393b94bc0e062d2ab90144343eca16ef/vsslctrl/api_alpha.py) |
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
The `volume up` HEX command for `Zone 2` would be `\x10\x05\x03\x02\xff\x03`
|
|
395
|
+
|
|
396
|
+
Now send the raw HEX using Netcat to the device using this syntax:
|
|
397
|
+
|
|
398
|
+
`echo -e "{HEX Command}" | nc {IP Address} 50002`
|
|
399
|
+
|
|
400
|
+
For example to send `volume up` to `Zone 2`:
|
|
401
|
+
|
|
402
|
+
`echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002`
|
|
403
|
+
|
|
404
|
+
Home Assistant `Configuration.yaml` example:
|
|
405
|
+
|
|
406
|
+
```ymal
|
|
407
|
+
...
|
|
408
|
+
|
|
409
|
+
shell_command:
|
|
410
|
+
#Zone 1
|
|
411
|
+
vssl_zone_1_volume_up: 'echo -e "\x10\x05\x03\x01\xff\x03" | nc 192.168.1.10 50002'
|
|
412
|
+
vssl_zone_1_volume_down: 'echo -e "\x10\x05\x03\x01\xfe\x03" | nc 192.168.1.10 50002'
|
|
413
|
+
vssl_zone_1_mute: 'echo -e "\x10\x11\x02\x01\x01" | nc 192.168.1.10 50002'
|
|
414
|
+
vssl_zone_1_unmute: 'echo -e "\x10\x11\x02\x01\x00" | nc 192.168.1.10 50002'
|
|
415
|
+
#Zone 2
|
|
416
|
+
vssl_zone_2_volume_up: 'echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002'
|
|
417
|
+
vssl_zone_2_volume_down: 'echo -e "\x10\x05\x03\x02\xfe\x03" | nc 192.168.1.11 50002'
|
|
418
|
+
vssl_zone_2_mute: 'echo -e "\x10\x11\x02\x02\x01" | nc 192.168.1.11 50002'
|
|
419
|
+
vssl_zone_2_unmute: 'echo -e "\x10\x11\x02\x02\x00" | nc 192.168.1.11 50002'
|
|
420
|
+
#Zone 3
|
|
421
|
+
vssl_zone_3_volume_up: 'echo -e "\x10\x05\x03\x03\xff\x03" | nc 192.168.1.12 50002'
|
|
422
|
+
vssl_zone_3_volume_down: 'echo -e "\x10\x05\x03\x03\xfe\x03" | nc 192.168.1.12 50002'
|
|
423
|
+
vssl_zone_3_mute: 'echo -e "\x10\x11\x02\x03\x01" | nc 192.168.1.12 50002'
|
|
424
|
+
vssl_zone_3_unmute: 'echo -e "\x10\x11\x02\x03\x00" | nc 192.168.1.12 50002'
|
|
425
|
+
|
|
426
|
+
...
|
|
427
|
+
```
|
|
428
|
+
|
|
374
429
|
## Credit
|
|
375
430
|
|
|
376
431
|
The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS app and their deprecated [vsslagent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start).
|
|
@@ -382,6 +437,7 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
|
|
|
382
437
|
* VSSL can not start a stream except for playing a URL directly. This is a limitation of the hardware itself.
|
|
383
438
|
* Not all sources set the volume to 0 when the zone is muted
|
|
384
439
|
* Grouping feedback is flaky on the X series amplifiers
|
|
440
|
+
* Airplay `Zone.track.progress` is not avaiable.
|
|
385
441
|
* Cant stop a URL playback, feedback is worng at least
|
|
386
442
|
* VSSL likes to cache old track metadata. For example when playing a URL after Spotify, often the device will respond with the previous (Spotify) tracks metadata
|
|
387
443
|
* `stop()` is intended to disconnect the client and pause the stream. Doesnt always function this way, depending on stream source
|
|
@@ -392,4 +448,5 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
|
|
|
392
448
|
* REST API / Web App
|
|
393
449
|
* Save and recall EQ
|
|
394
450
|
* A.1(x) coverage i.e Bluetooth
|
|
451
|
+
* IR Control
|
|
395
452
|
|
|
@@ -7,37 +7,45 @@
|
|
|
7
7
|
|
|
8
8
|
I am looking for testers with any VSSL amplifier models, please get in touch if you interested in helping.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Important
|
|
11
|
+
-----------
|
|
12
|
+
Only tested on a VSSL **A.3x** software version **p15305.016.3701**.
|
|
13
|
+
|
|
14
|
+
**Notice:** VSSLs new iOS app titled `VSSL`can cause connection refused issues if running at the same time as `vsslctrl`. Best to not use the iOS app togeather. The `VSSL Legacy` app works fine
|
|
15
|
+
|
|
16
|
+
**Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
|
|
11
17
|
|
|
12
18
|
## TODOs
|
|
13
19
|
|
|
14
20
|
* Test on other models (hardware needed)
|
|
15
21
|
* Home Assistant integration. In progress, [here](https://github.com/vsslctrl/integration.home-assistant)
|
|
16
|
-
* Function scoping to supported feature / models
|
|
22
|
+
* ~~Function scoping to supported feature / models~~ (needs tests)
|
|
17
23
|
* Better test coverage
|
|
18
24
|
|
|
19
|
-
Important
|
|
20
|
-
-----------
|
|
21
|
-
**Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
|
|
22
|
-
|
|
23
|
-
|
|
24
25
|
Basic Usage
|
|
25
26
|
-----------
|
|
26
27
|
`vsslctrl` needs to be running inside a **[asyncio](https://docs.python.org/3/library/asyncio.html)** event loop.
|
|
27
28
|
|
|
28
29
|
```python
|
|
29
30
|
import asyncio
|
|
30
|
-
from vsslctrl import Vssl, Zone
|
|
31
|
+
from vsslctrl import Vssl, DeviceModels, Zone, ZoneIDs
|
|
31
32
|
|
|
32
33
|
async def main():
|
|
33
34
|
|
|
34
35
|
# Represents a physical VSSL amplifier
|
|
35
36
|
vssl = Vssl()
|
|
36
37
|
|
|
38
|
+
"""Optional to init Vssl with a device model
|
|
39
|
+
|
|
40
|
+
vssl = Vssl(DeviceModels.A3X)
|
|
41
|
+
|
|
42
|
+
If no DeviceModels is passed, vsslctrl will default to the feature set of the X series amps
|
|
43
|
+
"""
|
|
44
|
+
|
|
37
45
|
# Add each you wish to control
|
|
38
|
-
zone1 = vssl.add_zone(
|
|
39
|
-
zone2 = vssl.add_zone(
|
|
40
|
-
zone3 = vssl.add_zone(
|
|
46
|
+
zone1 = vssl.add_zone(ZoneIDs.ZONE_1, '192.168.1.10')
|
|
47
|
+
zone2 = vssl.add_zone(ZoneIDs.ZONE_2, '192.168.1.11')
|
|
48
|
+
zone3 = vssl.add_zone(ZoneIDs.ZONE_3, '192.168.1.12')
|
|
41
49
|
#... up to 6 zones
|
|
42
50
|
|
|
43
51
|
# Connect and initiate zones.
|
|
@@ -89,7 +97,7 @@ print(zone_name)
|
|
|
89
97
|
| ---------------------- | ----------- | ----------- |
|
|
90
98
|
| `sw_version` | Software version | `str` readonly
|
|
91
99
|
| `serial` | Serial number | `str` readonly
|
|
92
|
-
| `
|
|
100
|
+
| `model` | Device Model | `int` readonly
|
|
93
101
|
| `reboot()` | Reboot all zones | `func` |
|
|
94
102
|
|
|
95
103
|
```python
|
|
@@ -131,7 +139,7 @@ vssl.settings.power.adaptive = True
|
|
|
131
139
|
|
|
132
140
|
| Property | Description | Type | Values |
|
|
133
141
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
134
|
-
| `id` | Zone number / ID | `int` readonly | `
|
|
142
|
+
| `id` | Zone number / ID | `int` readonly | `ZoneIDs`
|
|
135
143
|
| `host` | IP address | `str` readonly
|
|
136
144
|
| `volume` | Volume | `int` | `0...100`
|
|
137
145
|
| `volume_raise([step=1])` | Raise volume by `step` | `func` | step: `int` `1...100`
|
|
@@ -198,7 +206,7 @@ zone1.transport.state = ZoneTransport.States.PAUSE
|
|
|
198
206
|
## `Zone.track`
|
|
199
207
|
|
|
200
208
|
* Not all sources have complete metadata - missing value will be set to defaults.
|
|
201
|
-
* Airplay track
|
|
209
|
+
* Airplay track `progress` is not avaiable.
|
|
202
210
|
|
|
203
211
|
| Property | Description | Type | Values |
|
|
204
212
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
@@ -235,20 +243,20 @@ Working on A.3x but offically unsupported in x series amplifiers.
|
|
|
235
243
|
|
|
236
244
|
| Property | Description | Type | Values |
|
|
237
245
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
238
|
-
| `source` | Zone ID of group master / source | `int` readonly | `
|
|
246
|
+
| `source` | Zone ID of group master / source | `int` readonly | `ZoneIDs`
|
|
239
247
|
| `is_master` | This zone is the group master | `bool` readonly
|
|
240
|
-
| `add_member()` | Add zone to group / create group | `func` | `
|
|
241
|
-
| `remove_member()` | Remove zone from group | `func` | `
|
|
248
|
+
| `add_member()` | Add zone to group / create group | `func` | `ZoneIDs`
|
|
249
|
+
| `remove_member()` | Remove zone from group | `func` | `ZoneIDs`
|
|
242
250
|
| `dissolve()` | Dissolve group / remove all members | `func` |
|
|
243
251
|
| `leave()` | Leave the group if a member | `func` |
|
|
244
252
|
|
|
245
253
|
```python
|
|
246
254
|
"""Examples"""
|
|
247
255
|
# Add group 2 to a group with zone 1 as master
|
|
248
|
-
zone1.group.add_member(
|
|
249
|
-
# Remove zone 2 from group
|
|
256
|
+
zone1.group.add_member(ZoneIDs.ZONE_2)
|
|
257
|
+
# Remove zone 2 from group
|
|
250
258
|
zone2.group.leave() # or
|
|
251
|
-
zone1.group.remove_member(
|
|
259
|
+
zone1.group.remove_member(ZoneIDs.ZONE_2)
|
|
252
260
|
# If zone 1 is a master, remove all members
|
|
253
261
|
zone1.group.dissolve()
|
|
254
262
|
```
|
|
@@ -350,6 +358,53 @@ EQ to be set in [decibel](https://en.wikipedia.org/wiki/Decibel) using a range `
|
|
|
350
358
|
zone1.settings.eq.khz1_db = -2
|
|
351
359
|
```
|
|
352
360
|
|
|
361
|
+
## Another (Lite) Way
|
|
362
|
+
|
|
363
|
+
If you perfer to not run the complete intergration, you can send basic HEX commands to the VSSL device using [Netcat](https://nc110.sourceforge.io/) (or any network tool) on port `50002`.
|
|
364
|
+
|
|
365
|
+
| HEX | Description |
|
|
366
|
+
| ---------------------- | ----------- |
|
|
367
|
+
| `\x10\x05\x03\x0{Zone Number}\xff\x03` | Volume Up
|
|
368
|
+
| `\x10\x05\x03\x0{Zone Number}\xfe\x03` | Volume Down
|
|
369
|
+
| `\x10\x11\x02\x0{Zone Number}\x01` | Mute
|
|
370
|
+
| All commands can be found by looking [here](https://github.com/vsslctrl/vsslctrl/blob/2c43c2f2393b94bc0e062d2ab90144343eca16ef/vsslctrl/api_alpha.py) |
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
The `volume up` HEX command for `Zone 2` would be `\x10\x05\x03\x02\xff\x03`
|
|
374
|
+
|
|
375
|
+
Now send the raw HEX using Netcat to the device using this syntax:
|
|
376
|
+
|
|
377
|
+
`echo -e "{HEX Command}" | nc {IP Address} 50002`
|
|
378
|
+
|
|
379
|
+
For example to send `volume up` to `Zone 2`:
|
|
380
|
+
|
|
381
|
+
`echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002`
|
|
382
|
+
|
|
383
|
+
Home Assistant `Configuration.yaml` example:
|
|
384
|
+
|
|
385
|
+
```ymal
|
|
386
|
+
...
|
|
387
|
+
|
|
388
|
+
shell_command:
|
|
389
|
+
#Zone 1
|
|
390
|
+
vssl_zone_1_volume_up: 'echo -e "\x10\x05\x03\x01\xff\x03" | nc 192.168.1.10 50002'
|
|
391
|
+
vssl_zone_1_volume_down: 'echo -e "\x10\x05\x03\x01\xfe\x03" | nc 192.168.1.10 50002'
|
|
392
|
+
vssl_zone_1_mute: 'echo -e "\x10\x11\x02\x01\x01" | nc 192.168.1.10 50002'
|
|
393
|
+
vssl_zone_1_unmute: 'echo -e "\x10\x11\x02\x01\x00" | nc 192.168.1.10 50002'
|
|
394
|
+
#Zone 2
|
|
395
|
+
vssl_zone_2_volume_up: 'echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002'
|
|
396
|
+
vssl_zone_2_volume_down: 'echo -e "\x10\x05\x03\x02\xfe\x03" | nc 192.168.1.11 50002'
|
|
397
|
+
vssl_zone_2_mute: 'echo -e "\x10\x11\x02\x02\x01" | nc 192.168.1.11 50002'
|
|
398
|
+
vssl_zone_2_unmute: 'echo -e "\x10\x11\x02\x02\x00" | nc 192.168.1.11 50002'
|
|
399
|
+
#Zone 3
|
|
400
|
+
vssl_zone_3_volume_up: 'echo -e "\x10\x05\x03\x03\xff\x03" | nc 192.168.1.12 50002'
|
|
401
|
+
vssl_zone_3_volume_down: 'echo -e "\x10\x05\x03\x03\xfe\x03" | nc 192.168.1.12 50002'
|
|
402
|
+
vssl_zone_3_mute: 'echo -e "\x10\x11\x02\x03\x01" | nc 192.168.1.12 50002'
|
|
403
|
+
vssl_zone_3_unmute: 'echo -e "\x10\x11\x02\x03\x00" | nc 192.168.1.12 50002'
|
|
404
|
+
|
|
405
|
+
...
|
|
406
|
+
```
|
|
407
|
+
|
|
353
408
|
## Credit
|
|
354
409
|
|
|
355
410
|
The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS app and their deprecated [vsslagent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start).
|
|
@@ -361,6 +416,7 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
|
|
|
361
416
|
* VSSL can not start a stream except for playing a URL directly. This is a limitation of the hardware itself.
|
|
362
417
|
* Not all sources set the volume to 0 when the zone is muted
|
|
363
418
|
* Grouping feedback is flaky on the X series amplifiers
|
|
419
|
+
* Airplay `Zone.track.progress` is not avaiable.
|
|
364
420
|
* Cant stop a URL playback, feedback is worng at least
|
|
365
421
|
* VSSL likes to cache old track metadata. For example when playing a URL after Spotify, often the device will respond with the previous (Spotify) tracks metadata
|
|
366
422
|
* `stop()` is intended to disconnect the client and pause the stream. Doesnt always function this way, depending on stream source
|
|
@@ -371,4 +427,5 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
|
|
|
371
427
|
* REST API / Web App
|
|
372
428
|
* Save and recall EQ
|
|
373
429
|
* A.1(x) coverage i.e Bluetooth
|
|
430
|
+
* IR Control
|
|
374
431
|
|
|
@@ -558,8 +558,8 @@ class APIAlpha(APIBase):
|
|
|
558
558
|
def response_action_00_00(self, metadata: list):
|
|
559
559
|
self._log_debug(f"Received 00 Status: {metadata}")
|
|
560
560
|
|
|
561
|
-
#
|
|
562
|
-
self.vssl.
|
|
561
|
+
# Guess device model
|
|
562
|
+
self.vssl._infer_device_model(metadata)
|
|
563
563
|
|
|
564
564
|
# Analog output source
|
|
565
565
|
key = DeviceStatusExtKeys.add_zone_to_bus_key(self.zone.id)
|
|
@@ -158,6 +158,19 @@ class APIBase(ABC):
|
|
|
158
158
|
|
|
159
159
|
except asyncio.CancelledError:
|
|
160
160
|
self._log_debug(f"writer close timeout")
|
|
161
|
+
except asyncio.TimeoutError as e:
|
|
162
|
+
self._log_error(f"Timeout while closing connection: {e}")
|
|
163
|
+
# Handle the timeout: log, retry, or take other actions
|
|
164
|
+
except ConnectionResetError as e:
|
|
165
|
+
self._log_error(f"Connection reset by peer: {e}")
|
|
166
|
+
# Handle the error: log, retry, or take other actions
|
|
167
|
+
except Exception as e:
|
|
168
|
+
self._log_error(f"Unexpected error occurred while disconnecting: {e}")
|
|
169
|
+
# Handle unexpected errors
|
|
170
|
+
finally:
|
|
171
|
+
self._writer = None
|
|
172
|
+
|
|
173
|
+
self._reader = None
|
|
161
174
|
|
|
162
175
|
self._log_info(f"{self.host}:{self.port}: disconnected")
|
|
163
176
|
|
|
@@ -10,7 +10,8 @@ from .event_bus import EventBus
|
|
|
10
10
|
from .settings import VsslSettings
|
|
11
11
|
from .decorators import logging_helpers
|
|
12
12
|
from .discovery import check_zeroconf_availability, fetch_zone_id_serial
|
|
13
|
-
from .
|
|
13
|
+
from .device import Models
|
|
14
|
+
from .data_structure import ZoneIDs
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@logging_helpers("VSSL:")
|
|
@@ -23,14 +24,13 @@ class Vssl:
|
|
|
23
24
|
class Events:
|
|
24
25
|
PREFIX = "vssl."
|
|
25
26
|
MODEL_CHANGE = PREFIX + "model_changed"
|
|
26
|
-
MODEL_ZONE_QTY_CHANGE = PREFIX + "model_zone_qty_changed"
|
|
27
27
|
SW_VERSION_CHANGE = PREFIX + "sw_version_changed"
|
|
28
28
|
SERIAL_CHANGE = PREFIX + "serial_changed"
|
|
29
29
|
ALL = EventBus.WILDCARD
|
|
30
30
|
|
|
31
31
|
def __init__(
|
|
32
32
|
self,
|
|
33
|
-
model:
|
|
33
|
+
model: Models = None,
|
|
34
34
|
zones: Union[str, List[str]] = None,
|
|
35
35
|
):
|
|
36
36
|
self.event_bus = EventBus()
|
|
@@ -38,10 +38,10 @@ class Vssl:
|
|
|
38
38
|
self._sw_version = None
|
|
39
39
|
self._serial = None
|
|
40
40
|
self._model = None
|
|
41
|
-
self._model_zone_qty = 0
|
|
42
41
|
self.settings = VsslSettings(self)
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
if model is not None:
|
|
44
|
+
self.model = model
|
|
45
45
|
|
|
46
46
|
# Add zones if any are passed
|
|
47
47
|
if zones:
|
|
@@ -62,22 +62,22 @@ class Vssl:
|
|
|
62
62
|
try:
|
|
63
63
|
key, first_zone = zones_to_init.popitem()
|
|
64
64
|
|
|
65
|
-
# If we pass a model
|
|
66
|
-
#
|
|
67
|
-
if not self.
|
|
68
|
-
|
|
69
|
-
self.Events.
|
|
65
|
+
# If we dont pass a model we will default to the zone count of the X series amps once we
|
|
66
|
+
# know some info about the device
|
|
67
|
+
if not self.model:
|
|
68
|
+
future_model = self.event_bus.future(
|
|
69
|
+
self.Events.MODEL_CHANGE, self.ENTITY_ID
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
# Lets make sure the zone is initialised, otherwsie we fail all
|
|
73
73
|
await first_zone.initialise()
|
|
74
74
|
|
|
75
|
-
# Only continue after we
|
|
75
|
+
# Only continue after we have a model
|
|
76
76
|
try:
|
|
77
|
-
if not self.
|
|
78
|
-
await asyncio.wait_for(
|
|
77
|
+
if not self.model:
|
|
78
|
+
await asyncio.wait_for(future_model, timeout=init_timeout)
|
|
79
79
|
|
|
80
|
-
if len(self.zones) > self.
|
|
80
|
+
if len(self.zones) > self.model.zone_count:
|
|
81
81
|
raise VsslCtrlException("")
|
|
82
82
|
|
|
83
83
|
except asyncio.TimeoutError:
|
|
@@ -87,7 +87,8 @@ class Vssl:
|
|
|
87
87
|
raise VsslCtrlException(message)
|
|
88
88
|
|
|
89
89
|
except VsslCtrlException:
|
|
90
|
-
|
|
90
|
+
zone_count = self.model.zone_count
|
|
91
|
+
message = f"Device model only has {zone_count} zones instead of {len(self.zones)}"
|
|
91
92
|
self._log_critical(message)
|
|
92
93
|
await first_zone.disconnect()
|
|
93
94
|
raise VsslCtrlException(message)
|
|
@@ -173,47 +174,32 @@ class Vssl:
|
|
|
173
174
|
return self._model
|
|
174
175
|
|
|
175
176
|
@model.setter
|
|
176
|
-
def model(self, model
|
|
177
|
-
if
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
self.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
# but how many zones the model has in total. We can have 1, 3 or 6 zones.
|
|
188
|
-
#
|
|
189
|
-
@property
|
|
190
|
-
def model_zone_qty(self):
|
|
191
|
-
if not self._model_zone_qty and self.model is not None:
|
|
192
|
-
self._model_zone_qty = DeviceModels.zone_count(self.model.value)
|
|
193
|
-
|
|
194
|
-
return self._model_zone_qty
|
|
195
|
-
|
|
196
|
-
@model_zone_qty.setter
|
|
197
|
-
def model_zone_qty(self, model_zone_qty: int):
|
|
198
|
-
pass # read-only
|
|
177
|
+
def model(self, model):
|
|
178
|
+
if Models.is_valid(model):
|
|
179
|
+
self._set_property("model", Models(model).value)
|
|
180
|
+
elif isinstance(model, str):
|
|
181
|
+
model = model.upper()
|
|
182
|
+
if hasattr(Models, model):
|
|
183
|
+
self._set_property("model", getattr(Models, model).value)
|
|
184
|
+
else:
|
|
185
|
+
message = f"Model {model} doesnt exist"
|
|
186
|
+
self._log_error(message)
|
|
187
|
+
raise VsslCtrlException(message)
|
|
199
188
|
|
|
200
189
|
#
|
|
201
|
-
# Work out the
|
|
190
|
+
# Work out the a model given some device info
|
|
202
191
|
#
|
|
203
|
-
def
|
|
204
|
-
if
|
|
192
|
+
def _infer_device_model(self, data: Dict[str, int]):
|
|
193
|
+
# if we dont have a model, default to x series
|
|
194
|
+
if not self.model:
|
|
205
195
|
zone_count = sum(
|
|
206
196
|
1 for key in data if key.startswith("B") and key.endswith("Src")
|
|
207
197
|
)
|
|
208
198
|
|
|
209
199
|
if zone_count in (1, 3, 6):
|
|
210
|
-
self.
|
|
200
|
+
self.model = f"A{zone_count}X"
|
|
211
201
|
else:
|
|
212
|
-
self.
|
|
213
|
-
|
|
214
|
-
self.event_bus.publish(
|
|
215
|
-
self.Events.MODEL_ZONE_QTY_CHANGE, self.ENTITY_ID, self.model_zone_qty
|
|
216
|
-
)
|
|
202
|
+
self.model = Models.A1X
|
|
217
203
|
|
|
218
204
|
#
|
|
219
205
|
# Disconnect / Shutdown
|
|
@@ -234,9 +220,9 @@ class Vssl:
|
|
|
234
220
|
#
|
|
235
221
|
# Add a Zone
|
|
236
222
|
#
|
|
237
|
-
def add_zone(self, zone_index:
|
|
238
|
-
if
|
|
239
|
-
error = f"
|
|
223
|
+
def add_zone(self, zone_index: ZoneIDs, host: str):
|
|
224
|
+
if ZoneIDs.is_not_valid(zone_index):
|
|
225
|
+
error = f"ZoneIDs {zone_index} doesnt exist"
|
|
240
226
|
self._log_error(error)
|
|
241
227
|
raise ZoneError(error)
|
|
242
228
|
return None
|
|
@@ -262,7 +248,7 @@ class Vssl:
|
|
|
262
248
|
#
|
|
263
249
|
# Get a Zone by ID
|
|
264
250
|
#
|
|
265
|
-
def get_zone(self, zone_index:
|
|
251
|
+
def get_zone(self, zone_index: ZoneIDs):
|
|
266
252
|
if zone_index in self.zones:
|
|
267
253
|
return self.zones[zone_index]
|
|
268
254
|
else:
|