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/zone.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# v0.3
|
|
4
|
+
# Tested on A.3x on p15305.016.3701
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
from . import core
|
|
13
|
+
from .api_alpha import APIAlpha
|
|
14
|
+
from .api_bravo import APIBravo
|
|
15
|
+
from .utils import RepeatTimer, clamp_volume
|
|
16
|
+
from .data_structure import DeviceModels, VsslIntEnum
|
|
17
|
+
from .track import TrackMetadata
|
|
18
|
+
from .io import AnalogOutput, InputRouter
|
|
19
|
+
from .settings import ZoneSettings
|
|
20
|
+
from .transport import ZoneTransport
|
|
21
|
+
from .group import ZoneGroup
|
|
22
|
+
from .exceptions import ZoneError
|
|
23
|
+
from .decorators import logging_helpers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@logging_helpers()
|
|
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
|
+
#
|
|
40
|
+
# Zone Events
|
|
41
|
+
#
|
|
42
|
+
class Events:
|
|
43
|
+
ALL = "*"
|
|
44
|
+
PREFIX = "zone."
|
|
45
|
+
INITIALISED = PREFIX + "initialised"
|
|
46
|
+
ID_RECEIVED = PREFIX + "id_received"
|
|
47
|
+
SERIAL_RECEIVED = PREFIX + "serial_received"
|
|
48
|
+
MAC_ADDR_CHANGE = PREFIX + "mac_addr_change"
|
|
49
|
+
VOLUME_CHANGE = PREFIX + "volume_change"
|
|
50
|
+
MUTE_CHANGE = PREFIX + "mute_change"
|
|
51
|
+
|
|
52
|
+
def __init__(self, vssl_host: "core.Vssl", zone_id: "Zone.IDs", host: str):
|
|
53
|
+
self._log_prefix = f"Zone {zone_id}:"
|
|
54
|
+
|
|
55
|
+
self.vssl = vssl_host
|
|
56
|
+
self.initialisation = asyncio.Event()
|
|
57
|
+
|
|
58
|
+
# Data / Cache
|
|
59
|
+
self._host = host
|
|
60
|
+
self._id = self.IDs(zone_id)
|
|
61
|
+
self._mac_addr = None
|
|
62
|
+
self._serial = None
|
|
63
|
+
self._volume = 0
|
|
64
|
+
self._mute = False
|
|
65
|
+
|
|
66
|
+
self.transport = ZoneTransport(self)
|
|
67
|
+
self.track = TrackMetadata(self)
|
|
68
|
+
self.group = ZoneGroup(self)
|
|
69
|
+
self.analog_output = AnalogOutput(self)
|
|
70
|
+
self.input = InputRouter(self)
|
|
71
|
+
self.settings = ZoneSettings(self)
|
|
72
|
+
|
|
73
|
+
# Communication interfaces
|
|
74
|
+
self.api_alpha = APIAlpha(self.vssl, self)
|
|
75
|
+
self.api_bravo = APIBravo(self.vssl, self)
|
|
76
|
+
|
|
77
|
+
# Requests to poll
|
|
78
|
+
self._poller = ZonePoller(
|
|
79
|
+
self,
|
|
80
|
+
[
|
|
81
|
+
self._request_status, # First
|
|
82
|
+
self._request_mac_addr,
|
|
83
|
+
self._request_status_bus,
|
|
84
|
+
self._request_output_status,
|
|
85
|
+
self._request_eq_status,
|
|
86
|
+
self._request_track,
|
|
87
|
+
self._request_name,
|
|
88
|
+
self._request_bt_status,
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Initialise
|
|
93
|
+
async def initialise(self):
|
|
94
|
+
# ID and serial number futures
|
|
95
|
+
future_id = self.vssl.event_bus.future(self.Events.ID_RECEIVED, self.id)
|
|
96
|
+
future_serial = self.vssl.event_bus.future(self.Events.SERIAL_RECEIVED, self.id)
|
|
97
|
+
future_name = self.vssl.event_bus.future(
|
|
98
|
+
ZoneSettings.Events.NAME_CHANGE, self.id
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Subscribe to events
|
|
102
|
+
self.vssl.event_bus.subscribe(
|
|
103
|
+
ZoneTransport.Events.STATE_CHANGE,
|
|
104
|
+
self._event_transport_state_change,
|
|
105
|
+
self.id,
|
|
106
|
+
)
|
|
107
|
+
self.vssl.event_bus.subscribe(
|
|
108
|
+
ZoneGroup.Events.SOURCE_CHANGE, self._event_group_source_change, self.id
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Connect the APIs
|
|
112
|
+
# Wait until the zone is connected then continue
|
|
113
|
+
await self.api_alpha.connect()
|
|
114
|
+
await self.api_bravo.connect()
|
|
115
|
+
|
|
116
|
+
# Start polling zone
|
|
117
|
+
self._poller.start()
|
|
118
|
+
|
|
119
|
+
# Wait for the ID, serial and name to be returned from the device
|
|
120
|
+
received_id = await future_id
|
|
121
|
+
received_serial = await future_serial
|
|
122
|
+
await future_name
|
|
123
|
+
|
|
124
|
+
# Confirm the zone id is matches returned ID
|
|
125
|
+
if received_id != self.id:
|
|
126
|
+
message = f"Zone ID mismatch. {self.host} returned zone ID {received_id} instead of {self.id}"
|
|
127
|
+
self._log_critical(message)
|
|
128
|
+
await self.disconnect()
|
|
129
|
+
raise ZoneError(message)
|
|
130
|
+
|
|
131
|
+
# Confirm the zone and VSSL serial numbers match
|
|
132
|
+
if self.vssl.serial != received_serial:
|
|
133
|
+
message = f"Zone ({received_serial}) and VSSL ({self.vssl.serial}) serial numbers do not match. Does this zone belong to this VSSL?"
|
|
134
|
+
self._log_critical(message)
|
|
135
|
+
await self.disconnect()
|
|
136
|
+
raise ZoneError(message)
|
|
137
|
+
|
|
138
|
+
# Initialised
|
|
139
|
+
self.initialisation.set()
|
|
140
|
+
self.vssl.event_bus.publish(self.Events.INITIALISED, self.id, self)
|
|
141
|
+
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def initialised(self):
|
|
146
|
+
"""Initialised Event"""
|
|
147
|
+
return self.initialisation.is_set()
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def connected(self):
|
|
151
|
+
"""Check that the zone is connected to both APIs"""
|
|
152
|
+
return self.api_alpha.connected and self.api_bravo.connected
|
|
153
|
+
|
|
154
|
+
async def disconnect(self):
|
|
155
|
+
"""Disconnect / Shutdown"""
|
|
156
|
+
self._poller.cancel()
|
|
157
|
+
|
|
158
|
+
await self.api_alpha.disconnect()
|
|
159
|
+
await self.api_bravo.disconnect()
|
|
160
|
+
|
|
161
|
+
def _event_publish(self, event_type, data=None):
|
|
162
|
+
"""Event Publish Wrapper"""
|
|
163
|
+
self.vssl.event_bus.publish(event_type, self.id, data)
|
|
164
|
+
|
|
165
|
+
async def _event_transport_state_change(self, *args):
|
|
166
|
+
"""Request track info on transport state change unless stopped
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
VSSL doenst clear some vars on stopping of the stream, so we will do it
|
|
170
|
+
|
|
171
|
+
Doing this will fire the change events on the bus. Instead of conditionally
|
|
172
|
+
using the getter functions since we want the changes to be propogated
|
|
173
|
+
|
|
174
|
+
VSSL has a happit of caching the last songs metadata
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
if not self.transport.is_stopped:
|
|
178
|
+
self._request_track()
|
|
179
|
+
else:
|
|
180
|
+
self.track.set_defaults()
|
|
181
|
+
self.transport.set_defaults()
|
|
182
|
+
|
|
183
|
+
async def _event_group_source_change(self, source: int, *args):
|
|
184
|
+
"""Propgate the track metadata from a group master to its members"""
|
|
185
|
+
if source == None:
|
|
186
|
+
self._log_debug(f"unsubscribe to group master {source} track updates")
|
|
187
|
+
self.vssl.event_bus.unsubscribe(
|
|
188
|
+
TrackMetadata.Events.CHANGE,
|
|
189
|
+
self.track._update_property_from_group_master,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
self._log_debug(f"subscribe to group master {source} track updates")
|
|
193
|
+
self.vssl.event_bus.subscribe(
|
|
194
|
+
TrackMetadata.Events.CHANGE,
|
|
195
|
+
self.track._update_property_from_group_master,
|
|
196
|
+
source,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Populate group member from master
|
|
200
|
+
self.track._pull_from_zone(source)
|
|
201
|
+
|
|
202
|
+
def _set_property(self, property_name: str, new_value):
|
|
203
|
+
"""TODO, use the ZoneDataClass here too? Needs some reconfig"""
|
|
204
|
+
log = False
|
|
205
|
+
direct_setter = f"_set_{property_name}"
|
|
206
|
+
|
|
207
|
+
if hasattr(self, direct_setter):
|
|
208
|
+
log = getattr(self, direct_setter)(new_value)
|
|
209
|
+
else:
|
|
210
|
+
current_value = getattr(self, property_name)
|
|
211
|
+
if current_value != new_value:
|
|
212
|
+
setattr(self, f"_{property_name}", new_value)
|
|
213
|
+
log = True
|
|
214
|
+
|
|
215
|
+
if log:
|
|
216
|
+
self._log_debug(f"Set {property_name}: {getattr(self, property_name)}")
|
|
217
|
+
self._event_publish(
|
|
218
|
+
getattr(self.Events, property_name.upper() + "_CHANGE"),
|
|
219
|
+
getattr(self, property_name),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
#
|
|
223
|
+
# Host
|
|
224
|
+
#
|
|
225
|
+
@property
|
|
226
|
+
def host(self):
|
|
227
|
+
return self._host
|
|
228
|
+
|
|
229
|
+
@host.setter
|
|
230
|
+
def host(self, host: str):
|
|
231
|
+
pass # Immutable
|
|
232
|
+
|
|
233
|
+
#
|
|
234
|
+
# Zone ID
|
|
235
|
+
#
|
|
236
|
+
@property
|
|
237
|
+
def id(self):
|
|
238
|
+
return self._id
|
|
239
|
+
|
|
240
|
+
@id.setter
|
|
241
|
+
def id(self, zone_id: int):
|
|
242
|
+
if not self.initialised:
|
|
243
|
+
# We wait for this in the initialise function
|
|
244
|
+
self._event_publish(self.Events.ID_RECEIVED, zone_id)
|
|
245
|
+
|
|
246
|
+
#
|
|
247
|
+
# Serial Number
|
|
248
|
+
#
|
|
249
|
+
@property
|
|
250
|
+
def serial(self):
|
|
251
|
+
return self._serial
|
|
252
|
+
|
|
253
|
+
@serial.setter
|
|
254
|
+
def serial(self, serial: str):
|
|
255
|
+
pass # Immutable
|
|
256
|
+
|
|
257
|
+
def _set_serial(self, serial: str):
|
|
258
|
+
if not self.initialised:
|
|
259
|
+
self._serial = serial
|
|
260
|
+
# We wait for this in the initialise function
|
|
261
|
+
self._event_publish(self.Events.SERIAL_RECEIVED, serial)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def mac_addr(self):
|
|
265
|
+
"""MAC Address
|
|
266
|
+
|
|
267
|
+
Note: This command wont work if there is another VSSL agent running on the network
|
|
268
|
+
|
|
269
|
+
Known issue: zone 1 sometimes stops repsonding to the _request_mac_addr request.
|
|
270
|
+
rebooting all zones seems to fix it.
|
|
271
|
+
"""
|
|
272
|
+
return self._mac_addr
|
|
273
|
+
|
|
274
|
+
@mac_addr.setter
|
|
275
|
+
def mac_addr(self, mac: str):
|
|
276
|
+
pass # Immutable
|
|
277
|
+
|
|
278
|
+
def _set_mac_addr(self, mac: str):
|
|
279
|
+
mac = mac.strip()
|
|
280
|
+
if mac != self.mac_addr:
|
|
281
|
+
# Define the regular expression pattern for a MAC address
|
|
282
|
+
mac_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
|
|
283
|
+
|
|
284
|
+
if mac_pattern.match(mac):
|
|
285
|
+
self._mac_addr = mac
|
|
286
|
+
self._poller.remove(self._request_mac_addr)
|
|
287
|
+
return True
|
|
288
|
+
else:
|
|
289
|
+
self._log_error(f"Invalid MAC address {mac}")
|
|
290
|
+
|
|
291
|
+
#
|
|
292
|
+
# Transport Helper Commands
|
|
293
|
+
#
|
|
294
|
+
def play(self):
|
|
295
|
+
"""Play"""
|
|
296
|
+
self.transport.play()
|
|
297
|
+
|
|
298
|
+
def stop(self):
|
|
299
|
+
"""Stop"""
|
|
300
|
+
self.transport.stop()
|
|
301
|
+
|
|
302
|
+
def pause(self):
|
|
303
|
+
"""Pause"""
|
|
304
|
+
self.transport.pause()
|
|
305
|
+
|
|
306
|
+
def next(self):
|
|
307
|
+
"""Next Track"""
|
|
308
|
+
self.transport.next()
|
|
309
|
+
|
|
310
|
+
def prev(self):
|
|
311
|
+
"""Previous track"""
|
|
312
|
+
self.transport.prev()
|
|
313
|
+
|
|
314
|
+
def back(self):
|
|
315
|
+
"""Back"""
|
|
316
|
+
self.transport.back()
|
|
317
|
+
|
|
318
|
+
#
|
|
319
|
+
# Volume
|
|
320
|
+
#
|
|
321
|
+
@property
|
|
322
|
+
def volume(self):
|
|
323
|
+
"""
|
|
324
|
+
return 0 if self._mute else self._volume
|
|
325
|
+
|
|
326
|
+
Dont do this, as when the zone is unmuted, the volume is returned first
|
|
327
|
+
(before the mute status) so the events fire with a volume of zero.
|
|
328
|
+
This will need to be handled on the front end, to display, 0 when muted
|
|
329
|
+
|
|
330
|
+
Note; Some input sources i.e Spotify, will set the volume to 0 when muted, others dont
|
|
331
|
+
"""
|
|
332
|
+
return self._volume
|
|
333
|
+
|
|
334
|
+
@volume.setter
|
|
335
|
+
def volume(self, vol: int):
|
|
336
|
+
self.api_alpha.request_action_05(vol)
|
|
337
|
+
|
|
338
|
+
def _set_volume(self, vol: int):
|
|
339
|
+
vol = clamp_volume(vol)
|
|
340
|
+
if self.volume != vol:
|
|
341
|
+
self._volume = vol
|
|
342
|
+
return True
|
|
343
|
+
|
|
344
|
+
def volume_raise(self, step: int = 1):
|
|
345
|
+
"""Volume Up"""
|
|
346
|
+
step = max(min(step, 100), 1)
|
|
347
|
+
if step > 1:
|
|
348
|
+
self.volume = self.volume + step
|
|
349
|
+
else:
|
|
350
|
+
self.api_alpha.request_action_05_raise()
|
|
351
|
+
|
|
352
|
+
def volume_lower(self, step: int = 1):
|
|
353
|
+
"""Volume Down"""
|
|
354
|
+
step = max(min(step, 100), 1)
|
|
355
|
+
if step > 1:
|
|
356
|
+
self.volume = self.volume - step
|
|
357
|
+
else:
|
|
358
|
+
self.api_alpha.request_action_05_lower()
|
|
359
|
+
|
|
360
|
+
#
|
|
361
|
+
# Mute
|
|
362
|
+
#
|
|
363
|
+
@property
|
|
364
|
+
def mute(self):
|
|
365
|
+
return True if not self._volume else self._mute
|
|
366
|
+
|
|
367
|
+
@mute.setter
|
|
368
|
+
def mute(self, muted: Union[bool, int]):
|
|
369
|
+
self.api_alpha.request_action_11(not not muted)
|
|
370
|
+
|
|
371
|
+
def mute_toggle(self):
|
|
372
|
+
self.mute = False if self.mute else True
|
|
373
|
+
|
|
374
|
+
#
|
|
375
|
+
# Play a URL
|
|
376
|
+
#
|
|
377
|
+
def play_url(self, url: str, all_zones: bool = False):
|
|
378
|
+
self.api_alpha.request_action_55(url, all_zones)
|
|
379
|
+
return self
|
|
380
|
+
|
|
381
|
+
#
|
|
382
|
+
# Reboot this zone
|
|
383
|
+
#
|
|
384
|
+
def reboot(self):
|
|
385
|
+
self.api_alpha.request_action_33()
|
|
386
|
+
return self
|
|
387
|
+
|
|
388
|
+
#
|
|
389
|
+
# Requests
|
|
390
|
+
#
|
|
391
|
+
def _request_name(self):
|
|
392
|
+
self.api_bravo.request_action_5A()
|
|
393
|
+
return self
|
|
394
|
+
|
|
395
|
+
def _request_mac_addr(self):
|
|
396
|
+
self.api_bravo.request_action_5B()
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def _request_status_bus(self):
|
|
400
|
+
self.api_alpha.request_action_00_00()
|
|
401
|
+
return self
|
|
402
|
+
|
|
403
|
+
def _request_status(self):
|
|
404
|
+
self.api_alpha.request_action_00_08()
|
|
405
|
+
return self
|
|
406
|
+
|
|
407
|
+
def _request_eq_status(self):
|
|
408
|
+
self.api_alpha.request_action_00_09()
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def _request_output_status(self):
|
|
412
|
+
self.api_alpha.request_action_00_0A()
|
|
413
|
+
return self
|
|
414
|
+
|
|
415
|
+
def _request_bt_status(self):
|
|
416
|
+
self.api_alpha.request_action_00_0B()
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
def _request_track(self):
|
|
420
|
+
self.api_bravo.request_action_2A()
|
|
421
|
+
return self
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class ZonePoller:
|
|
425
|
+
def __init__(self, zone, requests=[], interval=30):
|
|
426
|
+
self.zone = zone
|
|
427
|
+
self._requests = requests
|
|
428
|
+
self._interval = interval
|
|
429
|
+
self._timer = RepeatTimer(self._interval, self._poll_state)
|
|
430
|
+
|
|
431
|
+
def _poll_state(self):
|
|
432
|
+
if self.zone.connected:
|
|
433
|
+
self.zone._log_debug("Polling state")
|
|
434
|
+
for request in self._requests:
|
|
435
|
+
request()
|
|
436
|
+
|
|
437
|
+
def start(self):
|
|
438
|
+
self._timer.start()
|
|
439
|
+
|
|
440
|
+
def cancel(self):
|
|
441
|
+
self._timer.cancel()
|
|
442
|
+
|
|
443
|
+
def remove(self, request):
|
|
444
|
+
if request in self._requests:
|
|
445
|
+
self._requests.remove(request)
|
|
446
|
+
|
|
447
|
+
def append(self, request):
|
|
448
|
+
if request not in self._requests:
|
|
449
|
+
self._requests.append(request)
|
|
450
|
+
|
|
451
|
+
def contains(self, request):
|
|
452
|
+
return request in self._requests
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 VSSLCTRL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|