vsslctrl 0.1.3.dev1__py3-none-any.whl → 0.1.5.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,6 @@
1
1
  from .core import Vssl
2
2
  from .zone import Zone
3
+ from .data_structure import ZoneIDs
4
+ from .device import Models as DeviceModels
3
5
 
4
6
  VSSL_NAME = "VSSL"
vsslctrl/api_alpha.py CHANGED
@@ -558,8 +558,8 @@ class APIAlpha(APIBase):
558
558
  def response_action_00_00(self, metadata: list):
559
559
  self._log_debug(f"Received 00 Status: {metadata}")
560
560
 
561
- # Workout how many zones we have
562
- self.vssl._infer_model_zone_qty(metadata)
561
+ # Guess device model
562
+ self.vssl._infer_device_model(metadata)
563
563
 
564
564
  # Analog output source
565
565
  key = DeviceStatusExtKeys.add_zone_to_bus_key(self.zone.id)
vsslctrl/api_base.py CHANGED
@@ -158,6 +158,19 @@ class APIBase(ABC):
158
158
 
159
159
  except asyncio.CancelledError:
160
160
  self._log_debug(f"writer close timeout")
161
+ except asyncio.TimeoutError as e:
162
+ self._log_error(f"Timeout while closing connection: {e}")
163
+ # Handle the timeout: log, retry, or take other actions
164
+ except ConnectionResetError as e:
165
+ self._log_error(f"Connection reset by peer: {e}")
166
+ # Handle the error: log, retry, or take other actions
167
+ except Exception as e:
168
+ self._log_error(f"Unexpected error occurred while disconnecting: {e}")
169
+ # Handle unexpected errors
170
+ finally:
171
+ self._writer = None
172
+
173
+ self._reader = None
161
174
 
162
175
  self._log_info(f"{self.host}:{self.port}: disconnected")
163
176
 
vsslctrl/core.py CHANGED
@@ -10,7 +10,8 @@ from .event_bus import EventBus
10
10
  from .settings import VsslSettings
11
11
  from .decorators import logging_helpers
12
12
  from .discovery import check_zeroconf_availability, fetch_zone_id_serial
13
- from .data_structure import DeviceModels
13
+ from .device import Models
14
+ from .data_structure import ZoneIDs
14
15
 
15
16
 
16
17
  @logging_helpers("VSSL:")
@@ -23,14 +24,13 @@ class Vssl:
23
24
  class Events:
24
25
  PREFIX = "vssl."
25
26
  MODEL_CHANGE = PREFIX + "model_changed"
26
- MODEL_ZONE_QTY_CHANGE = PREFIX + "model_zone_qty_changed"
27
27
  SW_VERSION_CHANGE = PREFIX + "sw_version_changed"
28
28
  SERIAL_CHANGE = PREFIX + "serial_changed"
29
29
  ALL = EventBus.WILDCARD
30
30
 
31
31
  def __init__(
32
32
  self,
33
- model: DeviceModels = None,
33
+ model: Models = None,
34
34
  zones: Union[str, List[str]] = None,
35
35
  ):
36
36
  self.event_bus = EventBus()
@@ -38,10 +38,10 @@ class Vssl:
38
38
  self._sw_version = None
39
39
  self._serial = None
40
40
  self._model = None
41
- self._model_zone_qty = 0
42
41
  self.settings = VsslSettings(self)
43
42
 
44
- self.model = model
43
+ if model is not None:
44
+ self.model = model
45
45
 
46
46
  # Add zones if any are passed
47
47
  if zones:
@@ -62,22 +62,22 @@ class Vssl:
62
62
  try:
63
63
  key, first_zone = zones_to_init.popitem()
64
64
 
65
- # If we pass a model to the zone, the zone count will be worked out from that,
66
- # otherwise we will try and work it out once we receive some info about he device
67
- if not self.model_zone_qty:
68
- future_model_zone_qty = self.event_bus.future(
69
- self.Events.MODEL_ZONE_QTY_CHANGE, self.ENTITY_ID
65
+ # If we dont pass a model we will default to the zone count of the X series amps once we
66
+ # know some info about the device
67
+ if not self.model:
68
+ future_model = self.event_bus.future(
69
+ self.Events.MODEL_CHANGE, self.ENTITY_ID
70
70
  )
71
71
 
72
72
  # Lets make sure the zone is initialised, otherwsie we fail all
73
73
  await first_zone.initialise()
74
74
 
75
- # Only continue after we now how many zones the device supports
75
+ # Only continue after we have a model
76
76
  try:
77
- if not self.model_zone_qty:
78
- await asyncio.wait_for(future_model_zone_qty, timeout=init_timeout)
77
+ if not self.model:
78
+ await asyncio.wait_for(future_model, timeout=init_timeout)
79
79
 
80
- if len(self.zones) > self.model_zone_qty:
80
+ if len(self.zones) > self.model.zone_count:
81
81
  raise VsslCtrlException("")
82
82
 
83
83
  except asyncio.TimeoutError:
@@ -87,7 +87,8 @@ class Vssl:
87
87
  raise VsslCtrlException(message)
88
88
 
89
89
  except VsslCtrlException:
90
- message = f"Device model only has {self.model_zone_qty} zones instead of {len(self.zones)}"
90
+ zone_count = self.model.zone_count
91
+ message = f"Device model only has {zone_count} zones instead of {len(self.zones)}"
91
92
  self._log_critical(message)
92
93
  await first_zone.disconnect()
93
94
  raise VsslCtrlException(message)
@@ -173,47 +174,32 @@ class Vssl:
173
174
  return self._model
174
175
 
175
176
  @model.setter
176
- def model(self, model: str):
177
- if self.model != model:
178
- if DeviceModels.is_valid(model):
179
- self._set_property("model", DeviceModels(model))
180
- else:
181
- message = f"DeviceModels {model} doesnt exist"
182
- self._log_error(message)
183
- raise VsslCtrlException(message)
184
-
185
- #
186
- # The amount of zones this VSSL has. This is not how many zones have been initialised
187
- # but how many zones the model has in total. We can have 1, 3 or 6 zones.
188
- #
189
- @property
190
- def model_zone_qty(self):
191
- if not self._model_zone_qty and self.model is not None:
192
- self._model_zone_qty = DeviceModels.zone_count(self.model.value)
193
-
194
- return self._model_zone_qty
195
-
196
- @model_zone_qty.setter
197
- def model_zone_qty(self, model_zone_qty: int):
198
- pass # read-only
177
+ def model(self, model):
178
+ if Models.is_valid(model):
179
+ self._set_property("model", Models(model).value)
180
+ elif isinstance(model, str):
181
+ model = model.upper()
182
+ if hasattr(Models, model):
183
+ self._set_property("model", getattr(Models, model).value)
184
+ else:
185
+ message = f"Model {model} doesnt exist"
186
+ self._log_error(message)
187
+ raise VsslCtrlException(message)
199
188
 
200
189
  #
201
- # Work out the model_zone_qty given device info
190
+ # Work out the a model given some device info
202
191
  #
203
- def _infer_model_zone_qty(self, data: Dict[str, int]):
204
- if not self.model_zone_qty:
192
+ def _infer_device_model(self, data: Dict[str, int]):
193
+ # if we dont have a model, default to x series
194
+ if not self.model:
205
195
  zone_count = sum(
206
196
  1 for key in data if key.startswith("B") and key.endswith("Src")
207
197
  )
208
198
 
209
199
  if zone_count in (1, 3, 6):
210
- self._model_zone_qty = zone_count
200
+ self.model = f"A{zone_count}X"
211
201
  else:
212
- self._model_zone_qty = 1
213
-
214
- self.event_bus.publish(
215
- self.Events.MODEL_ZONE_QTY_CHANGE, self.ENTITY_ID, self.model_zone_qty
216
- )
202
+ self.model = Models.A1X
217
203
 
218
204
  #
219
205
  # Disconnect / Shutdown
@@ -234,9 +220,9 @@ class Vssl:
234
220
  #
235
221
  # Add a Zone
236
222
  #
237
- def add_zone(self, zone_index: "Zone.IDs", host: str):
238
- if Zone.IDs.is_not_valid(zone_index):
239
- error = f"Zone.IDs {zone_index} doesnt exist"
223
+ def add_zone(self, zone_index: ZoneIDs, host: str):
224
+ if ZoneIDs.is_not_valid(zone_index):
225
+ error = f"ZoneIDs {zone_index} doesnt exist"
240
226
  self._log_error(error)
241
227
  raise ZoneError(error)
242
228
  return None
@@ -262,7 +248,7 @@ class Vssl:
262
248
  #
263
249
  # Get a Zone by ID
264
250
  #
265
- def get_zone(self, zone_index: "Zone.IDs"):
251
+ def get_zone(self, zone_index: ZoneIDs):
266
252
  if zone_index in self.zones:
267
253
  return self.zones[zone_index]
268
254
  else:
@@ -29,33 +29,50 @@ class VsslIntEnum(VsslEnum, IntEnum):
29
29
  """IntEnum"""
30
30
 
31
31
 
32
- class DeviceModels(VsslEnum):
33
- A1 = "a1"
34
- A3 = "a3"
35
- A6 = "a6"
36
- A1X = "a1x"
37
- A3X = "a3x"
38
- A6X = "a6x"
32
+ #
33
+ # Zones IDs
34
+ #
35
+ # Moved here to help with circular imports
36
+ #
37
+ class ZoneIDs(VsslIntEnum):
38
+ ZONE_1 = 1
39
+ ZONE_2 = 2
40
+ ZONE_3 = 3
41
+ ZONE_4 = 4
42
+ ZONE_5 = 5
43
+ ZONE_6 = 6
39
44
 
40
- @staticmethod
41
- # Get the amount of zones the model has by pulling out the number in the model
42
- def zone_count(model: str):
43
- if DeviceModels.is_valid(model.lower()):
44
- pattern = r"\d+"
45
- match = re.search(pattern, model)
46
- if match:
47
- number = int(match.group())
48
- return number
49
- return 1
50
-
51
-
52
- """ JSON Structure
53
-
54
- DO NOT CHANGE - VSSL Defined
55
-
56
- {'B1Src': '3', 'B2Src': '4', 'B3Src': '5', 'B1Nm': '', 'B2Nm': 'Optical In', 'dev': 'Device Name', 'ver': 'p15305.016.3701'}
57
45
 
58
46
  """
47
+ JSON Structure
48
+
49
+ DO NOT CHANGE - VSSL Defined
50
+
51
+ A3.x
52
+ {
53
+ "B1Src": "3",
54
+ "B2Src": "4",
55
+ "B3Src": "5",
56
+ "B1Nm": "",
57
+ "B2Nm": "Optical In",
58
+ "dev": "Device Name",
59
+ "ver": "p15305.016.3701"
60
+ }
61
+
62
+ A6.x
63
+ {
64
+ "B1Src": "3",
65
+ "B2Src": "4",
66
+ "B3Src": "5",
67
+ "B4Src": "6",
68
+ "B5Src": "7",
69
+ "B6Src": "8",
70
+ "B1Nm": "",
71
+ "B2Nm": "",
72
+ "dev": "VSSL A.6x",
73
+ "ver": "p15305.017.3701"
74
+ }
75
+ """
59
76
 
60
77
 
61
78
  class DeviceStatusExtKeys:
@@ -75,13 +92,50 @@ class DeviceStatusExtKeys:
75
92
  return f"B{zone_id}Src"
76
93
 
77
94
 
78
- """ JSON Structure
79
-
80
- DO NOT CHANGE - VSSL Defined
81
-
82
- {'id': '1', 'ac': '0', 'mc': 'XXXXXXXXXXXX', 'vol': '20', 'mt': '0', 'pa': '0', 'rm': '0', 'ts': '14',
83
- 'alex': '14', 'nmd': '0', 'ird': '14', 'lb': '24', 'tp': '13', 'wr': '0', 'as': '0', 'rg': '0'}
84
-
95
+ """
96
+ JSON Structure
97
+
98
+ DO NOT CHANGE - VSSL Defined
99
+
100
+ A3.x
101
+ {
102
+ "id": "1",
103
+ "ac": "0",
104
+ "mc": "XXXXXXXXXXXX",
105
+ "vol": "20",
106
+ "mt": "0",
107
+ "pa": "0",
108
+ "rm": "0",
109
+ "ts": "14",
110
+ "alex": "14",
111
+ "nmd": "0",
112
+ "ird": "14",
113
+ "lb": "24",
114
+ "tp": "13",
115
+ "wr": "0",
116
+ "as": "0",
117
+ "rg": "0"
118
+ }
119
+
120
+ A6.x
121
+ {
122
+ "id": "1",
123
+ "ac": "0",
124
+ "mc": "XXXXXXXXXXXX",
125
+ "vol": "50",
126
+ "mt": "0",
127
+ "pa": "0",
128
+ "rm": "0",
129
+ "ts": "0",
130
+ "alex": "126",
131
+ "nmd": "0",
132
+ "ird": "255",
133
+ "lb": "17",
134
+ "tp": "16",
135
+ "wr": "0",
136
+ "as": "0",
137
+ "rg": "0"
138
+ }
85
139
  """
86
140
 
87
141
 
@@ -97,13 +151,42 @@ class ZoneStatusExtKeys:
97
151
  DISABLED = "wr"
98
152
 
99
153
 
100
- """ JSON Structure
101
-
102
- DO NOT CHANGE - VSSL Defined
103
-
104
- {'mono': '0', 'AiNm': 'Analog In 1', 'eq1': '100', 'eq2': '100', 'eq3': '100', 'eq4': '100',
105
- 'eq5': '100', 'eq6': '100', 'eq7': '100', 'voll': '75', 'volr': '75', 'vold': '0'}
106
-
154
+ """
155
+ JSON Structure
156
+
157
+ DO NOT CHANGE - VSSL Defined
158
+
159
+ A3.x
160
+ {
161
+ "mono": "0",
162
+ "AiNm": "Analog In 1",
163
+ "eq1": "100",
164
+ "eq2": "100",
165
+ "eq3": "100",
166
+ "eq4": "100",
167
+ "eq5": "100",
168
+ "eq6": "100",
169
+ "eq7": "100",
170
+ "voll": "75",
171
+ "volr": "75",
172
+ "vold": "0"
173
+ }
174
+
175
+ A6.x
176
+ {
177
+ "mono": "0",
178
+ "AiNm": "",
179
+ "eq1": "100",
180
+ "eq2": "100",
181
+ "eq3": "100",
182
+ "eq4": "100",
183
+ "eq5": "100",
184
+ "eq6": "100",
185
+ "eq7": "100",
186
+ "voll": "75",
187
+ "volr": "75",
188
+ "vold": "0"
189
+ }
107
190
  """
108
191
 
109
192
 
@@ -122,13 +205,47 @@ class ZoneEQStatusExtKeys:
122
205
  VOL_DEFAULT_ON = "vold"
123
206
 
124
207
 
125
- """ JSON Structure
126
-
127
- DO NOT CHANGE - VSSL Defined
128
-
129
- {'ECO': '0', 'eqsw': '1', 'inSrc': '0', 'SP': '0', 'BF1': '0', 'BF2': '0', 'BF3': '0',
130
- 'GRM': '0', 'GRS': '255', 'Pwr': '0', 'Bvr': '1', 'fxv': '24', 'AtPwr': '1'}
131
-
208
+ """
209
+ JSON Structure
210
+
211
+ DO NOT CHANGE - VSSL Defined
212
+
213
+ A3.x
214
+ {
215
+ "ECO": "0",
216
+ "eqsw": "1",
217
+ "inSrc": "0",
218
+ "SP": "0",
219
+ "BF1": "0",
220
+ "BF2": "0",
221
+ "BF3": "0",
222
+ "GRM": "0",
223
+ "GRS": "255",
224
+ "Pwr": "0",
225
+ "Bvr": "1",
226
+ "fxv": "24",
227
+ "AtPwr": "1"
228
+ }
229
+
230
+ A6.x
231
+ {
232
+ "ECO": "0",
233
+ "eqsw": "1",
234
+ "inSrc": "0",
235
+ "SP": "0",
236
+ "BF1": "0",
237
+ "BF2": "0",
238
+ "BF3": "0",
239
+ "BF4": "0",
240
+ "BF5": "0",
241
+ "BF6": "0",
242
+ "GRM": "0",
243
+ "GRS": "255",
244
+ "Pwr": "0",
245
+ "Bvr": "2",
246
+ "fxv": "25",
247
+ "AtPwr": "1"
248
+ }
132
249
  """
133
250
 
134
251
 
@@ -148,17 +265,37 @@ class ZoneRouterStatusExtKeys:
148
265
  return f"BF{zone_id}"
149
266
 
150
267
 
151
- """ JSON Structure
152
-
153
- DO NOT CHANGE - VSSL Defined
154
-
155
- {'Album': 'International Skankers', 'Artist': 'Ashkabad', 'BitDepth': 16,
156
- 'BitRate': '320000', 'CoverArtUrl': 'https://i.scdn.co/image/ab67616d0000b2730cbb03a339c6ffd18d10eab2',
157
- 'Current Source': 4, 'Current_time': -1, 'DSDType': '', 'Fav': False, 'FileSize': 0, 'Genre': '',
158
- 'Index': 0, 'Mime': 'Ogg', 'Next': False, 'PlayState': 0, 'PlayUrl': 'spotify:track:0IHTiLO5qBYhf7Hmn0UDBN',
159
- 'Prev': False, 'Repeat': 0, 'SampleRate': '44100', 'Seek': False, 'Shuffle': 0, 'SinglePlay': False,
160
- 'TotalTime': 203087, 'TrackName': 'Beijing'}
161
-
268
+ """
269
+ JSON Structure
270
+
271
+ DO NOT CHANGE - VSSL Defined
272
+
273
+ {
274
+ "Album": "International Skankers",
275
+ "Artist": "Ashkabad",
276
+ "BitDepth": 16,
277
+ "BitRate": "320000",
278
+ "CoverArtUrl": "https://i.scdn.co/image/ab67616d0000b2730cbb03a339c6ffd18d10eab2",
279
+ "Current Source": 4,
280
+ "Current_time": -1,
281
+ "DSDType": "",
282
+ "Fav": False,
283
+ "FileSize": 0,
284
+ "Genre": "",
285
+ "Index": 0,
286
+ "Mime": "Ogg",
287
+ "Next": False,
288
+ "PlayState": 0,
289
+ "PlayUrl": "spotify:track:0IHTiLO5qBYhf7Hmn0UDBN",
290
+ "Prev": False,
291
+ "Repeat": 0,
292
+ "SampleRate": "44100",
293
+ "Seek": False,
294
+ "Shuffle": 0,
295
+ "SinglePlay": False,
296
+ "TotalTime": 203087,
297
+ "TrackName": "Beijing"
298
+ }
162
299
  """
163
300
 
164
301
 
vsslctrl/device.py ADDED
@@ -0,0 +1,115 @@
1
+ from .data_structure import VsslEnum, VsslIntEnum, ZoneIDs
2
+ from .io import AnalogOutput, InputRouter
3
+
4
+ SINGLE_ZONE = [ZoneIDs.ZONE_1]
5
+ THREE_ZONES = [ZoneIDs.ZONE_1, ZoneIDs.ZONE_2, ZoneIDs.ZONE_3]
6
+ SIX_ZONES = list(ZoneIDs)
7
+
8
+ INPUT_SOURCES_FOR_1_ZONE_DEVICE = [
9
+ InputRouter.Sources.STREAM,
10
+ InputRouter.Sources.ANALOG_IN_1,
11
+ InputRouter.Sources.OPTICAL_IN,
12
+ ]
13
+
14
+ INPUT_SOURCES_FOR_3_ZONE_DEVICE = INPUT_SOURCES_FOR_1_ZONE_DEVICE + [
15
+ InputRouter.Sources.ANALOG_IN_2,
16
+ InputRouter.Sources.ANALOG_IN_3,
17
+ ]
18
+
19
+ INPUT_SOURCES_FOR_6_ZONE_DEVICE = list(InputRouter.Sources)
20
+
21
+ ANALOG_OUTPUT_SOURCES_FOR_1_ZONE_DEVICE = [
22
+ AnalogOutput.Sources.OFF,
23
+ AnalogOutput.Sources.ZONE_1,
24
+ AnalogOutput.Sources.OPTICAL_IN,
25
+ ]
26
+
27
+ ANALOG_OUTPUT_SOURCES_FOR_3_ZONE_DEVICE = ANALOG_OUTPUT_SOURCES_FOR_1_ZONE_DEVICE + [
28
+ AnalogOutput.Sources.ZONE_2,
29
+ AnalogOutput.Sources.ZONE_3,
30
+ ]
31
+
32
+ ANALOG_OUTPUT_SOURCES_FOR_6_ZONE_DEVICE = list(AnalogOutput.Sources)
33
+
34
+
35
+ class Features(VsslIntEnum):
36
+ GROUPING = 1000
37
+ BLUETOOTH = 1001
38
+ PARTY_MODE = 1002
39
+
40
+
41
+ class Model:
42
+ def __init__(self, model: dict):
43
+ self.name = model.get("name")
44
+ self.zones = model.get("zones", [])
45
+ self.input_sources = model.get("input_sources", [])
46
+ self.analog_output_sources = model.get("analog_output_sources", [])
47
+ self.features = model.get("features", [])
48
+
49
+ @property
50
+ def zone_count(self):
51
+ return len(self.zones)
52
+
53
+ @property
54
+ def is_multizone(self):
55
+ return self.zone_count > 1
56
+
57
+ def supports_feature(self, feature: Features):
58
+ return feature in self.features
59
+
60
+
61
+ class Models(VsslEnum):
62
+ A1 = Model(
63
+ {
64
+ "name": "A1",
65
+ "zones": SINGLE_ZONE,
66
+ "input_sources": INPUT_SOURCES_FOR_1_ZONE_DEVICE,
67
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_1_ZONE_DEVICE,
68
+ "features": [Features.BLUETOOTH],
69
+ }
70
+ )
71
+ A3 = Model(
72
+ {
73
+ "name": "A3",
74
+ "zones": THREE_ZONES,
75
+ "input_sources": INPUT_SOURCES_FOR_3_ZONE_DEVICE,
76
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_3_ZONE_DEVICE,
77
+ "features": [Features.GROUPING, Features.PARTY_MODE],
78
+ }
79
+ )
80
+ A6 = Model(
81
+ {
82
+ "name": "A6",
83
+ "zones": SIX_ZONES,
84
+ "input_sources": INPUT_SOURCES_FOR_6_ZONE_DEVICE,
85
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_6_ZONE_DEVICE,
86
+ "features": [Features.GROUPING, Features.PARTY_MODE],
87
+ }
88
+ )
89
+ A1X = Model(
90
+ {
91
+ "name": "A.1x",
92
+ "zones": SINGLE_ZONE,
93
+ "input_sources": INPUT_SOURCES_FOR_1_ZONE_DEVICE,
94
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_1_ZONE_DEVICE,
95
+ "features": [Features.GROUPING, Features.BLUETOOTH],
96
+ }
97
+ )
98
+ A3X = Model(
99
+ {
100
+ "name": "A.3x",
101
+ "zones": THREE_ZONES,
102
+ "input_sources": INPUT_SOURCES_FOR_3_ZONE_DEVICE,
103
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_3_ZONE_DEVICE,
104
+ "features": [Features.GROUPING],
105
+ }
106
+ )
107
+ A6X = Model(
108
+ {
109
+ "name": "A.6x",
110
+ "zones": SIX_ZONES,
111
+ "input_sources": INPUT_SOURCES_FOR_6_ZONE_DEVICE,
112
+ "analog_output_sources": ANALOG_OUTPUT_SOURCES_FOR_6_ZONE_DEVICE,
113
+ "features": [Features.GROUPING],
114
+ }
115
+ )
vsslctrl/group.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import logging
2
2
  from . import zone
3
3
  from typing import Dict, Union
4
- from .data_structure import ZoneDataClass
4
+ from .data_structure import ZoneDataClass, ZoneIDs
5
+ from .device import Features as DeviceFeatures
5
6
 
6
7
 
7
8
  """
@@ -58,12 +59,19 @@ class ZoneGroup(ZoneDataClass):
58
59
  #
59
60
  # Group Add Zone
60
61
  #
61
- def add_member(self, zone_id: "zone.Zone.IDs"):
62
+ def add_member(self, zone_id: ZoneIDs):
63
+ # Check this device is a multizone device
64
+ if not self.zone.vssl.model.supports_feature(DeviceFeatures.GROUPING):
65
+ self.zone._log_error(
66
+ f"VSSL {self.zone.vssl.model.name} doesnt support grouping"
67
+ )
68
+ return False
69
+
62
70
  if self.zone.id == zone_id:
63
71
  self.zone._log_error(f"Zone {zone_id} cant be parent and member")
64
72
  return False
65
73
 
66
- if zone.Zone.IDs.is_not_valid(zone_id):
74
+ if ZoneIDs.is_not_valid(zone_id):
67
75
  self.zone._log_error(f"Zone {zone_id} doesnt exist")
68
76
  return False
69
77
 
@@ -84,7 +92,7 @@ class ZoneGroup(ZoneDataClass):
84
92
  #
85
93
  # Group Remove Child
86
94
  #
87
- def remove_member(self, zone_id: "zone.Zone.IDs"):
95
+ def remove_member(self, zone_id: ZoneIDs):
88
96
  self.zone.api_alpha.request_action_4B_remove(zone_id)
89
97
 
90
98
  #
@@ -132,9 +140,7 @@ class ZoneGroup(ZoneDataClass):
132
140
  pass # read-only
133
141
 
134
142
  def _set_source(self, grs: int):
135
- new_source = (
136
- zone.Zone.IDs(grs) if grs != 255 and zone.Zone.IDs.is_valid(grs) else None
137
- )
143
+ new_source = ZoneIDs(grs) if grs != 255 and ZoneIDs.is_valid(grs) else None
138
144
 
139
145
  if self.source != new_source:
140
146
  self._source = new_source
vsslctrl/io.py CHANGED
@@ -83,10 +83,14 @@ class InputRouter(ZoneDataClass):
83
83
 
84
84
  @source.setter
85
85
  def source(self, src: "InputRouter.Sources"):
86
- if self.Sources.is_valid(src):
86
+ # if self.Sources.is_valid():
87
+ if src in self.zone.vssl.model.input_sources:
88
+ # check source is avaialbe on this device
87
89
  self.zone.api_alpha.request_action_03(src)
88
90
  else:
89
- self.zone._log_error(f"InputRouter.Sources {src} doesnt exist")
91
+ self.zone._log_error(
92
+ f"InputRouter.Sources {src} doesnt exist in {list(self.zone.vssl.model.input_sources)}"
93
+ )
90
94
 
91
95
  def _set_source(self, src: int):
92
96
  if self.source != src:
@@ -135,7 +139,7 @@ class AnalogOutput(ZoneDataClass):
135
139
  self.zone = zone
136
140
 
137
141
  self._is_fixed_volume = False
138
- self._source = self.Sources(zone.id + 3)
142
+ self._source = self.Sources(zone.id + 2)
139
143
 
140
144
  #
141
145
  # Analog Output Fix Volume. Output wont respond to volume control
@@ -160,10 +164,13 @@ class AnalogOutput(ZoneDataClass):
160
164
 
161
165
  @source.setter
162
166
  def source(self, src: "AnalogOutput.Sources"):
163
- if self.Sources.is_valid(src):
167
+ # if self.Sources.is_valid(src):
168
+ if src in self.zone.vssl.model.analog_output_sources:
164
169
  self.zone.api_alpha.request_action_1D(src)
165
170
  else:
166
- self.zone._log_error(f"AnalogOutput.Sources {src} doesnt exist")
171
+ self.zone._log_error(
172
+ f"AnalogOutput.Sources {src} doesnt exist in {list(self.zone.vssl.model.analog_output_sources)}"
173
+ )
167
174
 
168
175
  def _set_source(self, src: int):
169
176
  if self.source != src:
vsslctrl/settings.py CHANGED
@@ -162,7 +162,7 @@ class ZoneSettings(ZoneDataClass):
162
162
 
163
163
  self._disabled = False
164
164
  self._name = f"Zone {zone.id}"
165
- self._mono = False
165
+ self._mono = self.StereoMono.Stereo
166
166
 
167
167
  self.eq = EQSettings(zone)
168
168
  self.volume = VolumeSettings(zone)
vsslctrl/zone.py CHANGED
@@ -13,7 +13,7 @@ from . import core
13
13
  from .api_alpha import APIAlpha
14
14
  from .api_bravo import APIBravo
15
15
  from .utils import RepeatTimer, clamp_volume
16
- from .data_structure import DeviceModels, VsslIntEnum
16
+ from .data_structure import VsslIntEnum, ZoneIDs
17
17
  from .track import TrackMetadata
18
18
  from .io import AnalogOutput, InputRouter
19
19
  from .settings import ZoneSettings
@@ -25,17 +25,6 @@ from .decorators import logging_helpers
25
25
 
26
26
  @logging_helpers()
27
27
  class Zone:
28
- #
29
- # Zones IDs
30
- #
31
- class IDs(VsslIntEnum):
32
- ZONE_1 = 1
33
- ZONE_2 = 2
34
- ZONE_3 = 3
35
- ZONE_4 = 4
36
- ZONE_5 = 5
37
- ZONE_6 = 6
38
-
39
28
  #
40
29
  # Zone Events
41
30
  #
@@ -49,7 +38,7 @@ class Zone:
49
38
  VOLUME_CHANGE = PREFIX + "volume_change"
50
39
  MUTE_CHANGE = PREFIX + "mute_change"
51
40
 
52
- def __init__(self, vssl_host: "core.Vssl", zone_id: "Zone.IDs", host: str):
41
+ def __init__(self, vssl_host: "core.Vssl", zone_id: ZoneIDs, host: str):
53
42
  self._log_prefix = f"Zone {zone_id}:"
54
43
 
55
44
  self.vssl = vssl_host
@@ -57,7 +46,7 @@ class Zone:
57
46
 
58
47
  # Data / Cache
59
48
  self._host = host
60
- self._id = self.IDs(zone_id)
49
+ self._id = ZoneIDs(zone_id)
61
50
  self._mac_addr = None
62
51
  self._serial = None
63
52
  self._volume = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: vsslctrl
3
- Version: 0.1.3.dev1
3
+ Version: 0.1.5.dev1
4
4
  Summary: Package for controlling VSSL amplifiers
5
5
  Author-email: vsslctrl <vsslcontrolled@proton.me>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -28,37 +28,45 @@ Requires-Dist: zeroconf ==0.132.2 ; extra == 'optional'
28
28
 
29
29
  I am looking for testers with any VSSL amplifier models, please get in touch if you interested in helping.
30
30
 
31
- Only tested on VSSL **A.3x** software version **p15305.016.3701**.
31
+ Important
32
+ -----------
33
+ Only tested on a VSSL **A.3x** software version **p15305.016.3701**.
34
+
35
+ **Notice:** VSSLs new iOS app titled `VSSL`can cause connection refused issues if running at the same time as `vsslctrl`. Best to not use the iOS app togeather. The `VSSL Legacy` app works fine
36
+
37
+ **Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
32
38
 
33
39
  ## TODOs
34
40
 
35
41
  * Test on other models (hardware needed)
36
42
  * Home Assistant integration. In progress, [here](https://github.com/vsslctrl/integration.home-assistant)
37
- * Function scoping to supported feature / models
43
+ * ~~Function scoping to supported feature / models~~ (needs tests)
38
44
  * Better test coverage
39
45
 
40
- Important
41
- -----------
42
- **Warning:** no *[VSSL Agent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start)* should be running on the same network. If you dont know what this is, then you can probably ignore this notice.
43
-
44
-
45
46
  Basic Usage
46
47
  -----------
47
48
  `vsslctrl` needs to be running inside a **[asyncio](https://docs.python.org/3/library/asyncio.html)** event loop.
48
49
 
49
50
  ```python
50
51
  import asyncio
51
- from vsslctrl import Vssl, Zone
52
+ from vsslctrl import Vssl, DeviceModels, Zone, ZoneIDs
52
53
 
53
54
  async def main():
54
55
 
55
56
  # Represents a physical VSSL amplifier
56
57
  vssl = Vssl()
57
58
 
59
+ """Optional to init Vssl with a device model
60
+
61
+ vssl = Vssl(DeviceModels.A3X)
62
+
63
+ If no DeviceModels is passed, vsslctrl will default to the feature set of the X series amps
64
+ """
65
+
58
66
  # Add each you wish to control
59
- zone1 = vssl.add_zone(Zone.IDs.ZONE_1, '192.168.1.10')
60
- zone2 = vssl.add_zone(Zone.IDs.ZONE_2, '192.168.1.11')
61
- zone3 = vssl.add_zone(Zone.IDs.ZONE_3, '192.168.1.12')
67
+ zone1 = vssl.add_zone(ZoneIDs.ZONE_1, '192.168.1.10')
68
+ zone2 = vssl.add_zone(ZoneIDs.ZONE_2, '192.168.1.11')
69
+ zone3 = vssl.add_zone(ZoneIDs.ZONE_3, '192.168.1.12')
62
70
  #... up to 6 zones
63
71
 
64
72
  # Connect and initiate zones.
@@ -110,7 +118,7 @@ print(zone_name)
110
118
  | ---------------------- | ----------- | ----------- |
111
119
  | `sw_version` | Software version | `str` readonly
112
120
  | `serial` | Serial number | `str` readonly
113
- | `model_zone_qty` | Number of zones the device has | `int` readonly
121
+ | `model` | Device Model | `int` readonly
114
122
  | `reboot()` | Reboot all zones | `func` |
115
123
 
116
124
  ```python
@@ -152,7 +160,7 @@ vssl.settings.power.adaptive = True
152
160
 
153
161
  | Property | Description | Type | Values |
154
162
  | ---------------------- | ----------- | ----------- |----------- |
155
- | `id` | Zone number / ID | `int` readonly | `Zone.IDs`
163
+ | `id` | Zone number / ID | `int` readonly | `ZoneIDs`
156
164
  | `host` | IP address | `str` readonly
157
165
  | `volume` | Volume | `int` | `0...100`
158
166
  | `volume_raise([step=1])` | Raise volume by `step` | `func` | step: `int` `1...100`
@@ -219,7 +227,7 @@ zone1.transport.state = ZoneTransport.States.PAUSE
219
227
  ## `Zone.track`
220
228
 
221
229
  * Not all sources have complete metadata - missing value will be set to defaults.
222
- * Airplay track position is not avaiable.
230
+ * Airplay track `progress` is not avaiable.
223
231
 
224
232
  | Property | Description | Type | Values |
225
233
  | ---------------------- | ----------- | ----------- |----------- |
@@ -256,20 +264,20 @@ Working on A.3x but offically unsupported in x series amplifiers.
256
264
 
257
265
  | Property | Description | Type | Values |
258
266
  | ---------------------- | ----------- | ----------- |----------- |
259
- | `source` | Zone ID of group master / source | `int` readonly | `Zone.IDs`
267
+ | `source` | Zone ID of group master / source | `int` readonly | `ZoneIDs`
260
268
  | `is_master` | This zone is the group master | `bool` readonly
261
- | `add_member()` | Add zone to group / create group | `func` | `Zone.IDs`
262
- | `remove_member()` | Remove zone from group | `func` | `Zone.IDs`
269
+ | `add_member()` | Add zone to group / create group | `func` | `ZoneIDs`
270
+ | `remove_member()` | Remove zone from group | `func` | `ZoneIDs`
263
271
  | `dissolve()` | Dissolve group / remove all members | `func` |
264
272
  | `leave()` | Leave the group if a member | `func` |
265
273
 
266
274
  ```python
267
275
  """Examples"""
268
276
  # Add group 2 to a group with zone 1 as master
269
- zone1.group.add_member(Zone.IDs.ZONE_2)
270
- # Remove zone 2 from group.
277
+ zone1.group.add_member(ZoneIDs.ZONE_2)
278
+ # Remove zone 2 from group
271
279
  zone2.group.leave() # or
272
- zone1.group.remove_member(Zone.IDs.ZONE_2)
280
+ zone1.group.remove_member(ZoneIDs.ZONE_2)
273
281
  # If zone 1 is a master, remove all members
274
282
  zone1.group.dissolve()
275
283
  ```
@@ -371,6 +379,53 @@ EQ to be set in [decibel](https://en.wikipedia.org/wiki/Decibel) using a range `
371
379
  zone1.settings.eq.khz1_db = -2
372
380
  ```
373
381
 
382
+ ## Another (Lite) Way
383
+
384
+ If you perfer to not run the complete intergration, you can send basic HEX commands to the VSSL device using [Netcat](https://nc110.sourceforge.io/) (or any network tool) on port `50002`.
385
+
386
+ | HEX | Description |
387
+ | ---------------------- | ----------- |
388
+ | `\x10\x05\x03\x0{Zone Number}\xff\x03` | Volume Up
389
+ | `\x10\x05\x03\x0{Zone Number}\xfe\x03` | Volume Down
390
+ | `\x10\x11\x02\x0{Zone Number}\x01` | Mute
391
+ | All commands can be found by looking [here](https://github.com/vsslctrl/vsslctrl/blob/2c43c2f2393b94bc0e062d2ab90144343eca16ef/vsslctrl/api_alpha.py) |
392
+
393
+
394
+ The `volume up` HEX command for `Zone 2` would be `\x10\x05\x03\x02\xff\x03`
395
+
396
+ Now send the raw HEX using Netcat to the device using this syntax:
397
+
398
+ `echo -e "{HEX Command}" | nc {IP Address} 50002`
399
+
400
+ For example to send `volume up` to `Zone 2`:
401
+
402
+ `echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002`
403
+
404
+ Home Assistant `Configuration.yaml` example:
405
+
406
+ ```ymal
407
+ ...
408
+
409
+ shell_command:
410
+ #Zone 1
411
+ vssl_zone_1_volume_up: 'echo -e "\x10\x05\x03\x01\xff\x03" | nc 192.168.1.10 50002'
412
+ vssl_zone_1_volume_down: 'echo -e "\x10\x05\x03\x01\xfe\x03" | nc 192.168.1.10 50002'
413
+ vssl_zone_1_mute: 'echo -e "\x10\x11\x02\x01\x01" | nc 192.168.1.10 50002'
414
+ vssl_zone_1_unmute: 'echo -e "\x10\x11\x02\x01\x00" | nc 192.168.1.10 50002'
415
+ #Zone 2
416
+ vssl_zone_2_volume_up: 'echo -e "\x10\x05\x03\x02\xff\x03" | nc 192.168.1.11 50002'
417
+ vssl_zone_2_volume_down: 'echo -e "\x10\x05\x03\x02\xfe\x03" | nc 192.168.1.11 50002'
418
+ vssl_zone_2_mute: 'echo -e "\x10\x11\x02\x02\x01" | nc 192.168.1.11 50002'
419
+ vssl_zone_2_unmute: 'echo -e "\x10\x11\x02\x02\x00" | nc 192.168.1.11 50002'
420
+ #Zone 3
421
+ vssl_zone_3_volume_up: 'echo -e "\x10\x05\x03\x03\xff\x03" | nc 192.168.1.12 50002'
422
+ vssl_zone_3_volume_down: 'echo -e "\x10\x05\x03\x03\xfe\x03" | nc 192.168.1.12 50002'
423
+ vssl_zone_3_mute: 'echo -e "\x10\x11\x02\x03\x01" | nc 192.168.1.12 50002'
424
+ vssl_zone_3_unmute: 'echo -e "\x10\x11\x02\x03\x00" | nc 192.168.1.12 50002'
425
+
426
+ ...
427
+ ```
428
+
374
429
  ## Credit
375
430
 
376
431
  The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS app and their deprecated [vsslagent](https://vssl.gitbook.io/vssl-rest-api/getting-started/start).
@@ -382,6 +437,7 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
382
437
  * VSSL can not start a stream except for playing a URL directly. This is a limitation of the hardware itself.
383
438
  * Not all sources set the volume to 0 when the zone is muted
384
439
  * Grouping feedback is flaky on the X series amplifiers
440
+ * Airplay `Zone.track.progress` is not avaiable.
385
441
  * Cant stop a URL playback, feedback is worng at least
386
442
  * VSSL likes to cache old track metadata. For example when playing a URL after Spotify, often the device will respond with the previous (Spotify) tracks metadata
387
443
  * `stop()` is intended to disconnect the client and pause the stream. Doesnt always function this way, depending on stream source
@@ -392,4 +448,5 @@ The VSSL API was reverse engineered using Wireshark, VSSLs native "legacy" iOS a
392
448
  * REST API / Web App
393
449
  * Save and recall EQ
394
450
  * A.1(x) coverage i.e Bluetooth
451
+ * IR Control
395
452
 
@@ -0,0 +1,23 @@
1
+ vsslctrl/__init__.py,sha256=W7cljoE7L3DukAVr_TEqkxFycYrUbJiqaHrOlbPkmKU,145
2
+ vsslctrl/api_alpha.py,sha256=wsrwc4owIlRZFJK40evdRDh-iY1ZOxuCjDfsy2xHlt8,33216
3
+ vsslctrl/api_base.py,sha256=a80CRtUkYK2OwXQOL8bP-YOvt4YE-ALd-VONza_gjK8,9623
4
+ vsslctrl/api_bravo.py,sha256=HW-toa38umSpKAij_zxyypsMoU8S_AXpAbBmAaRqgow,12196
5
+ vsslctrl/core.py,sha256=cMMfrZzSDtIO2J3__EsA6aAWsGVuxSWkfkC8byXpQSY,8823
6
+ vsslctrl/data_structure.py,sha256=RoOAB4VevWpiNJ-WdW3_s7onHTjLFenSzM2nLaV3vcA,7482
7
+ vsslctrl/decorators.py,sha256=lG8cYSSA-fNvdcp_90lv58tj50bF6iPoigLKcucWSSI,1707
8
+ vsslctrl/device.py,sha256=P4sYiibPPFp6iDuF93-OKIsWBL_9vtIxBPDc5uRwvbA,3418
9
+ vsslctrl/discovery.py,sha256=81uA3rbm-X_CR0Q0LsCQJyCs7638Bb-mgqNZWqV_dq4,5572
10
+ vsslctrl/event_bus.py,sha256=9liAEJnZfthJwy5O-qc3pDIWRBv5AcV19PjMCwH_niU,5014
11
+ vsslctrl/exceptions.py,sha256=EEfNlu--9PMIidj3V3ZB8c7p3My8siqKw7YIOjPFaqE,419
12
+ vsslctrl/group.py,sha256=B7lmJb6DIwXGnMyTKsp-JfRWVCVRFQYzlvS71tutfx4,5368
13
+ vsslctrl/io.py,sha256=crzz5-vsdQfVFBL9a1Btle8mq5zx41SsVSt4N5cWPaE,6048
14
+ vsslctrl/settings.py,sha256=6zZUf2y-JvmEz6aREMZqxjTptMtVJFO6dGkXdCxR1NU,16587
15
+ vsslctrl/track.py,sha256=_lQNHYe8ZBm3v1lehFLjZruF5-Rz8PGWuQN-zISBgwc,10136
16
+ vsslctrl/transport.py,sha256=XJFy0kl9y7hm3YV4_9ElBrq3u6--_k73UKJqKdO6Rqg,6993
17
+ vsslctrl/utils.py,sha256=CqipPCffDjXCJ00NHAABOI886xRMyvnqkp4tr8i9uhg,1744
18
+ vsslctrl/zone.py,sha256=5UOgIslYLxEnZ9cjQc5nCgJF1ENlLMsoKcbjORW3hQk,12721
19
+ vsslctrl-0.1.5.dev1.dist-info/LICENSE,sha256=67Ekj8PIxjEvkPd7L3GYJ8dyvn1EC4sPM3q3FKtpN6w,1065
20
+ vsslctrl-0.1.5.dev1.dist-info/METADATA,sha256=mB3khdgA1cwsCVMu1nr1L-RyA-38q-SiMOVWcPGzX6o,16557
21
+ vsslctrl-0.1.5.dev1.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
22
+ vsslctrl-0.1.5.dev1.dist-info/top_level.txt,sha256=MRycO_RKageNX27UZ2bLJDMlfeWTQzUZlsd3hS-0u3Y,9
23
+ vsslctrl-0.1.5.dev1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (70.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,22 +0,0 @@
1
- vsslctrl/__init__.py,sha256=8neULYocxQjushISE5_1ziZIWSxFWuf2vseXGcrEmmk,66
2
- vsslctrl/api_alpha.py,sha256=tgOG2vTth93HxAmtbDpXCOr1OcJ6dFsfyl3tSiqLMFU,33230
3
- vsslctrl/api_base.py,sha256=BpDjKTopnWr9UXTPxOywri_2ndfV7m0MYiFk1nz4514,8998
4
- vsslctrl/api_bravo.py,sha256=HW-toa38umSpKAij_zxyypsMoU8S_AXpAbBmAaRqgow,12196
5
- vsslctrl/core.py,sha256=_WoB5uiRwzP5vi27dWtRgcNcd0WsWIsCT7LoIrtMqfE,9443
6
- vsslctrl/data_structure.py,sha256=JYB8d96B49kj1XeSKisqCcBu723WcchlpC_-WDmOaaM,6557
7
- vsslctrl/decorators.py,sha256=lG8cYSSA-fNvdcp_90lv58tj50bF6iPoigLKcucWSSI,1707
8
- vsslctrl/discovery.py,sha256=81uA3rbm-X_CR0Q0LsCQJyCs7638Bb-mgqNZWqV_dq4,5572
9
- vsslctrl/event_bus.py,sha256=9liAEJnZfthJwy5O-qc3pDIWRBv5AcV19PjMCwH_niU,5014
10
- vsslctrl/exceptions.py,sha256=EEfNlu--9PMIidj3V3ZB8c7p3My8siqKw7YIOjPFaqE,419
11
- vsslctrl/group.py,sha256=xHCvKR7nEbZRkO-oE0xd9oAkA_Vv_gSMIRvzQ2t3v28,5091
12
- vsslctrl/io.py,sha256=UnuSNJeB8Mf1fy3GyrMVXaMhn7fIqnfVDtruwfxaC1M,5717
13
- vsslctrl/settings.py,sha256=nkW4SpsVhYL3Ep6T7z5j2yQ0viWXW7NIZ3anc2WfYpA,16570
14
- vsslctrl/track.py,sha256=_lQNHYe8ZBm3v1lehFLjZruF5-Rz8PGWuQN-zISBgwc,10136
15
- vsslctrl/transport.py,sha256=XJFy0kl9y7hm3YV4_9ElBrq3u6--_k73UKJqKdO6Rqg,6993
16
- vsslctrl/utils.py,sha256=CqipPCffDjXCJ00NHAABOI886xRMyvnqkp4tr8i9uhg,1744
17
- vsslctrl/zone.py,sha256=-Uqt0picOp9SEwZT0s4tM11kkmnvj3MkkYjOo7lqXtc,12901
18
- vsslctrl-0.1.3.dev1.dist-info/LICENSE,sha256=67Ekj8PIxjEvkPd7L3GYJ8dyvn1EC4sPM3q3FKtpN6w,1065
19
- vsslctrl-0.1.3.dev1.dist-info/METADATA,sha256=c6aeDgtbpFKzQGeSy-sa2mDS_9RKr4GiFFIx8I3cToY,14078
20
- vsslctrl-0.1.3.dev1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
21
- vsslctrl-0.1.3.dev1.dist-info/top_level.txt,sha256=MRycO_RKageNX27UZ2bLJDMlfeWTQzUZlsd3hS-0u3Y,9
22
- vsslctrl-0.1.3.dev1.dist-info/RECORD,,