vsslctrl 0.1.0.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 +4 -0
- vsslctrl/api_alpha.py +1040 -0
- vsslctrl/api_base.py +321 -0
- vsslctrl/api_bravo.py +419 -0
- vsslctrl/core.py +322 -0
- vsslctrl/data_structure.py +242 -0
- vsslctrl/decorators.py +61 -0
- vsslctrl/discovery.py +193 -0
- vsslctrl/event_bus.py +159 -0
- vsslctrl/exceptions.py +21 -0
- vsslctrl/group.py +187 -0
- vsslctrl/io.py +229 -0
- vsslctrl/settings.py +655 -0
- vsslctrl/track.py +337 -0
- vsslctrl/transport.py +242 -0
- vsslctrl/utils.py +75 -0
- vsslctrl/zone.py +452 -0
- vsslctrl-0.1.0.dev1.dist-info/LICENSE +21 -0
- vsslctrl-0.1.0.dev1.dist-info/METADATA +385 -0
- vsslctrl-0.1.0.dev1.dist-info/RECORD +22 -0
- vsslctrl-0.1.0.dev1.dist-info/WHEEL +5 -0
- vsslctrl-0.1.0.dev1.dist-info/top_level.txt +1 -0
vsslctrl/core.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Dict, Union, List
|
|
6
|
+
|
|
7
|
+
from .zone import Zone
|
|
8
|
+
from .exceptions import VsslCtrlException, ZoneError, ZeroConfNotInstalled
|
|
9
|
+
from .event_bus import EventBus
|
|
10
|
+
from .settings import VsslSettings
|
|
11
|
+
from .decorators import logging_helpers
|
|
12
|
+
from .discovery import check_zeroconf_availability, fetch_zone_id_serial
|
|
13
|
+
from .data_structure import DeviceModels
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@logging_helpers("VSSL:")
|
|
17
|
+
class Vssl:
|
|
18
|
+
ENTITY_ID = 0
|
|
19
|
+
|
|
20
|
+
#
|
|
21
|
+
# VSSL Events
|
|
22
|
+
#
|
|
23
|
+
class Events:
|
|
24
|
+
PREFIX = "vssl."
|
|
25
|
+
MODEL_CHANGE = PREFIX + "model_changed"
|
|
26
|
+
MODEL_ZONE_QTY_CHANGE = PREFIX + "model_zone_qty_changed"
|
|
27
|
+
SW_VERSION_CHANGE = PREFIX + "sw_version_changed"
|
|
28
|
+
SERIAL_CHANGE = PREFIX + "serial_changed"
|
|
29
|
+
ALL = EventBus.WILDCARD
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
model: DeviceModels = None,
|
|
34
|
+
zones: Union[str, List[str]] = None,
|
|
35
|
+
):
|
|
36
|
+
self.event_bus = EventBus()
|
|
37
|
+
self.zones = {}
|
|
38
|
+
self._sw_version = None
|
|
39
|
+
self._serial = None
|
|
40
|
+
self._model = None
|
|
41
|
+
self._model_zone_qty = 0
|
|
42
|
+
self.settings = VsslSettings(self)
|
|
43
|
+
|
|
44
|
+
self.model = model
|
|
45
|
+
|
|
46
|
+
# Add zones if any are passed
|
|
47
|
+
if zones:
|
|
48
|
+
self.add_zones(zones)
|
|
49
|
+
|
|
50
|
+
#
|
|
51
|
+
# Initialise the zones
|
|
52
|
+
#
|
|
53
|
+
# We init all the zones sequentially, so we can do some error checking
|
|
54
|
+
# and fail if any of the zones are in error
|
|
55
|
+
#
|
|
56
|
+
async def initialise(self, init_timeout: int = 10):
|
|
57
|
+
if len(self.zones) < 1:
|
|
58
|
+
raise VsslCtrlException("No zones were added to VSSL before calling run()")
|
|
59
|
+
|
|
60
|
+
zones_to_init = self.zones.copy()
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
key, first_zone = zones_to_init.popitem()
|
|
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 self.model_zone_qty is None:
|
|
68
|
+
future_model_zone_qty = self.event_bus.future(
|
|
69
|
+
self.Events.MODEL_ZONE_QTY_CHANGE, self.ENTITY_ID
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Lets make sure the zone is initialised, otherwsie we fail all
|
|
73
|
+
await first_zone.initialise()
|
|
74
|
+
|
|
75
|
+
# Only continue after we now how many zones the device supports
|
|
76
|
+
try:
|
|
77
|
+
if self.model_zone_qty is None:
|
|
78
|
+
await asyncio.wait_for(future_model_zone_qty, timeout=init_timeout)
|
|
79
|
+
|
|
80
|
+
if len(self.zones) > self.model_zone_qty:
|
|
81
|
+
raise VsslCtrlException("")
|
|
82
|
+
|
|
83
|
+
except asyncio.TimeoutError:
|
|
84
|
+
message = f"Timed out waiting for model infomation from zone {first_zone.id}, exiting!"
|
|
85
|
+
self._log_critical(message)
|
|
86
|
+
await first_zone.disconnect()
|
|
87
|
+
raise VsslCtrlException(message)
|
|
88
|
+
|
|
89
|
+
except VsslCtrlException:
|
|
90
|
+
message = f"Device model only has {self.model_zone_qty} zones instead of {len(self.zones)}"
|
|
91
|
+
self._log_critical(message)
|
|
92
|
+
await first_zone.disconnect()
|
|
93
|
+
raise VsslCtrlException(message)
|
|
94
|
+
|
|
95
|
+
# Now we can init the rest of the zones
|
|
96
|
+
initialisations = [zone.initialise() for zone in zones_to_init.values()]
|
|
97
|
+
await asyncio.gather(*initialisations)
|
|
98
|
+
|
|
99
|
+
except ZoneError as e:
|
|
100
|
+
message = f"Error occured while initialising zones {e}"
|
|
101
|
+
self._log_critical(e)
|
|
102
|
+
await self.disconnect()
|
|
103
|
+
raise
|
|
104
|
+
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
#
|
|
108
|
+
# Shutdown
|
|
109
|
+
#
|
|
110
|
+
async def shutdown(self):
|
|
111
|
+
await self.disconnect()
|
|
112
|
+
self.event_bus.stop()
|
|
113
|
+
|
|
114
|
+
#
|
|
115
|
+
# Discover host on the network using zero_conf package
|
|
116
|
+
#
|
|
117
|
+
async def discover(self, *args):
|
|
118
|
+
try:
|
|
119
|
+
check_zeroconf_availability()
|
|
120
|
+
|
|
121
|
+
from .discovery import VsslDiscovery
|
|
122
|
+
|
|
123
|
+
service = VsslDiscovery(*args)
|
|
124
|
+
return await service.discover()
|
|
125
|
+
except ZeroConfNotInstalled as e:
|
|
126
|
+
self._log_error(e)
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
#
|
|
130
|
+
# Update a property and fire the event
|
|
131
|
+
#
|
|
132
|
+
#
|
|
133
|
+
# TODO, use the ZoneDataClass here too? Needs some reconfig
|
|
134
|
+
#
|
|
135
|
+
def _set_property(self, property_name: str, new_value):
|
|
136
|
+
current_value = getattr(self, property_name)
|
|
137
|
+
if current_value != new_value:
|
|
138
|
+
setattr(self, f"_{property_name}", new_value)
|
|
139
|
+
self.event_bus.publish(
|
|
140
|
+
getattr(self.Events, property_name.upper() + "_CHANGE"),
|
|
141
|
+
self.ENTITY_ID,
|
|
142
|
+
getattr(self, property_name),
|
|
143
|
+
)
|
|
144
|
+
self._log_debug(f"Set {property_name}: {getattr(self, property_name)}")
|
|
145
|
+
|
|
146
|
+
#
|
|
147
|
+
# Software Version
|
|
148
|
+
#
|
|
149
|
+
@property
|
|
150
|
+
def sw_version(self):
|
|
151
|
+
return self._sw_version
|
|
152
|
+
|
|
153
|
+
@sw_version.setter
|
|
154
|
+
def sw_version(self, sw: str):
|
|
155
|
+
pass # read-only
|
|
156
|
+
|
|
157
|
+
#
|
|
158
|
+
# Serial Number
|
|
159
|
+
#
|
|
160
|
+
@property
|
|
161
|
+
def serial(self):
|
|
162
|
+
return self._serial
|
|
163
|
+
|
|
164
|
+
@serial.setter
|
|
165
|
+
def serial(self, serial: str):
|
|
166
|
+
pass # read-only
|
|
167
|
+
|
|
168
|
+
#
|
|
169
|
+
# Model of the device
|
|
170
|
+
#
|
|
171
|
+
@property
|
|
172
|
+
def model(self):
|
|
173
|
+
return self._model
|
|
174
|
+
|
|
175
|
+
@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
|
|
199
|
+
|
|
200
|
+
#
|
|
201
|
+
# Work out the model_zone_qty given device info
|
|
202
|
+
#
|
|
203
|
+
def _infer_model_zone_qty(self, data: Dict[str, int]):
|
|
204
|
+
if not self.model_zone_qty:
|
|
205
|
+
zone_count = sum(
|
|
206
|
+
1 for key in data if key.startswith("B") and key.endswith("Src")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if zone_count in (1, 3, 6):
|
|
210
|
+
self._model_zone_qty = zone_count
|
|
211
|
+
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
|
+
)
|
|
217
|
+
|
|
218
|
+
#
|
|
219
|
+
# Disconnect / Shutdown
|
|
220
|
+
#
|
|
221
|
+
async def disconnect(self):
|
|
222
|
+
for zone in self.zones.values():
|
|
223
|
+
await zone.disconnect()
|
|
224
|
+
|
|
225
|
+
#
|
|
226
|
+
# Add a Zones using a List, index emplys the zone ID
|
|
227
|
+
#
|
|
228
|
+
def add_zones(self, zones=Union[str, List[str]]):
|
|
229
|
+
zones_list = [zones] if isinstance(zones, str) else zones
|
|
230
|
+
|
|
231
|
+
for index, ip in enumerate(zones_list):
|
|
232
|
+
self.add_zone(index + 1, ip)
|
|
233
|
+
|
|
234
|
+
#
|
|
235
|
+
# Add a Zone
|
|
236
|
+
#
|
|
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"
|
|
240
|
+
self._log_error(error)
|
|
241
|
+
raise ZoneError(error)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
if zone_index in self.zones:
|
|
245
|
+
error = f"Zone {zone_index} already exists"
|
|
246
|
+
self._log_error(error)
|
|
247
|
+
raise ZoneError(error)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
# Check if any object in the dictionary has the specified value for the
|
|
251
|
+
# property
|
|
252
|
+
if any(zone.host == host for zone in self.zones.values()):
|
|
253
|
+
error = f"Zone with IP {host} already exists"
|
|
254
|
+
self._log_error(error)
|
|
255
|
+
raise ZoneError(error)
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
self.zones[zone_index] = Zone(self, zone_index, host)
|
|
259
|
+
|
|
260
|
+
return self.zones[zone_index]
|
|
261
|
+
|
|
262
|
+
#
|
|
263
|
+
# Get a Zone by ID
|
|
264
|
+
#
|
|
265
|
+
def get_zone(self, zone_index: "Zone.IDs"):
|
|
266
|
+
if zone_index in self.zones:
|
|
267
|
+
return self.zones[zone_index]
|
|
268
|
+
else:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
#
|
|
272
|
+
# Get a Zone by group index
|
|
273
|
+
#
|
|
274
|
+
def get_zones_by_group_index(self, group_index: int):
|
|
275
|
+
zones = {}
|
|
276
|
+
if self.zones:
|
|
277
|
+
for zone_id in self.zones:
|
|
278
|
+
zone = self.zones[zone_id]
|
|
279
|
+
if zone.group.index == group_index:
|
|
280
|
+
zones[zone_id] = zone
|
|
281
|
+
return zones
|
|
282
|
+
|
|
283
|
+
#
|
|
284
|
+
# Get a Zone that is connected to its APIs
|
|
285
|
+
#
|
|
286
|
+
def get_connected_zone(self):
|
|
287
|
+
if self.zones:
|
|
288
|
+
for zone_id in self.zones:
|
|
289
|
+
zone = self.zones[zone_id]
|
|
290
|
+
if zone.connected:
|
|
291
|
+
return zone
|
|
292
|
+
|
|
293
|
+
#
|
|
294
|
+
# Get the device name
|
|
295
|
+
#
|
|
296
|
+
def _request_name(self):
|
|
297
|
+
zone = self.get_connected_zone()
|
|
298
|
+
if zone:
|
|
299
|
+
zone.api_alpha.request_action_19()
|
|
300
|
+
|
|
301
|
+
#
|
|
302
|
+
# Reboot Device (All Zones)
|
|
303
|
+
#
|
|
304
|
+
def reboot(self):
|
|
305
|
+
zone = self.get_connected_zone()
|
|
306
|
+
if zone:
|
|
307
|
+
zone.api_alpha.request_action_33_device()
|
|
308
|
+
|
|
309
|
+
#
|
|
310
|
+
# Zones Groups. Build a dict of zone according to group membership
|
|
311
|
+
#
|
|
312
|
+
@property
|
|
313
|
+
def zone_groups(self):
|
|
314
|
+
MASTER = "master"
|
|
315
|
+
MEMBERS = "members"
|
|
316
|
+
|
|
317
|
+
groups = []
|
|
318
|
+
for zone in self.zones.values():
|
|
319
|
+
if zone.group.is_master:
|
|
320
|
+
groups.append({MASTER: zone, MEMBERS: zone.group.members})
|
|
321
|
+
|
|
322
|
+
return groups
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from enum import Enum, IntEnum
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from .decorators import sterilizable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VsslEnum(Enum):
|
|
8
|
+
@classmethod
|
|
9
|
+
def is_valid(cls, value):
|
|
10
|
+
try:
|
|
11
|
+
cls(value)
|
|
12
|
+
return True
|
|
13
|
+
except ValueError:
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def is_not_valid(cls, value):
|
|
18
|
+
return not cls.is_valid(value)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def get(cls, value, default=None):
|
|
22
|
+
try:
|
|
23
|
+
return cls(value)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return default
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VsslIntEnum(VsslEnum, IntEnum):
|
|
29
|
+
"""IntEnum"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DeviceModels(VsslEnum):
|
|
33
|
+
A1 = "a1"
|
|
34
|
+
A3 = "a3"
|
|
35
|
+
A6 = "a6"
|
|
36
|
+
A1X = "a1x"
|
|
37
|
+
A3X = "a3x"
|
|
38
|
+
A6X = "a6x"
|
|
39
|
+
|
|
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
|
+
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DeviceStatusExtKeys:
|
|
62
|
+
ANALOG_OUTPUT_1_SOURCE = "B1Src"
|
|
63
|
+
ANALOG_OUTPUT_2_SOURCE = "B2Src"
|
|
64
|
+
ANALOG_OUTPUT_3_SOURCE = "B3Src"
|
|
65
|
+
ANALOG_OUTPUT_4_SOURCE = "B4Src"
|
|
66
|
+
ANALOG_OUTPUT_5_SOURCE = "B5Src"
|
|
67
|
+
ANALOG_OUTPUT_6_SOURCE = "B6Src"
|
|
68
|
+
UNKNOWN = "B1Nm" # Party mode bus?
|
|
69
|
+
OPTICAL_INPUT_NAME = "B2Nm" # For A.3x this is the optical input name
|
|
70
|
+
DEVICE_NAME = "dev"
|
|
71
|
+
SW_VERSION = "ver"
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def add_zone_to_bus_key(zone_id: int):
|
|
75
|
+
return f"B{zone_id}Src"
|
|
76
|
+
|
|
77
|
+
|
|
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
|
+
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ZoneStatusExtKeys:
|
|
89
|
+
ID = "id"
|
|
90
|
+
TRANSPORT_STATE = "ac"
|
|
91
|
+
SERIAL_NUMBER = "mc"
|
|
92
|
+
VOLUME = "vol"
|
|
93
|
+
MUTE = "mt"
|
|
94
|
+
PARTY_MODE = "pa"
|
|
95
|
+
GROUP_INDEX = "rm"
|
|
96
|
+
TRACK_SOURCE = "lb"
|
|
97
|
+
DISABLED = "wr"
|
|
98
|
+
|
|
99
|
+
|
|
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
|
+
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ZoneEQStatusExtKeys:
|
|
111
|
+
MONO = "mono"
|
|
112
|
+
ANALOG_INPUT_NAME = "AiNm"
|
|
113
|
+
HZ60 = "eq1"
|
|
114
|
+
HZ200 = "eq2"
|
|
115
|
+
HZ500 = "eq3"
|
|
116
|
+
KHZ1 = "eq4"
|
|
117
|
+
KHZ4 = "eq5"
|
|
118
|
+
KHZ8 = "eq6"
|
|
119
|
+
KHZ15 = "eq7"
|
|
120
|
+
VOL_MAX_LEFT = "voll"
|
|
121
|
+
VOL_MAX_RIGHT = "volr"
|
|
122
|
+
VOL_DEFAULT_ON = "vold"
|
|
123
|
+
|
|
124
|
+
|
|
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
|
+
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ZoneRouterStatusExtKeys:
|
|
136
|
+
EQ_ENABLED = "eqsw"
|
|
137
|
+
INPUT_SOURCE = "inSrc"
|
|
138
|
+
SOURCE_PRIORITY = "SP"
|
|
139
|
+
ANALOG_OUTPUT_FIXED_VOLUME_PREFIX = "BF"
|
|
140
|
+
GROUP_SOURCE = "GRS"
|
|
141
|
+
GROUP_MASTER = "GRM"
|
|
142
|
+
POWER_STATE = "Pwr"
|
|
143
|
+
ANALOG_INPUT_FIXED_GAIN = "fxv"
|
|
144
|
+
ADAPTIVE_POWER = "AtPwr"
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def add_zone_to_ao_fixed_volume_key(zone_id: int):
|
|
148
|
+
return f"BF{zone_id}"
|
|
149
|
+
|
|
150
|
+
|
|
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
|
+
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TrackMetadataExtKeys:
|
|
166
|
+
COMMAND_ID = "CMD ID"
|
|
167
|
+
WINDOW_CONTENTS = "Window CONTENTS"
|
|
168
|
+
WINDOW_TITLE = "Title"
|
|
169
|
+
DURATION = "TotalTime"
|
|
170
|
+
TITLE = "TrackName"
|
|
171
|
+
ALBUM = "Album"
|
|
172
|
+
ARTIST = "Artist"
|
|
173
|
+
COVER_ART_URL = "CoverArtUrl"
|
|
174
|
+
SOURCE = "Current Source"
|
|
175
|
+
GENRE = "Genre"
|
|
176
|
+
URL = "PlayUrl"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@sterilizable
|
|
180
|
+
class VsslDataClass(ABC):
|
|
181
|
+
def _set_property(self, property_name: str, new_value):
|
|
182
|
+
log = False
|
|
183
|
+
direct_setter = f"_set_{property_name}"
|
|
184
|
+
|
|
185
|
+
if hasattr(self, direct_setter):
|
|
186
|
+
log = getattr(self, direct_setter)(new_value)
|
|
187
|
+
else:
|
|
188
|
+
current_value = getattr(self, property_name)
|
|
189
|
+
if current_value != new_value:
|
|
190
|
+
setattr(self, f"_{property_name}", new_value)
|
|
191
|
+
log = True
|
|
192
|
+
|
|
193
|
+
if log:
|
|
194
|
+
updated_value = getattr(self, property_name)
|
|
195
|
+
|
|
196
|
+
message = ""
|
|
197
|
+
if isinstance(updated_value, IntEnum):
|
|
198
|
+
message = f"{self.__class__.__name__} set {property_name}: {updated_value.name} ({updated_value.value})"
|
|
199
|
+
else:
|
|
200
|
+
message = (
|
|
201
|
+
f"{self.__class__.__name__} set {property_name}: {updated_value}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
self._vssl._log_debug(message)
|
|
205
|
+
|
|
206
|
+
self._vssl.event_bus.publish(
|
|
207
|
+
getattr(self.Events, property_name.upper() + "_CHANGE"),
|
|
208
|
+
self._vssl.ENTITY_ID,
|
|
209
|
+
updated_value,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@sterilizable
|
|
214
|
+
class ZoneDataClass(ABC):
|
|
215
|
+
def _set_property(self, property_name: str, new_value):
|
|
216
|
+
log = False
|
|
217
|
+
direct_setter = f"_set_{property_name}"
|
|
218
|
+
|
|
219
|
+
if hasattr(self, direct_setter):
|
|
220
|
+
log = getattr(self, direct_setter)(new_value)
|
|
221
|
+
else:
|
|
222
|
+
current_value = getattr(self, property_name)
|
|
223
|
+
if current_value != new_value:
|
|
224
|
+
setattr(self, f"_{property_name}", new_value)
|
|
225
|
+
log = True
|
|
226
|
+
|
|
227
|
+
if log:
|
|
228
|
+
updated_value = getattr(self, property_name)
|
|
229
|
+
|
|
230
|
+
message = ""
|
|
231
|
+
if isinstance(updated_value, IntEnum):
|
|
232
|
+
message = f"{self.__class__.__name__} set {property_name}: {updated_value.name} ({updated_value.value})"
|
|
233
|
+
else:
|
|
234
|
+
message = (
|
|
235
|
+
f"{self.__class__.__name__} set {property_name}: {updated_value}"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
self.zone._log_debug(message)
|
|
239
|
+
|
|
240
|
+
self.zone._event_publish(
|
|
241
|
+
getattr(self.Events, property_name.upper() + "_CHANGE"), updated_value
|
|
242
|
+
)
|
vsslctrl/decorators.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def sterilizable(cls):
|
|
6
|
+
# Define __iter__ method
|
|
7
|
+
def __iter__(self):
|
|
8
|
+
if hasattr(self.__class__, "DEFAULTS"):
|
|
9
|
+
for key in getattr(self.__class__, "DEFAULTS"):
|
|
10
|
+
yield key, getattr(self, key)
|
|
11
|
+
else:
|
|
12
|
+
for attr_name in dir(self):
|
|
13
|
+
if not attr_name.startswith("_"): # Exclude private attributes
|
|
14
|
+
yield attr_name, getattr(self, attr_name)
|
|
15
|
+
|
|
16
|
+
cls.__iter__ = __iter__
|
|
17
|
+
|
|
18
|
+
def _as_dict(self):
|
|
19
|
+
return dict(self)
|
|
20
|
+
|
|
21
|
+
cls.as_dict = _as_dict
|
|
22
|
+
|
|
23
|
+
def _as_json(self):
|
|
24
|
+
return json.dumps(self.as_dict())
|
|
25
|
+
|
|
26
|
+
cls.as_json = _as_json
|
|
27
|
+
|
|
28
|
+
return cls
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def logging_helpers(prefix=""):
|
|
32
|
+
def decorator(cls):
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
def _is_log_level(self, level: str):
|
|
36
|
+
level = level.upper()
|
|
37
|
+
if hasattr(logging, level):
|
|
38
|
+
return getattr(logging, level) == logger.getEffectiveLevel()
|
|
39
|
+
else:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
setattr(cls, "_is_log_level", _is_log_level)
|
|
43
|
+
|
|
44
|
+
LOG_LEVELS = {"debug", "info", "warning", "error", "critical"}
|
|
45
|
+
|
|
46
|
+
def create_log_function(log_level, prefix=prefix): # Pass prefix here
|
|
47
|
+
def log_function(self, message): # Rename prefix to custom_prefix
|
|
48
|
+
final_prefix = getattr(self, "_log_prefix", prefix)
|
|
49
|
+
log_level(f"{final_prefix} {message}")
|
|
50
|
+
|
|
51
|
+
return log_function
|
|
52
|
+
|
|
53
|
+
for level in LOG_LEVELS:
|
|
54
|
+
log_func = getattr(logger, level)
|
|
55
|
+
setattr(
|
|
56
|
+
cls, f"_log_{level}", create_log_function(log_func)
|
|
57
|
+
) # Pass prefix here
|
|
58
|
+
|
|
59
|
+
return cls
|
|
60
|
+
|
|
61
|
+
return decorator
|