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/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