vsslctrl 0.1.11.dev1__py3-none-any.whl → 0.1.13.dev1__py3-none-any.whl
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/__init__.py +1 -1
- vsslctrl/api_alpha.py +33 -29
- vsslctrl/api_base.py +25 -0
- vsslctrl/api_bravo.py +6 -0
- vsslctrl/core.py +98 -107
- vsslctrl/data_structure.py +2 -1
- vsslctrl/event_bus.py +23 -26
- vsslctrl/group.py +1 -2
- vsslctrl/track.py +3 -4
- vsslctrl/transport.py +1 -1
- vsslctrl/utils.py +11 -0
- vsslctrl/zone.py +28 -13
- {vsslctrl-0.1.11.dev1.dist-info → vsslctrl-0.1.13.dev1.dist-info}/METADATA +126 -90
- vsslctrl-0.1.13.dev1.dist-info/RECORD +23 -0
- {vsslctrl-0.1.11.dev1.dist-info → vsslctrl-0.1.13.dev1.dist-info}/WHEEL +1 -1
- vsslctrl-0.1.11.dev1.dist-info/RECORD +0 -23
- {vsslctrl-0.1.11.dev1.dist-info → vsslctrl-0.1.13.dev1.dist-info}/LICENSE +0 -0
- {vsslctrl-0.1.11.dev1.dist-info → vsslctrl-0.1.13.dev1.dist-info}/top_level.txt +0 -0
vsslctrl/__init__.py
CHANGED
vsslctrl/api_alpha.py
CHANGED
|
@@ -39,6 +39,12 @@ class APIAlpha(APIBase):
|
|
|
39
39
|
self.vssl = vssl_host
|
|
40
40
|
self.zone = zone
|
|
41
41
|
|
|
42
|
+
#
|
|
43
|
+
# Send event on event bus
|
|
44
|
+
#
|
|
45
|
+
def _event_publish(self, event_type, data=None):
|
|
46
|
+
self.zone._event_publish(event_type, (self.zone.host, self.TCP_PORT))
|
|
47
|
+
|
|
42
48
|
#
|
|
43
49
|
# Send keep alive
|
|
44
50
|
#
|
|
@@ -165,6 +171,23 @@ class APIAlpha(APIBase):
|
|
|
165
171
|
self._log_debug("Requesting to factory reset device")
|
|
166
172
|
self.send(bytearray([16, 43, 2, 8, 0]))
|
|
167
173
|
|
|
174
|
+
#
|
|
175
|
+
# Rename Functions
|
|
176
|
+
#
|
|
177
|
+
def _request_action_rename(self, name: str, command_byte: int, id_byte: int = 0):
|
|
178
|
+
"""
|
|
179
|
+
General method to send a request to change a name.
|
|
180
|
+
:param name: The new name to set.
|
|
181
|
+
:param command_byte: The subcommand byte (e.g., 21 or 24).
|
|
182
|
+
:param id_byte: The ID byte to specify the target (default is 0).
|
|
183
|
+
"""
|
|
184
|
+
name = name.strip()
|
|
185
|
+
command = bytearray([16, command_byte])
|
|
186
|
+
command.extend(struct.pack(">B", len(name) + 1))
|
|
187
|
+
command.extend([id_byte])
|
|
188
|
+
command.extend(name.encode("utf-8"))
|
|
189
|
+
self.send(command)
|
|
190
|
+
|
|
168
191
|
#
|
|
169
192
|
# 15 [21]
|
|
170
193
|
# Set Analog Input Name / Rename Analog Input
|
|
@@ -172,27 +195,16 @@ class APIAlpha(APIBase):
|
|
|
172
195
|
# TODO: Does this work on A.1(x)?
|
|
173
196
|
#
|
|
174
197
|
def request_action_15(self, name: str):
|
|
175
|
-
name = name.strip()
|
|
176
198
|
self._log_debug(f"Requesting to change analog input name: {name}")
|
|
177
|
-
|
|
178
|
-
command.extend(struct.pack(">B", len(name) + 1))
|
|
179
|
-
command.extend([0]) # zone id placeholder
|
|
180
|
-
command = self._add_zone_id_to_request(command)
|
|
181
|
-
command.extend(name.encode("utf-8"))
|
|
182
|
-
self.send(command)
|
|
199
|
+
self._request_action_rename(name, 21, self.zone.id)
|
|
183
200
|
|
|
184
201
|
#
|
|
185
202
|
# 15 [21]
|
|
186
203
|
# Set Bus 1 Name
|
|
187
204
|
#
|
|
188
205
|
def request_action_15_10(self, name: str):
|
|
189
|
-
name = name.strip()
|
|
190
206
|
self._log_debug(f"Requesting to change bus 1 name: {name}")
|
|
191
|
-
|
|
192
|
-
command.extend(struct.pack(">B", len(name) + 1))
|
|
193
|
-
command.extend([10])
|
|
194
|
-
command.extend(name.encode("utf-8"))
|
|
195
|
-
self.send(command)
|
|
207
|
+
self._request_action_rename(name, 21, 10)
|
|
196
208
|
|
|
197
209
|
#
|
|
198
210
|
# 15 [21]
|
|
@@ -201,11 +213,7 @@ class APIAlpha(APIBase):
|
|
|
201
213
|
def request_action_15_12(self, name: str):
|
|
202
214
|
name = name.strip()
|
|
203
215
|
self._log_debug(f"Requesting to change bus 2 name: {name}")
|
|
204
|
-
|
|
205
|
-
command.extend(struct.pack(">B", len(name) + 1))
|
|
206
|
-
command.extend([12])
|
|
207
|
-
command.extend(name.encode("utf-8"))
|
|
208
|
-
self.send(command)
|
|
216
|
+
self._request_action_rename(name, 21, 12)
|
|
209
217
|
|
|
210
218
|
#
|
|
211
219
|
# 18 [24]
|
|
@@ -214,17 +222,13 @@ class APIAlpha(APIBase):
|
|
|
214
222
|
def request_action_18(self, name: str):
|
|
215
223
|
name = name.strip()
|
|
216
224
|
self._log_debug(f"Requesting to change device name: {name}")
|
|
217
|
-
|
|
218
|
-
command.extend(struct.pack(">B", len(name) + 1))
|
|
219
|
-
command.extend([7])
|
|
220
|
-
command.extend(name.encode("utf-8"))
|
|
221
|
-
self.send(command)
|
|
225
|
+
self._request_action_rename(name, 24, 7)
|
|
222
226
|
|
|
223
227
|
#
|
|
224
228
|
# 4F [79]
|
|
225
229
|
# Adaptive Power
|
|
226
230
|
#
|
|
227
|
-
def request_action_4F(self, state=True):
|
|
231
|
+
def request_action_4F(self, state: bool = True):
|
|
228
232
|
self._log_debug(f"Requesting to set adaptive power state: {state}")
|
|
229
233
|
# Device level command (dont need zone)
|
|
230
234
|
command = bytearray([16, 79, 2, 8, int(state)])
|
|
@@ -665,10 +669,6 @@ class APIAlpha(APIBase):
|
|
|
665
669
|
def response_action_00_00(self, metadata: list):
|
|
666
670
|
self._log_debug(f"Received 00 Status: {metadata}")
|
|
667
671
|
|
|
668
|
-
# Guess device model
|
|
669
|
-
# TODO: remove?
|
|
670
|
-
self.vssl._infer_device_model(metadata)
|
|
671
|
-
|
|
672
672
|
# Analog output source
|
|
673
673
|
# TODO: this need to be global (on the core?)
|
|
674
674
|
key = DeviceStatusExtKeys.add_zone_to_bus_key(self.zone.id)
|
|
@@ -868,7 +868,7 @@ class APIAlpha(APIBase):
|
|
|
868
868
|
)
|
|
869
869
|
|
|
870
870
|
# Analog Output Fix Volume
|
|
871
|
-
#
|
|
871
|
+
# e.g BF1
|
|
872
872
|
#
|
|
873
873
|
# TODO, this not great for A1
|
|
874
874
|
#
|
|
@@ -944,6 +944,8 @@ class APIAlpha(APIBase):
|
|
|
944
944
|
# 16 [22]
|
|
945
945
|
# Received Analog Input Name
|
|
946
946
|
#
|
|
947
|
+
# Note: Only received on Zone 1
|
|
948
|
+
#
|
|
947
949
|
# TODO, maybe this should be global with the analog outputs.
|
|
948
950
|
#
|
|
949
951
|
def response_action_16(self, hexl: list, response: bytes):
|
|
@@ -1223,6 +1225,8 @@ class APIAlpha(APIBase):
|
|
|
1223
1225
|
# 50 [80]
|
|
1224
1226
|
# Adaptive Power Feedback
|
|
1225
1227
|
#
|
|
1228
|
+
# Note: Only received on Zone 1
|
|
1229
|
+
#
|
|
1226
1230
|
@validate_response_length()
|
|
1227
1231
|
@validate_response_zone_id()
|
|
1228
1232
|
def response_action_50(self, hexl: list, response: bytes):
|
vsslctrl/api_base.py
CHANGED
|
@@ -45,6 +45,17 @@ class APIBase(ABC):
|
|
|
45
45
|
|
|
46
46
|
FRIST_BYTE = 1
|
|
47
47
|
|
|
48
|
+
#
|
|
49
|
+
# API Events
|
|
50
|
+
#
|
|
51
|
+
class Events:
|
|
52
|
+
PREFIX = "zone.api."
|
|
53
|
+
CONNECTING = PREFIX + "connecting"
|
|
54
|
+
CONNECTED = PREFIX + "connected"
|
|
55
|
+
DISCONNECTING = PREFIX + "disconnecting"
|
|
56
|
+
DISCONNECTED = PREFIX + "disconnected"
|
|
57
|
+
RECONNECTING = PREFIX + "reconnecting"
|
|
58
|
+
|
|
48
59
|
def __init__(self, host, port):
|
|
49
60
|
self.host = host
|
|
50
61
|
self.port = port
|
|
@@ -89,6 +100,7 @@ class APIBase(ABC):
|
|
|
89
100
|
return self.connected
|
|
90
101
|
|
|
91
102
|
self._connecting = True
|
|
103
|
+
self._event_publish(self.Events.CONNECTING)
|
|
92
104
|
|
|
93
105
|
try:
|
|
94
106
|
self._log_debug(f"Attemping connection to {self.host}:{self.port}")
|
|
@@ -98,6 +110,7 @@ class APIBase(ABC):
|
|
|
98
110
|
|
|
99
111
|
# Connected
|
|
100
112
|
self.connection_event.set()
|
|
113
|
+
self._event_publish(self.Events.CONNECTED)
|
|
101
114
|
|
|
102
115
|
# cancel any reconnecting loops
|
|
103
116
|
self._cancel_keep_connected()
|
|
@@ -140,6 +153,7 @@ class APIBase(ABC):
|
|
|
140
153
|
@final
|
|
141
154
|
async def disconnect(self):
|
|
142
155
|
self._disconnecting = True
|
|
156
|
+
self._event_publish(self.Events.DISCONNECTING)
|
|
143
157
|
|
|
144
158
|
# cancel any reconnecting loops
|
|
145
159
|
self._cancel_keep_connected()
|
|
@@ -176,6 +190,8 @@ class APIBase(ABC):
|
|
|
176
190
|
|
|
177
191
|
self._disconnecting = False
|
|
178
192
|
|
|
193
|
+
self._event_publish(self.Events.DISCONNECTED)
|
|
194
|
+
|
|
179
195
|
return not self.connected
|
|
180
196
|
|
|
181
197
|
#
|
|
@@ -184,7 +200,9 @@ class APIBase(ABC):
|
|
|
184
200
|
@final
|
|
185
201
|
async def reconnect(self):
|
|
186
202
|
if not self._reconnecting and not self._is_keep_connected_running():
|
|
203
|
+
self._event_publish(self.Events.RECONNECTING)
|
|
187
204
|
await self.disconnect()
|
|
205
|
+
await asyncio.sleep(1)
|
|
188
206
|
self._keep_connected()
|
|
189
207
|
|
|
190
208
|
#
|
|
@@ -306,6 +324,13 @@ class APIBase(ABC):
|
|
|
306
324
|
async def _read_byte_stream(self):
|
|
307
325
|
pass
|
|
308
326
|
|
|
327
|
+
#
|
|
328
|
+
# Send event on event bus
|
|
329
|
+
#
|
|
330
|
+
@abstractmethod
|
|
331
|
+
def _event_publish(self, event_type, data=None):
|
|
332
|
+
pass
|
|
333
|
+
|
|
309
334
|
#
|
|
310
335
|
# Send a keep alive
|
|
311
336
|
#
|
vsslctrl/api_bravo.py
CHANGED
|
@@ -23,6 +23,12 @@ class APIBravo(APIBase):
|
|
|
23
23
|
self.vssl = vssl_host
|
|
24
24
|
self.zone = zone
|
|
25
25
|
|
|
26
|
+
#
|
|
27
|
+
# Send event on event bus
|
|
28
|
+
#
|
|
29
|
+
def _event_publish(self, event_type, data=None):
|
|
30
|
+
self.zone._event_publish(event_type, (self.zone.host, self.TCP_PORT))
|
|
31
|
+
|
|
26
32
|
#
|
|
27
33
|
# Send keep alive
|
|
28
34
|
#
|
vsslctrl/core.py
CHANGED
|
@@ -24,70 +24,51 @@ class Vssl:
|
|
|
24
24
|
#
|
|
25
25
|
class Events:
|
|
26
26
|
PREFIX = "vssl."
|
|
27
|
+
INITIALISED = PREFIX + "initialised"
|
|
27
28
|
MODEL_CHANGE = PREFIX + "model_changed"
|
|
28
29
|
SW_VERSION_CHANGE = PREFIX + "sw_version_changed"
|
|
29
30
|
SERIAL_CHANGE = PREFIX + "serial_changed"
|
|
30
|
-
ALL = EventBus.WILDCARD
|
|
31
31
|
|
|
32
|
-
def __init__(
|
|
33
|
-
self,
|
|
34
|
-
model: Models = None,
|
|
35
|
-
zones: Union[str, List[str]] = None,
|
|
36
|
-
):
|
|
32
|
+
def __init__(self, model: Models):
|
|
37
33
|
self.event_bus = EventBus()
|
|
34
|
+
self.initialisation = asyncio.Event()
|
|
38
35
|
self.zones = {}
|
|
39
36
|
self._sw_version = None
|
|
40
37
|
self._serial = None
|
|
41
38
|
self._model = None
|
|
39
|
+
self.model = model
|
|
42
40
|
self.settings = VsslSettings(self)
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if zones:
|
|
49
|
-
self.add_zones(zones)
|
|
42
|
+
@property
|
|
43
|
+
def initialised(self):
|
|
44
|
+
"""Initialised Event"""
|
|
45
|
+
return self.initialisation.is_set()
|
|
50
46
|
|
|
51
47
|
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# We init all the zones sequentially, so we can do some error checking
|
|
55
|
-
# and fail if any of the zones are in error
|
|
48
|
+
# Initialize the zones
|
|
56
49
|
#
|
|
57
50
|
async def initialise(self, init_timeout: int = 10):
|
|
58
51
|
if len(self.zones) < 1:
|
|
59
|
-
raise VsslCtrlException("
|
|
52
|
+
raise VsslCtrlException("Add minimum one zone before initializing")
|
|
60
53
|
|
|
61
54
|
zones_to_init = self.zones.copy()
|
|
62
55
|
|
|
63
56
|
try:
|
|
64
57
|
key, first_zone = zones_to_init.popitem()
|
|
65
58
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
future_model = self.event_bus.future(
|
|
70
|
-
self.Events.MODEL_CHANGE, self.ENTITY_ID
|
|
71
|
-
)
|
|
59
|
+
future_serial = self.event_bus.future(Zone.Events.SERIAL_RECEIVED)
|
|
60
|
+
future_sw_version = self.event_bus.future(self.Events.SW_VERSION_CHANGE)
|
|
61
|
+
future_name = self.event_bus.future(VsslSettings.Events.NAME_CHANGE)
|
|
72
62
|
|
|
73
|
-
# Lets make sure the zone is initialised, otherwsie we fail for all zones
|
|
74
63
|
await first_zone.initialise()
|
|
75
64
|
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if len(self.zones) > self.model.zone_count:
|
|
82
|
-
raise VsslCtrlException("")
|
|
83
|
-
|
|
84
|
-
except asyncio.TimeoutError:
|
|
85
|
-
message = f"Timed out waiting for model infomation from zone {first_zone.id}, exiting!"
|
|
86
|
-
self._log_critical(message)
|
|
87
|
-
await first_zone.disconnect()
|
|
88
|
-
raise VsslCtrlException(message)
|
|
65
|
+
# Wait until we have some basic infomation
|
|
66
|
+
await self.event_bus.wait_future(future_serial, init_timeout)
|
|
67
|
+
await self.event_bus.wait_future(future_sw_version, init_timeout)
|
|
68
|
+
await self.event_bus.wait_future(future_name, init_timeout)
|
|
89
69
|
|
|
90
|
-
|
|
70
|
+
# Check we haven't added too many zones
|
|
71
|
+
if len(self.zones) > self.model.zone_count:
|
|
91
72
|
message = f"Device model {self.model.name} only has {self.model.zone_count} zones not {len(self.zones)}."
|
|
92
73
|
self._log_critical(message)
|
|
93
74
|
await first_zone.disconnect()
|
|
@@ -95,21 +76,32 @@ class Vssl:
|
|
|
95
76
|
|
|
96
77
|
# Output a bit of helpful info
|
|
97
78
|
self._log_info(f"vsslctrl Version: {VSSL_VERSION}")
|
|
98
|
-
self._log_info(f"Device Model: {self.model.name}")
|
|
99
|
-
self._log_info(f"Device SW Version: {self.sw_version}")
|
|
100
79
|
self._log_info(f"Device Serial: {self.serial}")
|
|
80
|
+
self._log_info(f"Device SW Version: {self.sw_version}")
|
|
81
|
+
self._log_info(f"Device Model: {self.model.name}")
|
|
101
82
|
|
|
102
|
-
#
|
|
83
|
+
# Initialise remaining zones
|
|
103
84
|
initialisations = [zone.initialise() for zone in zones_to_init.values()]
|
|
104
85
|
await asyncio.gather(*initialisations)
|
|
105
86
|
|
|
106
87
|
except ZoneError as e:
|
|
107
|
-
message = f"
|
|
108
|
-
self._log_critical(
|
|
88
|
+
message = f"Zone initializing error: {e}"
|
|
89
|
+
self._log_critical(message)
|
|
109
90
|
await self.disconnect()
|
|
110
91
|
raise
|
|
111
92
|
|
|
112
|
-
|
|
93
|
+
except asyncio.TimeoutError:
|
|
94
|
+
message = f"Timeout during VSSL initialization. Are any zones available?"
|
|
95
|
+
self._log_critical(message)
|
|
96
|
+
await first_zone.disconnect()
|
|
97
|
+
raise VsslCtrlException(message)
|
|
98
|
+
|
|
99
|
+
# Initialised
|
|
100
|
+
self.initialisation.set()
|
|
101
|
+
self.event_bus.publish(self.Events.INITIALISED, self.ENTITY_ID, self)
|
|
102
|
+
self._log_info(f"Core initialization complete")
|
|
103
|
+
|
|
104
|
+
return self
|
|
113
105
|
|
|
114
106
|
#
|
|
115
107
|
# Shutdown
|
|
@@ -121,20 +113,17 @@ class Vssl:
|
|
|
121
113
|
#
|
|
122
114
|
# Discover host on the network using zero_conf package
|
|
123
115
|
#
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
116
|
+
@staticmethod
|
|
117
|
+
async def discover(*args):
|
|
118
|
+
check_zeroconf_availability()
|
|
127
119
|
|
|
128
|
-
|
|
120
|
+
from .discovery import VsslDiscovery
|
|
129
121
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
except ZeroConfNotInstalled as e:
|
|
133
|
-
self._log_error(e)
|
|
134
|
-
raise
|
|
122
|
+
service = VsslDiscovery(*args)
|
|
123
|
+
return await service.discover()
|
|
135
124
|
|
|
136
125
|
#
|
|
137
|
-
# Update a property and fire
|
|
126
|
+
# Update a property and fire an event
|
|
138
127
|
#
|
|
139
128
|
#
|
|
140
129
|
# TODO, use the ZoneDataClass here too? Needs some reconfig
|
|
@@ -188,34 +177,10 @@ class Vssl:
|
|
|
188
177
|
if hasattr(Models, model):
|
|
189
178
|
self._set_property("model", getattr(Models, model).value)
|
|
190
179
|
else:
|
|
191
|
-
message = f"
|
|
180
|
+
message = f"VSSL model {model} doesnt exist"
|
|
192
181
|
self._log_error(message)
|
|
193
182
|
raise VsslCtrlException(message)
|
|
194
183
|
|
|
195
|
-
#
|
|
196
|
-
# Work out the a model given some device info
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
# THIS WONT WORK FOR ORIGINAL A SERIES! TODO!
|
|
201
|
-
# Maybe we can just use the ID of the zone because 7 will be an A1(x) otherwise
|
|
202
|
-
# we will need to count the zones.
|
|
203
|
-
#
|
|
204
|
-
# Or we just require a model to be passed?
|
|
205
|
-
#
|
|
206
|
-
#
|
|
207
|
-
def _infer_device_model(self, data: Dict[str, int]):
|
|
208
|
-
# if we dont have a model, default to x series
|
|
209
|
-
if not self.model:
|
|
210
|
-
zone_count = sum(
|
|
211
|
-
1 for key in data if key.startswith("B") and key.endswith("Src")
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
if zone_count in (1, 3, 6):
|
|
215
|
-
self.model = f"A{zone_count}X"
|
|
216
|
-
else:
|
|
217
|
-
self.model = Models.A1X
|
|
218
|
-
|
|
219
184
|
#
|
|
220
185
|
# Disconnect / Shutdown
|
|
221
186
|
#
|
|
@@ -224,42 +189,60 @@ class Vssl:
|
|
|
224
189
|
await zone.disconnect()
|
|
225
190
|
|
|
226
191
|
#
|
|
227
|
-
# Add a
|
|
192
|
+
# Add a Zone
|
|
228
193
|
#
|
|
229
|
-
def
|
|
230
|
-
|
|
194
|
+
def add_zone(self, host: str, zone_index: ZoneIDs = ZoneIDs.A1):
|
|
195
|
+
# Check if VSSL is already initialised
|
|
196
|
+
if self.initialised:
|
|
197
|
+
error = f"Zones can not be added after VSSL is initialised. Error trying to add Zone {zone_index}"
|
|
198
|
+
self._log_error(error)
|
|
199
|
+
raise ZoneError(error)
|
|
231
200
|
|
|
232
|
-
|
|
233
|
-
|
|
201
|
+
# Check the ZoneID is valid for the model
|
|
202
|
+
if zone_index not in self.model.zones:
|
|
203
|
+
error = f"ZoneIDs {zone_index} is not supported on device model {self.model.name}. Did you select the correct model?"
|
|
204
|
+
self._log_error(error)
|
|
205
|
+
raise ZoneError(error)
|
|
234
206
|
|
|
235
|
-
|
|
236
|
-
# Add a Zone
|
|
237
|
-
#
|
|
238
|
-
def add_zone(self, zone_index: ZoneIDs, host: str):
|
|
207
|
+
# Double check ZoneID is valid
|
|
239
208
|
if ZoneIDs.is_not_valid(zone_index):
|
|
240
209
|
error = f"ZoneIDs {zone_index} doesnt exist"
|
|
241
210
|
self._log_error(error)
|
|
242
211
|
raise ZoneError(error)
|
|
243
|
-
return None
|
|
244
212
|
|
|
213
|
+
# Check ZoneID is unique
|
|
245
214
|
if zone_index in self.zones:
|
|
246
|
-
error = f"Zone {zone_index} already exists"
|
|
215
|
+
error = f"Zone {zone_index} already exists on this instance"
|
|
247
216
|
self._log_error(error)
|
|
248
217
|
raise ZoneError(error)
|
|
249
|
-
return None
|
|
250
218
|
|
|
251
|
-
# Check
|
|
252
|
-
# property
|
|
219
|
+
# Check IPs are unique
|
|
253
220
|
if any(zone.host == host for zone in self.zones.values()):
|
|
254
221
|
error = f"Zone with IP {host} already exists"
|
|
255
222
|
self._log_error(error)
|
|
256
223
|
raise ZoneError(error)
|
|
257
|
-
return None
|
|
258
224
|
|
|
259
225
|
self.zones[zone_index] = Zone(self, zone_index, host)
|
|
260
226
|
|
|
261
227
|
return self.zones[zone_index]
|
|
262
228
|
|
|
229
|
+
#
|
|
230
|
+
# Add a Zones using a list.
|
|
231
|
+
#
|
|
232
|
+
async def add_zones(self, zones=Union[str, List[str]]):
|
|
233
|
+
zones_list = [zones] if isinstance(zones, str) else zones
|
|
234
|
+
|
|
235
|
+
# A.1(x)
|
|
236
|
+
if not self.model.is_multizone:
|
|
237
|
+
return self.add_zone(zones_list[0], ZoneIDs.A1)
|
|
238
|
+
else:
|
|
239
|
+
# Fetch the ZoneID from the device
|
|
240
|
+
for host in zones_list:
|
|
241
|
+
zone_id, serial = await fetch_zone_id_serial(host)
|
|
242
|
+
self.add_zone(host, ZoneIDs(int(zone_id)))
|
|
243
|
+
|
|
244
|
+
return self.zones
|
|
245
|
+
|
|
263
246
|
#
|
|
264
247
|
# Get a Zone by ID
|
|
265
248
|
#
|
|
@@ -270,19 +253,7 @@ class Vssl:
|
|
|
270
253
|
return None
|
|
271
254
|
|
|
272
255
|
#
|
|
273
|
-
# Get a
|
|
274
|
-
#
|
|
275
|
-
def get_zones_by_group_index(self, group_index: int):
|
|
276
|
-
zones = {}
|
|
277
|
-
if self.zones:
|
|
278
|
-
for zone_id in self.zones:
|
|
279
|
-
zone = self.zones[zone_id]
|
|
280
|
-
if zone.group.index == group_index:
|
|
281
|
-
zones[zone_id] = zone
|
|
282
|
-
return zones
|
|
283
|
-
|
|
284
|
-
#
|
|
285
|
-
# Get a Zone that is connected to its APIs
|
|
256
|
+
# Get a zone that is connected
|
|
286
257
|
#
|
|
287
258
|
def get_connected_zone(self):
|
|
288
259
|
if self.zones:
|
|
@@ -290,6 +261,14 @@ class Vssl:
|
|
|
290
261
|
zone = self.zones[zone_id]
|
|
291
262
|
if zone.connected:
|
|
292
263
|
return zone
|
|
264
|
+
self._log_error("There are no connected zones.")
|
|
265
|
+
|
|
266
|
+
#
|
|
267
|
+
# Has a connected zone
|
|
268
|
+
#
|
|
269
|
+
@property
|
|
270
|
+
def connected(self):
|
|
271
|
+
return True if self.get_connected_zone() else False
|
|
293
272
|
|
|
294
273
|
#
|
|
295
274
|
# Get the device name
|
|
@@ -315,6 +294,18 @@ class Vssl:
|
|
|
315
294
|
if zone:
|
|
316
295
|
zone.api_alpha.request_action_2B()
|
|
317
296
|
|
|
297
|
+
#
|
|
298
|
+
# Get a Zone by group index
|
|
299
|
+
#
|
|
300
|
+
def get_zones_by_group_index(self, group_index: int):
|
|
301
|
+
zones = {}
|
|
302
|
+
if self.zones:
|
|
303
|
+
for zone_id in self.zones:
|
|
304
|
+
zone = self.zones[zone_id]
|
|
305
|
+
if zone.group.index == group_index:
|
|
306
|
+
zones[zone_id] = zone
|
|
307
|
+
return zones
|
|
308
|
+
|
|
318
309
|
#
|
|
319
310
|
# Zones Groups. Build a dict of zone according to group membership
|
|
320
311
|
#
|
vsslctrl/data_structure.py
CHANGED
|
@@ -489,7 +489,8 @@ class ZoneRouterStatusExtKeys:
|
|
|
489
489
|
|
|
490
490
|
@staticmethod
|
|
491
491
|
def add_zone_to_ao_fixed_volume_key(zone_id: int):
|
|
492
|
-
|
|
492
|
+
# TODO - Does this work on the A.1(x)?
|
|
493
|
+
zone_id = ZoneIDs.ZONE_1 if zone_id > ZoneIDs.ZONE_6 else zone_id
|
|
493
494
|
return f"BF{zone_id}"
|
|
494
495
|
|
|
495
496
|
|
vsslctrl/event_bus.py
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import traceback
|
|
3
|
+
import fnmatch
|
|
3
4
|
from enum import IntEnum
|
|
4
5
|
from typing import Callable
|
|
5
6
|
from .exceptions import VsslCtrlException
|
|
6
7
|
from .decorators import logging_helpers
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
-
# Event Bus
|
|
10
|
-
#
|
|
11
|
-
|
|
12
9
|
|
|
13
10
|
@logging_helpers("EventBus:")
|
|
14
11
|
class EventBus:
|
|
@@ -23,6 +20,10 @@ class EventBus:
|
|
|
23
20
|
|
|
24
21
|
self.process = asyncio.create_task(self.process_events())
|
|
25
22
|
|
|
23
|
+
# Helper for wildcard matching, so we can use partial wildcards. e.g zone.api.connected
|
|
24
|
+
def _matches_pattern(self, event_type, pattern):
|
|
25
|
+
return fnmatch.fnmatch(event_type, pattern)
|
|
26
|
+
|
|
26
27
|
#
|
|
27
28
|
# Stop
|
|
28
29
|
#
|
|
@@ -34,7 +35,7 @@ class EventBus:
|
|
|
34
35
|
#
|
|
35
36
|
# Subscribe
|
|
36
37
|
#
|
|
37
|
-
def subscribe(self, event_type, callback: Callable, entity=
|
|
38
|
+
def subscribe(self, event_type, callback: Callable, entity=WILDCARD, once=False):
|
|
38
39
|
# Make sure we are using async callbacks
|
|
39
40
|
if callback is not None and asyncio.iscoroutinefunction(callback):
|
|
40
41
|
event_type = event_type.lower()
|
|
@@ -62,12 +63,13 @@ class EventBus:
|
|
|
62
63
|
#
|
|
63
64
|
# Get a future value from the event bus
|
|
64
65
|
#
|
|
65
|
-
def future(self, event_type, entity=
|
|
66
|
+
def future(self, event_type, entity=WILDCARD) -> asyncio.Future:
|
|
66
67
|
future = asyncio.Future()
|
|
67
68
|
|
|
68
69
|
async def future_callback(data, *args):
|
|
69
70
|
nonlocal future
|
|
70
|
-
future.
|
|
71
|
+
if not future.done(): # Ensure the future is not already resolved
|
|
72
|
+
future.set_result(data)
|
|
71
73
|
|
|
72
74
|
self.subscribe(event_type, future_callback, entity, once=True)
|
|
73
75
|
|
|
@@ -81,7 +83,7 @@ class EventBus:
|
|
|
81
83
|
try:
|
|
82
84
|
return await asyncio.wait_for(future, timeout)
|
|
83
85
|
except asyncio.TimeoutError as error:
|
|
84
|
-
self._log_error(f"
|
|
86
|
+
self._log_error(f"Timeout waiting for eventbus future")
|
|
85
87
|
raise error
|
|
86
88
|
else:
|
|
87
89
|
return await future
|
|
@@ -92,7 +94,7 @@ class EventBus:
|
|
|
92
94
|
async def wait_for(
|
|
93
95
|
self,
|
|
94
96
|
event_type,
|
|
95
|
-
entity=
|
|
97
|
+
entity=WILDCARD,
|
|
96
98
|
timeout: int = FUTURE_TIMEOUT,
|
|
97
99
|
timeout_result=None,
|
|
98
100
|
):
|
|
@@ -115,12 +117,8 @@ class EventBus:
|
|
|
115
117
|
event_type = event_type.lower()
|
|
116
118
|
await self.event_queue.put((event_type, entity, data))
|
|
117
119
|
|
|
118
|
-
#
|
|
119
|
-
# Process Events
|
|
120
|
-
#
|
|
121
120
|
async def process_events(self):
|
|
122
121
|
self._log_debug(f"starting event processing")
|
|
123
|
-
|
|
124
122
|
self.running = True
|
|
125
123
|
while self.running:
|
|
126
124
|
try:
|
|
@@ -136,23 +134,22 @@ class EventBus:
|
|
|
136
134
|
message += str(data)
|
|
137
135
|
self._log_debug(message)
|
|
138
136
|
|
|
139
|
-
for
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
137
|
+
# Check for matching subscribers, including wildcards.
|
|
138
|
+
# e.g "zone.*", "zone.api.*"
|
|
139
|
+
matched_subscribers = []
|
|
140
|
+
for pattern in self.subscribers:
|
|
141
|
+
if self._matches_pattern(event_type, pattern):
|
|
142
|
+
matched_subscribers.extend(self.subscribers[pattern])
|
|
143
|
+
|
|
144
|
+
for callback, subscribed_entity, once in matched_subscribers:
|
|
145
|
+
if entity is None or subscribed_entity in {entity, self.WILDCARD}:
|
|
146
|
+
await callback(data, entity, event_type)
|
|
147
|
+
if once:
|
|
148
|
+
self.unsubscribe(pattern, callback)
|
|
151
149
|
|
|
152
150
|
except asyncio.CancelledError:
|
|
153
151
|
break
|
|
154
152
|
except Exception as e:
|
|
155
|
-
# Capture the traceback as a string
|
|
156
153
|
traceback_str = traceback.format_exc()
|
|
157
154
|
self._log_error(
|
|
158
155
|
f"exception occurred processing event: {e}\n{traceback_str}"
|
vsslctrl/group.py
CHANGED
|
@@ -159,7 +159,6 @@ class ZoneGroup(ZoneDataClass):
|
|
|
159
159
|
#
|
|
160
160
|
# Group: This zone is a master for a group
|
|
161
161
|
#
|
|
162
|
-
# TODO: propogate track meta to member zones.
|
|
163
162
|
# Only play state is propgated by the VSSL device
|
|
164
163
|
#
|
|
165
164
|
@property
|
|
@@ -215,4 +214,4 @@ class ZoneGroup(ZoneDataClass):
|
|
|
215
214
|
self.zone.api_alpha.request_action_0C(state)
|
|
216
215
|
|
|
217
216
|
def is_party_zone_member_toggle(self):
|
|
218
|
-
self.is_party_zone_member =
|
|
217
|
+
self.is_party_zone_member = not self.is_party_zone_member
|
vsslctrl/track.py
CHANGED
|
@@ -102,7 +102,7 @@ class TrackMetadata(ZoneDataClass):
|
|
|
102
102
|
# Doing this will fire the change events on the bus. Instead of conditionally
|
|
103
103
|
# using the getter functions since we want the changes to be propogated
|
|
104
104
|
#
|
|
105
|
-
# VSSL has a
|
|
105
|
+
# VSSL has a habit of caching the last song played, so we need to clear it
|
|
106
106
|
#
|
|
107
107
|
def set_defaults(self):
|
|
108
108
|
for key, default_value in self.DEFAULTS.items():
|
|
@@ -143,7 +143,7 @@ class TrackMetadata(ZoneDataClass):
|
|
|
143
143
|
def _map_response_dict(self, track_data: Dict[str, int]) -> None:
|
|
144
144
|
"""Ignore the track data if zone is part of a group.
|
|
145
145
|
|
|
146
|
-
VSSL has a
|
|
146
|
+
VSSL has a habit of caching old track meta when part of a group
|
|
147
147
|
|
|
148
148
|
"""
|
|
149
149
|
if not self.zone.group.is_member:
|
|
@@ -161,8 +161,7 @@ class TrackMetadata(ZoneDataClass):
|
|
|
161
161
|
but generally it responds with a BrowseView when its a member of a group
|
|
162
162
|
|
|
163
163
|
When a group is created, the child will get the group index first (generally this will be the same
|
|
164
|
-
as the index_id) then its transport state will be
|
|
165
|
-
updated on the VSSL side.
|
|
164
|
+
as the index_id) then its transport state will be updated on the VSSL side.
|
|
166
165
|
When the transport state is changed, the zone will request the track meta. VSSL will respond
|
|
167
166
|
with the last cached metadata from the zone and not the correct meta from the current zone master.
|
|
168
167
|
|
vsslctrl/transport.py
CHANGED
|
@@ -70,7 +70,7 @@ class ZoneTransport(ZoneDataClass):
|
|
|
70
70
|
Doing this will fire the change events on the bus. Instead of conditionally
|
|
71
71
|
using the getter functions since we want the changes to be propogated
|
|
72
72
|
|
|
73
|
-
VSSL has a
|
|
73
|
+
VSSL has a habit of caching the last song played, so we need to clear it
|
|
74
74
|
"""
|
|
75
75
|
for key, default_value in self.DEFAULTS.items():
|
|
76
76
|
set_func = f"_set_{key}"
|
vsslctrl/utils.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import random
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
#
|
|
@@ -51,6 +52,16 @@ def hex_to_bytearray_string(hex_string):
|
|
|
51
52
|
return bytearray_str
|
|
52
53
|
|
|
53
54
|
|
|
55
|
+
#
|
|
56
|
+
# Generate a random INT excliding
|
|
57
|
+
#
|
|
58
|
+
def generate_number_excluding(excluded_number, lower: int = 1, upper: int = 100):
|
|
59
|
+
while True:
|
|
60
|
+
num = random.randint(lower, upper)
|
|
61
|
+
if num != excluded_number:
|
|
62
|
+
return num
|
|
63
|
+
|
|
64
|
+
|
|
54
65
|
#
|
|
55
66
|
# Repeat Timer
|
|
56
67
|
#
|
vsslctrl/zone.py
CHANGED
|
@@ -29,7 +29,6 @@ class Zone:
|
|
|
29
29
|
# Zone Events
|
|
30
30
|
#
|
|
31
31
|
class Events:
|
|
32
|
-
ALL = "*"
|
|
33
32
|
PREFIX = "zone."
|
|
34
33
|
INITIALISED = PREFIX + "initialised"
|
|
35
34
|
ID_RECEIVED = PREFIX + "id_received"
|
|
@@ -79,8 +78,8 @@ class Zone:
|
|
|
79
78
|
)
|
|
80
79
|
|
|
81
80
|
# Initialise
|
|
82
|
-
async def initialise(self):
|
|
83
|
-
#
|
|
81
|
+
async def initialise(self, init_timeout: int = 10):
|
|
82
|
+
# Data we require from the device
|
|
84
83
|
future_id = self.vssl.event_bus.future(self.Events.ID_RECEIVED, self.id)
|
|
85
84
|
future_serial = self.vssl.event_bus.future(self.Events.SERIAL_RECEIVED, self.id)
|
|
86
85
|
future_name = self.vssl.event_bus.future(
|
|
@@ -98,28 +97,35 @@ class Zone:
|
|
|
98
97
|
)
|
|
99
98
|
|
|
100
99
|
# Connect the APIs
|
|
101
|
-
# Wait until the zone is connected then continue
|
|
102
100
|
await self.api_alpha.connect()
|
|
103
101
|
await self.api_bravo.connect()
|
|
104
102
|
|
|
105
103
|
# Start polling zone
|
|
106
104
|
self._poller.start()
|
|
107
105
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
try:
|
|
107
|
+
# Wait for the ID, serial and name to be returned from the device
|
|
108
|
+
received_id = await self.vssl.event_bus.wait_future(future_id, init_timeout)
|
|
109
|
+
received_serial = await self.vssl.event_bus.wait_future(
|
|
110
|
+
future_serial, init_timeout
|
|
111
|
+
)
|
|
112
|
+
await self.vssl.event_bus.wait_future(future_name, init_timeout)
|
|
113
|
+
except asyncio.TimeoutError:
|
|
114
|
+
message = f"Zone {self.id}: initialization timeout. Is the zone available?"
|
|
115
|
+
self._log_critical(message)
|
|
116
|
+
await self.disconnect()
|
|
117
|
+
raise ZoneError(message)
|
|
112
118
|
|
|
113
|
-
# Confirm the zone id is matches returned ID
|
|
119
|
+
# Confirm the zone id is matches the returned zone ID
|
|
114
120
|
if received_id != self.id:
|
|
115
|
-
message = f"Zone ID mismatch. {self.host} returned zone ID {received_id} instead of {self.id}"
|
|
121
|
+
message = f"Zone {self.id}: ID mismatch. {self.host} returned zone ID {received_id} instead of {self.id}"
|
|
116
122
|
self._log_critical(message)
|
|
117
123
|
await self.disconnect()
|
|
118
124
|
raise ZoneError(message)
|
|
119
125
|
|
|
120
126
|
# Confirm the zone and VSSL serial numbers match
|
|
121
127
|
if self.vssl.serial != received_serial:
|
|
122
|
-
message = f"Zone ({received_serial}) and VSSL ({self.vssl.serial}) serial numbers do not match. Does this zone belong to this VSSL?"
|
|
128
|
+
message = f"Zone {self.id}: ({received_serial}) and VSSL ({self.vssl.serial}) serial numbers do not match. Does this zone belong to this VSSL?"
|
|
123
129
|
self._log_critical(message)
|
|
124
130
|
await self.disconnect()
|
|
125
131
|
raise ZoneError(message)
|
|
@@ -162,7 +168,7 @@ class Zone:
|
|
|
162
168
|
Doing this will fire the change events on the bus. Instead of conditionally
|
|
163
169
|
using the getter functions since we want the changes to be propogated
|
|
164
170
|
|
|
165
|
-
VSSL has a
|
|
171
|
+
VSSL has a habit of caching the last songs metadata
|
|
166
172
|
|
|
167
173
|
"""
|
|
168
174
|
if not self.transport.is_stopped:
|
|
@@ -356,6 +362,9 @@ class Zone:
|
|
|
356
362
|
#
|
|
357
363
|
# Mute
|
|
358
364
|
#
|
|
365
|
+
# Note: Mute will still return true if volume is at 0
|
|
366
|
+
# so always use _mute when comparing
|
|
367
|
+
#
|
|
359
368
|
@property
|
|
360
369
|
def mute(self):
|
|
361
370
|
return True if not self._volume else self._mute
|
|
@@ -365,7 +374,13 @@ class Zone:
|
|
|
365
374
|
self.api_alpha.request_action_11(not not muted)
|
|
366
375
|
|
|
367
376
|
def mute_toggle(self):
|
|
368
|
-
self.mute = False if self.
|
|
377
|
+
self.mute = False if self._mute else True
|
|
378
|
+
|
|
379
|
+
# Use self._mute since we are chcking volume in mute property
|
|
380
|
+
def _set_mute(self, state: bool):
|
|
381
|
+
if self._mute != state:
|
|
382
|
+
self._mute = state
|
|
383
|
+
return True
|
|
369
384
|
|
|
370
385
|
#
|
|
371
386
|
# Play a URL
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: vsslctrl
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.13.dev1
|
|
4
4
|
Summary: Package for controlling VSSL's range of streaming amplifiers
|
|
5
5
|
Author-email: vsslctrl <vsslcontrolled@proton.me>
|
|
6
6
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -21,32 +21,44 @@ Requires-Dist: zeroconf==0.132.2; extra == "optional"
|
|
|
21
21
|
|
|
22
22
|
# vsslctrl
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Package for controlling [VSSL's](https://www.vssl.com/) range of streaming amplifiers.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
## Coverage
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Tested on:
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
| Model | Software Version | Note |
|
|
31
|
+
| ------------|--------- | -------------
|
|
32
|
+
| A.3x | p15305.016.3701 |
|
|
31
33
|
|
|
32
|
-
Tested on:
|
|
33
|
-
- **A.3x** software version **p15305.016.3701**
|
|
34
34
|
|
|
35
35
|
Home Assistant [integration](https://github.com/vsslctrl/integration.home-assistant) with basic functionality working on:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
|
|
37
|
+
| Model | Software Version | User Reported |
|
|
38
|
+
| ------------|--------- | -------------
|
|
39
|
+
| A.1 | p15265.033.3703 | ✔️
|
|
40
|
+
| A.3 | p12013.141.3703 | ✔️
|
|
41
|
+
| A.3x | p15305.016.3701 |
|
|
42
|
+
| A.6x | p15305.017.3701 | ✔️
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## Testers Needed
|
|
46
|
+
|
|
47
|
+
I am looking for testers with any VSSL amplifier models, please get in touch if you interested in helping. <vsslcontrolled@proton.me>
|
|
48
|
+
|
|
49
|
+
|
|
40
50
|
|
|
41
51
|
## Important
|
|
42
52
|
|
|
43
|
-
There should not be any *[VSSL Agent's](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* running on the
|
|
53
|
+
There should not be any *[VSSL Agent's](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* running on the network. If you dont know what this is, then you can ignore this notice.
|
|
54
|
+
|
|
55
|
+
**`vsslctrl` is not endorsed or affiliated with [VSSL](https://www.vssl.com/) in any manner.**
|
|
44
56
|
|
|
45
57
|
## TODOs
|
|
46
58
|
|
|
47
|
-
* Correct IO mapping between models and versions
|
|
48
59
|
* Better test coverage
|
|
49
|
-
* A.1(x) testing
|
|
60
|
+
* A.1(x) testing - e.g output settings
|
|
61
|
+
* More controls - e.g IR Control
|
|
50
62
|
|
|
51
63
|
## Basic Usage
|
|
52
64
|
|
|
@@ -62,9 +74,9 @@ async def main():
|
|
|
62
74
|
|
|
63
75
|
# Represents a physical VSSL amplifier
|
|
64
76
|
vssl = Vssl(DeviceModels.A1)
|
|
65
|
-
a1 = vssl.add_zone(
|
|
77
|
+
a1 = vssl.add_zone('192.168.1.10')
|
|
66
78
|
|
|
67
|
-
# Connect and
|
|
79
|
+
# Connect and initialise zone.
|
|
68
80
|
await vssl.initialise()
|
|
69
81
|
|
|
70
82
|
"""Control Examples"""
|
|
@@ -92,16 +104,15 @@ from vsslctrl import Vssl, DeviceModels, Zone, ZoneIDs
|
|
|
92
104
|
async def main():
|
|
93
105
|
|
|
94
106
|
# Represents a physical VSSL amplifier
|
|
95
|
-
# If no DeviceModels is passed, vsslctrl will default to the feature set of the X series amps
|
|
96
107
|
vssl = Vssl(DeviceModels.A3X)
|
|
97
108
|
|
|
98
109
|
# Add each you wish to control
|
|
99
|
-
zone1 = vssl.add_zone(
|
|
100
|
-
zone2 = vssl.add_zone(
|
|
101
|
-
zone3 = vssl.add_zone(
|
|
102
|
-
#... up to 6 zones
|
|
110
|
+
zone1 = vssl.add_zone('192.168.1.10', ZoneIDs.ZONE_1)
|
|
111
|
+
zone2 = vssl.add_zone('192.168.1.11', ZoneIDs.ZONE_2)
|
|
112
|
+
zone3 = vssl.add_zone('192.168.1.12', ZoneIDs.ZONE_3)
|
|
113
|
+
#... up to 6 zones for A.6(x)
|
|
103
114
|
|
|
104
|
-
# Connect and
|
|
115
|
+
# Connect and initialise zones.
|
|
105
116
|
await vssl.initialise()
|
|
106
117
|
|
|
107
118
|
"""Control Examples"""
|
|
@@ -123,24 +134,106 @@ async def main():
|
|
|
123
134
|
asyncio.run(main())
|
|
124
135
|
```
|
|
125
136
|
|
|
126
|
-
|
|
137
|
+
### Device Discovery Helper
|
|
138
|
+
|
|
139
|
+
You can discover VSSL devices on the network using [mDNS](https://wikipedia.org/wiki/Multicast_DNS) / Bonjour if you have the [`zeroconf`](https://pypi.org/project/zeroconf/) package installed.
|
|
140
|
+
|
|
141
|
+
This uses airplay service string `_airplay._tcp.local.`, therefore airplay needs to available and will not work across VLANs without other provisions.
|
|
127
142
|
|
|
128
|
-
|
|
143
|
+
**Note:** This is designed to be a helper and its not recommended to be used for the initialization of the VSSL class.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import asyncio
|
|
147
|
+
from vsslctrl import Vssl
|
|
148
|
+
|
|
149
|
+
async def main():
|
|
150
|
+
|
|
151
|
+
print(await Vssl.discover())
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
{
|
|
155
|
+
'XXXXXXXXXXXX': [
|
|
156
|
+
{
|
|
157
|
+
'host': '192.168.168.25',
|
|
158
|
+
'name': 'Living Room',
|
|
159
|
+
'model': 'A1x',
|
|
160
|
+
'mac_addr': 'AA:BB:CC:DD:EE:FF',
|
|
161
|
+
'zone_id': '7',
|
|
162
|
+
'serial': 'XXXXXXXXXXXX'
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
asyncio.run(main())
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# API Functionality
|
|
174
|
+
|
|
175
|
+
Most functionality is achieved via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
|
|
129
176
|
|
|
130
177
|
The classes will update the physical VSSL device when setting a property and once feedback has been received, the classes internal state will be updated. For example:
|
|
131
178
|
|
|
132
179
|
```python
|
|
133
|
-
# Setting the
|
|
180
|
+
# Setting the zone name
|
|
134
181
|
zone1.settings.name = 'Living Room'
|
|
135
|
-
>>>
|
|
182
|
+
>>> 'Old Zone Name'
|
|
136
183
|
|
|
137
184
|
# Printing zone name
|
|
138
|
-
|
|
139
|
-
print(zone_name)
|
|
185
|
+
print(zone1.settings.name)
|
|
140
186
|
>>> 'Living Room'
|
|
141
187
|
```
|
|
142
188
|
|
|
143
|
-
**Important** in the above example, `zone1.settings.name`
|
|
189
|
+
**Important** in the above example, `zone1.settings.name` won't be set to its new value until after the VSSL device has changed the name and the `Zone` class has received confirmation feedback. If you need to wait for the value change, you can await a `[property_name]_CHANGE` events as below:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from vsslctrl.settings import ZoneSettings
|
|
193
|
+
# Setting the zone name and wait for feedback
|
|
194
|
+
future_name = vssl.event_bus.future(ZoneSettings.Events.NAME_CHANGE, zone1.id)
|
|
195
|
+
zone1.settings.name = 'Bathroom'
|
|
196
|
+
# Helper to await a future with timeout
|
|
197
|
+
new_name = await vssl.event_bus.wait_future(future_name)
|
|
198
|
+
# Printing zone name
|
|
199
|
+
print(new_name)
|
|
200
|
+
>>> 'Bathroom'
|
|
201
|
+
# or
|
|
202
|
+
print(zone1.settings.name)
|
|
203
|
+
>>> 'Bathroom'
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
# API Reference
|
|
207
|
+
|
|
208
|
+
# `DeviceModels`
|
|
209
|
+
|
|
210
|
+
A device model has to be passed to VSSL so it knows internally what features are supported by the device.
|
|
211
|
+
This might be removed in the future if we can differentiate different models from the API.
|
|
212
|
+
|
|
213
|
+
| Property | Description |
|
|
214
|
+
| ------------|--------- |
|
|
215
|
+
| `A1X` | A.1x |
|
|
216
|
+
| `A3X` | A.3x |
|
|
217
|
+
| `A6X` | A.6x |
|
|
218
|
+
| `A1` | A1 |
|
|
219
|
+
| `A3` | A3 |
|
|
220
|
+
| `A6` | A6 |
|
|
221
|
+
|
|
222
|
+
# `ZoneIDs`
|
|
223
|
+
|
|
224
|
+
A `ZoneIDs` must be passed to each `zone` you which to control and it must match the zone on the VSSL device.
|
|
225
|
+
|
|
226
|
+
If you are unsure of your `ZoneIDs` you could use the discovery helper to find out the correct mapping.
|
|
227
|
+
|
|
228
|
+
| Property | Description | A.1(x) | A.3(x) | A.6(x)
|
|
229
|
+
| ------------|--------- |-------- | -------- | -------- |
|
|
230
|
+
| `A1` | |✔️ | | |
|
|
231
|
+
| `ZONE_1` | Zone 1 | | ✔️| ✔️|
|
|
232
|
+
| `ZONE_2` | Zone 2 | | ✔️| ✔️|
|
|
233
|
+
| `ZONE_3` | Zone 3 | | ✔️| ✔️|
|
|
234
|
+
| `ZONE_4` | Zone 4 | | | ✔️|
|
|
235
|
+
| `ZONE_5` | Zone 5 | | | ✔️|
|
|
236
|
+
| `ZONE_6` | Zone 6 | | | ✔️|
|
|
144
237
|
|
|
145
238
|
|
|
146
239
|
# `Vssl`
|
|
@@ -164,7 +257,7 @@ vssl.factory_reset()
|
|
|
164
257
|
|
|
165
258
|
## `Vssl.settings`
|
|
166
259
|
|
|
167
|
-
| Property | Description | Type |
|
|
260
|
+
| Property | Description | Type | Model: Default |
|
|
168
261
|
| ---------------------- | ----------- | ----------- | ----------- |
|
|
169
262
|
| `name` | Device name | `str` |
|
|
170
263
|
| `bus_1_name` | Name of Bus 1 | `str` | <ul><li>A.1: Optical Input</li><li>A.3x/6x: Not Used</li></ul>
|
|
@@ -313,13 +406,13 @@ Input `InputRouter.Priorities` still apply.
|
|
|
313
406
|
# Change zone 1 to listen to analog input 4
|
|
314
407
|
zone1.input.source = InputRouter.Sources.ANALOG_IN_4
|
|
315
408
|
|
|
316
|
-
# Change zone 1 to perfer
|
|
409
|
+
# Change zone 1 to perfer local inputs over stream
|
|
317
410
|
zone1.input.priority = InputRouter.Priorities.LOCAL
|
|
318
411
|
```
|
|
319
412
|
|
|
320
413
|
## `Zone.group`
|
|
321
414
|
|
|
322
|
-
|
|
415
|
+
Unsupported on X series amplifiers.
|
|
323
416
|
|
|
324
417
|
| Property | Description | Type | Values |
|
|
325
418
|
| ---------------------- | ----------- | ----------- |----------- |
|
|
@@ -458,55 +551,6 @@ zone1.settings.eq.khz1_db = -2
|
|
|
458
551
|
zone1.settings.subwoofer.crossover = 100
|
|
459
552
|
```
|
|
460
553
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
## Another (Lite) Way
|
|
464
|
-
|
|
465
|
-
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`.
|
|
466
|
-
|
|
467
|
-
| HEX | Description |
|
|
468
|
-
| ---------------------- | ----------- |
|
|
469
|
-
| `\x10\x05\x03\x0{Zone Number}\xff\x03` | Volume Up
|
|
470
|
-
| `\x10\x05\x03\x0{Zone Number}\xfe\x03` | Volume Down
|
|
471
|
-
| `\x10\x11\x02\x0{Zone Number}\x01` | Mute
|
|
472
|
-
| All commands can be found by looking [here](https://github.com/vsslctrl/vsslctrl/blob/2c43c2f2393b94bc0e062d2ab90144343eca16ef/vsslctrl/api_alpha.py) |
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
The `volume up` HEX command for `Zone 2` would be `\x10\x05\x03\x02\xff\x03`
|
|
476
|
-
|
|
477
|
-
Now send the raw HEX using Netcat to the device using this syntax:
|
|
478
|
-
|
|
479
|
-
`echo -e "{HEX Command}" | nc {IP Address} 50002`
|
|
480
|
-
|
|
481
|
-
For example to send `volume up` to `Zone 2`:
|
|
482
|
-
|
|
483
|
-
`echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002`
|
|
484
|
-
|
|
485
|
-
Home Assistant `Configuration.yaml` example:
|
|
486
|
-
|
|
487
|
-
```ymal
|
|
488
|
-
...
|
|
489
|
-
|
|
490
|
-
shell_command:
|
|
491
|
-
#Zone 1
|
|
492
|
-
vssl_zone_1_volume_up: 'echo -e "\x10\x05\x03\x01\xff\x03" | nc 192.168.1.10 50002'
|
|
493
|
-
vssl_zone_1_volume_down: 'echo -e "\x10\x05\x03\x01\xfe\x03" | nc 192.168.1.10 50002'
|
|
494
|
-
vssl_zone_1_mute: 'echo -e "\x10\x11\x02\x01\x01" | nc 192.168.1.10 50002'
|
|
495
|
-
vssl_zone_1_unmute: 'echo -e "\x10\x11\x02\x01\x00" | nc 192.168.1.10 50002'
|
|
496
|
-
#Zone 2
|
|
497
|
-
vssl_zone_2_volume_up: 'echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002'
|
|
498
|
-
vssl_zone_2_volume_down: 'echo -e "\x10\x05\x03\x02\xfe\x03" | nc 192.168.1.11 50002'
|
|
499
|
-
vssl_zone_2_mute: 'echo -e "\x10\x11\x02\x02\x01" | nc 192.168.1.11 50002'
|
|
500
|
-
vssl_zone_2_unmute: 'echo -e "\x10\x11\x02\x02\x00" | nc 192.168.1.11 50002'
|
|
501
|
-
#Zone 3
|
|
502
|
-
vssl_zone_3_volume_up: 'echo -e "\x10\x05\x03\x03\xff\x03" | nc 192.168.1.12 50002'
|
|
503
|
-
vssl_zone_3_volume_down: 'echo -e "\x10\x05\x03\x03\xfe\x03" | nc 192.168.1.12 50002'
|
|
504
|
-
vssl_zone_3_mute: 'echo -e "\x10\x11\x02\x03\x01" | nc 192.168.1.12 50002'
|
|
505
|
-
vssl_zone_3_unmute: 'echo -e "\x10\x11\x02\x03\x00" | nc 192.168.1.12 50002'
|
|
506
|
-
|
|
507
|
-
...
|
|
508
|
-
```
|
|
509
|
-
|
|
510
554
|
## Credit
|
|
511
555
|
|
|
512
556
|
Thanks to [@dj-jam](https://github.com/dj-jam) for the continued testing.
|
|
@@ -520,17 +564,9 @@ Motivation for this project was to integrate VSSLs amplifiers into [Home Assista
|
|
|
520
564
|
* Not tested on A.1x or original A series range of amplifiers (testers welcome)
|
|
521
565
|
* VSSL can not start a stream except for playing a URL directly. This is a limitation of the hardware itself.
|
|
522
566
|
* Not all sources set the volume to 0 when the zone is muted
|
|
523
|
-
* Grouping feedback is flaky on the X series amplifiers
|
|
524
567
|
* Airplay `Zone.track.progress` is not available.
|
|
525
568
|
* Cant stop a URL playback, feedback is worng at least
|
|
526
569
|
* 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
|
|
527
570
|
* `stop()` is intended to disconnect the client and pause the stream. Doesn’t always function this way, depending on stream source
|
|
528
571
|
* Occasionally a zones might stop responding to certain commands, issuing the `reboot` command generally corrects
|
|
529
572
|
|
|
530
|
-
## Future
|
|
531
|
-
|
|
532
|
-
* A.1(x) coverage i.e Bluetooth
|
|
533
|
-
* REST API / Web App
|
|
534
|
-
* Save and recall EQ
|
|
535
|
-
* IR Control
|
|
536
|
-
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
vsslctrl/__init__.py,sha256=Gl9Q-q3nJi1hTjsCSXwJVY2r_zDmSvLBZ4yoKQjINTQ,174
|
|
2
|
+
vsslctrl/api_alpha.py,sha256=UQsvFPYCbczrG74fbAWYxVeKLw7R1uT0evxprMyuev8,36984
|
|
3
|
+
vsslctrl/api_base.py,sha256=g5rmjahkjyY_OB-2UjNbSwSUXVcWuL1RZZcElRmciTM,10366
|
|
4
|
+
vsslctrl/api_bravo.py,sha256=xpOCCREHIe7dlLu3-PIaMqV2gqPUnX-gaE0MrZlo_YI,12457
|
|
5
|
+
vsslctrl/core.py,sha256=tmWr4vxeQgrFryB-ELMDFtXs1qlwPzZ3tMVSQhHTKgY,9573
|
|
6
|
+
vsslctrl/data_structure.py,sha256=3Tu8CIVM6ednmKVJE1fX6Ny-rKmlJLa2iJHsnjy7fvI,10884
|
|
7
|
+
vsslctrl/decorators.py,sha256=_yQJ0MTXRX6LfvaXVgT2OwbYi9u83w4ciozIsnkxDdI,3406
|
|
8
|
+
vsslctrl/device.py,sha256=JQK0k3N65uSLlYzfNMhUF5Pd7nfSgZj4WCT7o2-p_gs,7860
|
|
9
|
+
vsslctrl/discovery.py,sha256=3z7z6YQUtvjFvRlQOZXIu6Wd4M896rMbi8SUkTub3AM,5568
|
|
10
|
+
vsslctrl/event_bus.py,sha256=1MIUh8v_ukgqwbGzYkzqnwYPCtNyDcJwWpeB4oRk_uo,5282
|
|
11
|
+
vsslctrl/exceptions.py,sha256=EEfNlu--9PMIidj3V3ZB8c7p3My8siqKw7YIOjPFaqE,419
|
|
12
|
+
vsslctrl/group.py,sha256=hD46J-9oHk9EQhnBBlo4HiAA6XTSZ16bq0QMMR889NU,6227
|
|
13
|
+
vsslctrl/io.py,sha256=xBOZjFyP7z_oaYRCxO56EwOJ1FesSphSWkHzGzBlgoU,8796
|
|
14
|
+
vsslctrl/settings.py,sha256=aD-XUykRDe_EmTtSm1h8isHPDAs9DKIcLk5sz7g8PsQ,19507
|
|
15
|
+
vsslctrl/track.py,sha256=tmEADTqQfW6Nk2fvmyNAajTLzuUEn3D71JHJWPYOhxY,10126
|
|
16
|
+
vsslctrl/transport.py,sha256=hNo_gjVk2mCW_Z5cq-kj5xSMjWkXnGgZYpdwFel7K98,6992
|
|
17
|
+
vsslctrl/utils.py,sha256=_bvsOIu3G5czdYhrtm_c0fM60t-UXcLxztnCduQoyvs,2340
|
|
18
|
+
vsslctrl/zone.py,sha256=_uzwC7qJanUQ8GamYFSdJxAEj0qdtwZi9c_m7jnw05U,13696
|
|
19
|
+
vsslctrl-0.1.13.dev1.dist-info/LICENSE,sha256=67Ekj8PIxjEvkPd7L3GYJ8dyvn1EC4sPM3q3FKtpN6w,1065
|
|
20
|
+
vsslctrl-0.1.13.dev1.dist-info/METADATA,sha256=dW3xfOXA721g4uGZ29gLtVlfeSCD4YV8Y75QLP1ksL4,19617
|
|
21
|
+
vsslctrl-0.1.13.dev1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
22
|
+
vsslctrl-0.1.13.dev1.dist-info/top_level.txt,sha256=MRycO_RKageNX27UZ2bLJDMlfeWTQzUZlsd3hS-0u3Y,9
|
|
23
|
+
vsslctrl-0.1.13.dev1.dist-info/RECORD,,
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
vsslctrl/__init__.py,sha256=o5Ku3L87_CKsTDJJ06OVmJnH9wVpILvzGxHeDh0UxDE,174
|
|
2
|
-
vsslctrl/api_alpha.py,sha256=kPvT-v7CSvi1P2pDxKqPqmucwbTXiJsWj3A27h8zoc0,36902
|
|
3
|
-
vsslctrl/api_base.py,sha256=a80CRtUkYK2OwXQOL8bP-YOvt4YE-ALd-VONza_gjK8,9623
|
|
4
|
-
vsslctrl/api_bravo.py,sha256=pchZN16KIvdSrqkI1WU9PA9IEmxkyyP-XifxpRC0jDE,12283
|
|
5
|
-
vsslctrl/core.py,sha256=uUNb8bU2JRZxDbzWgwrblXjKHYdeIWbuogSp9qQX3U8,9555
|
|
6
|
-
vsslctrl/data_structure.py,sha256=kswH_GTtszeecwAxUc89ipJA1QbLQ8475A1jwcAZrro,10840
|
|
7
|
-
vsslctrl/decorators.py,sha256=_yQJ0MTXRX6LfvaXVgT2OwbYi9u83w4ciozIsnkxDdI,3406
|
|
8
|
-
vsslctrl/device.py,sha256=JQK0k3N65uSLlYzfNMhUF5Pd7nfSgZj4WCT7o2-p_gs,7860
|
|
9
|
-
vsslctrl/discovery.py,sha256=3z7z6YQUtvjFvRlQOZXIu6Wd4M896rMbi8SUkTub3AM,5568
|
|
10
|
-
vsslctrl/event_bus.py,sha256=9liAEJnZfthJwy5O-qc3pDIWRBv5AcV19PjMCwH_niU,5014
|
|
11
|
-
vsslctrl/exceptions.py,sha256=EEfNlu--9PMIidj3V3ZB8c7p3My8siqKw7YIOjPFaqE,419
|
|
12
|
-
vsslctrl/group.py,sha256=Lb-UJcU50PXOlIepz-1_yC9CazpkrhVPsf39GtyJwRM,6292
|
|
13
|
-
vsslctrl/io.py,sha256=xBOZjFyP7z_oaYRCxO56EwOJ1FesSphSWkHzGzBlgoU,8796
|
|
14
|
-
vsslctrl/settings.py,sha256=aD-XUykRDe_EmTtSm1h8isHPDAs9DKIcLk5sz7g8PsQ,19507
|
|
15
|
-
vsslctrl/track.py,sha256=_lQNHYe8ZBm3v1lehFLjZruF5-Rz8PGWuQN-zISBgwc,10136
|
|
16
|
-
vsslctrl/transport.py,sha256=XJFy0kl9y7hm3YV4_9ElBrq3u6--_k73UKJqKdO6Rqg,6993
|
|
17
|
-
vsslctrl/utils.py,sha256=FWOMHM_sl1Cp1xab-V6b-A5B7qRmDN67hMMxV4ReWCk,2087
|
|
18
|
-
vsslctrl/zone.py,sha256=teSk8oE3LKteidRnuVK3koUOqtN-90LclEx0-T4FhAE,12978
|
|
19
|
-
vsslctrl-0.1.11.dev1.dist-info/LICENSE,sha256=67Ekj8PIxjEvkPd7L3GYJ8dyvn1EC4sPM3q3FKtpN6w,1065
|
|
20
|
-
vsslctrl-0.1.11.dev1.dist-info/METADATA,sha256=3pI4VH-NpkWtFTXc2qtRTqq_kS-KwJXhV6XRoZSBC9w,19273
|
|
21
|
-
vsslctrl-0.1.11.dev1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
|
22
|
-
vsslctrl-0.1.11.dev1.dist-info/top_level.txt,sha256=MRycO_RKageNX27UZ2bLJDMlfeWTQzUZlsd3hS-0u3Y,9
|
|
23
|
-
vsslctrl-0.1.11.dev1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|