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 CHANGED
@@ -1,4 +1,4 @@
1
- VSSL_VERSION = "0.1.11.dev1"
1
+ VSSL_VERSION = "0.1.13.dev1"
2
2
  VSSL_NAME = "VSSL"
3
3
 
4
4
  from .core import Vssl
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
- command = bytearray([16, 21])
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
- command = bytearray([16, 21])
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
- command = bytearray([16, 21])
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
- command = bytearray([16, 24])
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
- # e.g BF1
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
- if model is not None:
45
- self.model = model
46
-
47
- # Add zones if any are passed
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
- # Initialise the zones
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("No zones were added to VSSL before calling run()")
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
- # If we dont pass a model we will default to the zone count of the X series amps once we
67
- # know some info about the device
68
- if not self.model:
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
- # Only continue after we have a model
77
- try:
78
- if not self.model:
79
- await asyncio.wait_for(future_model, timeout=init_timeout)
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
- except VsslCtrlException:
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
- # Now we can init the rest of the zones
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"Error occured while initialising zones {e}"
108
- self._log_critical(e)
88
+ message = f"Zone initializing error: {e}"
89
+ self._log_critical(message)
109
90
  await self.disconnect()
110
91
  raise
111
92
 
112
- return True
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
- async def discover(self, *args):
125
- try:
126
- check_zeroconf_availability()
116
+ @staticmethod
117
+ async def discover(*args):
118
+ check_zeroconf_availability()
127
119
 
128
- from .discovery import VsslDiscovery
120
+ from .discovery import VsslDiscovery
129
121
 
130
- service = VsslDiscovery(*args)
131
- return await service.discover()
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 the event
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"Model {model} doesnt exist"
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 Zones using a List, index emplys the zone ID
192
+ # Add a Zone
228
193
  #
229
- def add_zones(self, zones=Union[str, List[str]]):
230
- zones_list = [zones] if isinstance(zones, str) else zones
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
- for index, ip in enumerate(zones_list):
233
- self.add_zone(index + 1, ip)
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 if any object in the dictionary has the specified value for the
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 Zone by group index
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
  #
@@ -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
- zone_id = 1 if zone_id > 6 else zone_id # TODO - test A1 workaround
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="*", once=False):
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=None) -> asyncio.Future:
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.set_result(data)
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"timeout waiting for future")
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=None,
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 event in [event_type, self.WILDCARD]:
140
- if event in self.subscribers:
141
- for callback, subscribed_entity, once in self.subscribers[
142
- event
143
- ]:
144
- if entity is None or subscribed_entity in {
145
- entity,
146
- self.WILDCARD,
147
- }:
148
- await callback(data, entity, event_type)
149
- if once:
150
- self.unsubscribe(event, callback)
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 = False if self.is_party_zone_member else True
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 happit of caching the last song played, so we need to clear it
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 happit of caching old track meta when part of a group
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 happit of caching the last song played, so we need to clear it
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
- # ID and serial number futures
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
- # Wait for the ID, serial and name to be returned from the device
109
- received_id = await future_id
110
- received_serial = await future_serial
111
- await future_name
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 happit of caching the last songs metadata
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.mute else True
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
1
+ Metadata-Version: 2.2
2
2
  Name: vsslctrl
3
- Version: 0.1.11.dev1
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
- Package for controlling [VSSL's](https://www.vssl.com/) range of streaming amplifiers.
24
+ Package for controlling [VSSL's](https://www.vssl.com/) range of streaming amplifiers.
25
25
 
26
- **`vsslctrl` is not endorsed or affiliated with [VSSL](https://www.vssl.com/) in any manner.**
26
+ ## Coverage
27
27
 
28
- ## Help
28
+ Tested on:
29
29
 
30
- I am looking for testers with any VSSL amplifier models, please get in touch if you interested in helping. <vsslcontrolled@proton.me>
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
- - **A.1** software version **p15265.033.3703** (reported)
37
- - **A.3** software version **p12013.141.3703** (reported)
38
- - **A.3x** software version **p15305.016.3701**
39
- - **A.6x** software version **p15305.017.3701** (reported)
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 same network. If you dont know what this is, then you can ignore this notice.
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(ZoneIDs.A1, '192.168.1.10')
77
+ a1 = vssl.add_zone('192.168.1.10')
66
78
 
67
- # Connect and initiate zones.
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(ZoneIDs.ZONE_1, '192.168.1.10')
100
- zone2 = vssl.add_zone(ZoneIDs.ZONE_2, '192.168.1.11')
101
- zone3 = vssl.add_zone(ZoneIDs.ZONE_3, '192.168.1.12')
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 initiate zones.
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
- # API
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
- Most functionality is achived via `getters` and `setters` of the two main classes `Vssl`, `Zone`.
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 zones name
180
+ # Setting the zone name
134
181
  zone1.settings.name = 'Living Room'
135
- >>> None
182
+ >>> 'Old Zone Name'
136
183
 
137
184
  # Printing zone name
138
- zone_name = zone1.settings.name
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` wont 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.
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 | Notes / Defaults |
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 analog input (local) over stream
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
- Officially unsupported in X series amplifiers.
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,