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/api_bravo.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
import json
|
|
4
|
+
import struct
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .api_base import APIBase
|
|
8
|
+
from .utils import hex_to_int
|
|
9
|
+
from .decorators import logging_helpers
|
|
10
|
+
from .data_structure import TrackMetadataExtKeys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@logging_helpers()
|
|
14
|
+
class APIBravo(APIBase):
|
|
15
|
+
TCP_PORT = 7777
|
|
16
|
+
HEADER_LENGTH = 10
|
|
17
|
+
|
|
18
|
+
def __init__(self, vssl_host: "vssl.VSSL", zone: "zone.Zone"):
|
|
19
|
+
super().__init__(host=zone.host, port=self.TCP_PORT)
|
|
20
|
+
|
|
21
|
+
self._log_prefix = f"Zone {zone.id}: Bravo API:"
|
|
22
|
+
|
|
23
|
+
self.vssl = vssl_host
|
|
24
|
+
self.zone = zone
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Send keep alive
|
|
28
|
+
#
|
|
29
|
+
def _send_keepalive(self):
|
|
30
|
+
self.request_action_03()
|
|
31
|
+
|
|
32
|
+
#
|
|
33
|
+
#
|
|
34
|
+
#
|
|
35
|
+
# Requests
|
|
36
|
+
#
|
|
37
|
+
#
|
|
38
|
+
#
|
|
39
|
+
def _build_request(self, command: int, get=True):
|
|
40
|
+
setget = 1 if get else 2
|
|
41
|
+
return bytearray([170, 170, setget, command, 0, 0, 0, 0])
|
|
42
|
+
|
|
43
|
+
#
|
|
44
|
+
# Request with data
|
|
45
|
+
#
|
|
46
|
+
def _build_request_with_data(self, cmd: int, data: str):
|
|
47
|
+
command = self._build_request(cmd, False)
|
|
48
|
+
command.extend(struct.pack(">B", len(data)))
|
|
49
|
+
command.extend([0])
|
|
50
|
+
command.extend(data.encode("utf-8"))
|
|
51
|
+
return command
|
|
52
|
+
|
|
53
|
+
#
|
|
54
|
+
# 03 [3]
|
|
55
|
+
# Keep alive
|
|
56
|
+
#
|
|
57
|
+
def request_action_03(self):
|
|
58
|
+
self._log_debug("Sending keep alive with IP")
|
|
59
|
+
self.send(self._build_request_with_data(3, self.zone.host))
|
|
60
|
+
|
|
61
|
+
#
|
|
62
|
+
# 5A [90]
|
|
63
|
+
# Zone Name - Request
|
|
64
|
+
#
|
|
65
|
+
def request_action_5A(self):
|
|
66
|
+
self._log_debug("Requesting name")
|
|
67
|
+
command = self._build_request(90)
|
|
68
|
+
command.extend([0, 0])
|
|
69
|
+
self.send(command)
|
|
70
|
+
|
|
71
|
+
#
|
|
72
|
+
# 5A [90]
|
|
73
|
+
# Zone Name - Rename
|
|
74
|
+
#
|
|
75
|
+
def request_action_5A_set(self, name: str):
|
|
76
|
+
self._log_debug(f"Requesting to set name: {name}")
|
|
77
|
+
self.send(self._build_request_with_data(90, name))
|
|
78
|
+
|
|
79
|
+
#
|
|
80
|
+
# 5B [91]
|
|
81
|
+
# MAC Address
|
|
82
|
+
#
|
|
83
|
+
def request_action_5B(self):
|
|
84
|
+
self._log_debug("Requesting MAC address")
|
|
85
|
+
command = self._build_request(91)
|
|
86
|
+
command.extend([0, 0])
|
|
87
|
+
self.send(command)
|
|
88
|
+
|
|
89
|
+
#
|
|
90
|
+
# 2A [42]
|
|
91
|
+
# Track Metadata
|
|
92
|
+
#
|
|
93
|
+
def request_action_2A(self):
|
|
94
|
+
self._log_debug("Requesting track metadata")
|
|
95
|
+
command = self._build_request(42)
|
|
96
|
+
command.extend([0, 0])
|
|
97
|
+
self.send(command)
|
|
98
|
+
|
|
99
|
+
#
|
|
100
|
+
# 28 [40]
|
|
101
|
+
# Track Next
|
|
102
|
+
#
|
|
103
|
+
# b'\xaa\xaa\x01(\x00\x00\x00\x00\x04\x00NEXT'
|
|
104
|
+
# aaaa01280000000004004e455854
|
|
105
|
+
#
|
|
106
|
+
def request_action_40_next(self):
|
|
107
|
+
self._log_debug("Requesting next track")
|
|
108
|
+
command = self._build_request(40, False)
|
|
109
|
+
command.extend([4, 0, 78, 69, 88, 84])
|
|
110
|
+
self.send(command)
|
|
111
|
+
|
|
112
|
+
#
|
|
113
|
+
# 28 [40]
|
|
114
|
+
# Track Previous
|
|
115
|
+
#
|
|
116
|
+
# b'\xaa\xaa\x01(\x00\x00\x00\x00\x08\x00PREV'
|
|
117
|
+
# aaaa012800000000080050524556
|
|
118
|
+
#
|
|
119
|
+
def request_action_40_prev(self):
|
|
120
|
+
self._log_debug("Requesting previous track")
|
|
121
|
+
command = self._build_request(40, False)
|
|
122
|
+
command.extend([8, 0, 80, 82, 69, 86])
|
|
123
|
+
self.send(command)
|
|
124
|
+
|
|
125
|
+
#
|
|
126
|
+
# 40 [64]
|
|
127
|
+
# Zone Volume
|
|
128
|
+
#
|
|
129
|
+
def request_action_64(self):
|
|
130
|
+
self._log_debug("Requesting volume")
|
|
131
|
+
command = self._build_request(64)
|
|
132
|
+
command.extend([0, 0])
|
|
133
|
+
self.send(command)
|
|
134
|
+
|
|
135
|
+
#
|
|
136
|
+
#
|
|
137
|
+
#
|
|
138
|
+
# Respsonses
|
|
139
|
+
#
|
|
140
|
+
#
|
|
141
|
+
#
|
|
142
|
+
|
|
143
|
+
#
|
|
144
|
+
# Handle Response
|
|
145
|
+
#
|
|
146
|
+
|
|
147
|
+
async def _read_byte_stream(self, reader, data):
|
|
148
|
+
data += await reader.readexactly(self.HEADER_LENGTH - APIBase.FRIST_BYTE)
|
|
149
|
+
|
|
150
|
+
length = int.from_bytes(data[8:10], "big")
|
|
151
|
+
|
|
152
|
+
data += await reader.readexactly(length)
|
|
153
|
+
|
|
154
|
+
self._log_debug(f"Response: {data}")
|
|
155
|
+
|
|
156
|
+
await self._handle_response(data)
|
|
157
|
+
|
|
158
|
+
async def _handle_response(self, response: bytes):
|
|
159
|
+
try:
|
|
160
|
+
# Convert to HEX and split into a array
|
|
161
|
+
hexl = response.hex("-").split("-")
|
|
162
|
+
action = f"response_action_{hexl[4].upper()}"
|
|
163
|
+
length = hexl[2]
|
|
164
|
+
|
|
165
|
+
self._log_debug(f"Response action: {action}")
|
|
166
|
+
|
|
167
|
+
except Exception as error:
|
|
168
|
+
self._log_error(f"Couldnt handle response: {error} | {hexl} | {response}")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
if hasattr(self, action):
|
|
172
|
+
method = getattr(self, action)
|
|
173
|
+
if callable(method):
|
|
174
|
+
return method(hexl, response)
|
|
175
|
+
# Default
|
|
176
|
+
return self.response_action_default(hexl, response)
|
|
177
|
+
|
|
178
|
+
#
|
|
179
|
+
# Extract Data
|
|
180
|
+
#
|
|
181
|
+
def _extract_response_data(self, response: bytes, length_index: int = 9):
|
|
182
|
+
try:
|
|
183
|
+
header = response[:9]
|
|
184
|
+
length_field = response[8:10]
|
|
185
|
+
length = int.from_bytes(length_field, "big")
|
|
186
|
+
return response[10 : 10 + length].decode("ascii")
|
|
187
|
+
except Exception as e:
|
|
188
|
+
self._log_error(
|
|
189
|
+
f"Unable to extract response data. Exception: {e} | Response: {hexl}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
#
|
|
193
|
+
# 03 [3]
|
|
194
|
+
# Keep Alive
|
|
195
|
+
#
|
|
196
|
+
def response_action_03(self, hexl: list, response: bytes):
|
|
197
|
+
if hex_to_int(hexl[5]) != 1:
|
|
198
|
+
self._log_debug(f"Couldnt register, trying again", "critical")
|
|
199
|
+
self.request_action_03()
|
|
200
|
+
|
|
201
|
+
self._log_debug(f"Received keep alive")
|
|
202
|
+
|
|
203
|
+
#
|
|
204
|
+
# 5A [90]
|
|
205
|
+
# Zone Name
|
|
206
|
+
#
|
|
207
|
+
def response_action_5A(self, hexl: list, response: bytes):
|
|
208
|
+
name = self._extract_response_data(response)
|
|
209
|
+
self._log_debug(f"Received zone name: {name}")
|
|
210
|
+
self.zone.settings._set_property("name", name.strip())
|
|
211
|
+
|
|
212
|
+
#
|
|
213
|
+
# 31 [49]
|
|
214
|
+
# Progress
|
|
215
|
+
#
|
|
216
|
+
def response_action_31(self, hexl: list, response: bytes):
|
|
217
|
+
self.zone.track.progress = int(self._extract_response_data(response))
|
|
218
|
+
|
|
219
|
+
#
|
|
220
|
+
# 2A [42]
|
|
221
|
+
# Track Metadata
|
|
222
|
+
#
|
|
223
|
+
def response_action_2A(self, hexl: list, response: bytes):
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
Example PlayView Response:
|
|
227
|
+
|
|
228
|
+
{'Album': 'International Skankers', 'Artist': 'Ashkabad', 'BitDepth': 16,
|
|
229
|
+
'BitRate': '320000', 'CoverArtUrl': 'https://i.scdn.co/image/ab67616d0000b2730cbb03a339c6ffd18d10eab2',
|
|
230
|
+
'Current Source': 4, 'Current_time': -1, 'DSDType': '', 'Fav': False, 'FileSize': 0, 'Genre': '',
|
|
231
|
+
'Index': 0, 'Mime': 'Ogg', 'Next': False, 'PlayState': 0, 'PlayUrl': 'spotify:track:0IHTiLO5qBYhf7Hmn0UDBN',
|
|
232
|
+
'Prev': False, 'Repeat': 0, 'SampleRate': '44100', 'Seek': False, 'Shuffle': 0, 'SinglePlay': False,
|
|
233
|
+
'TotalTime': 203087, 'TrackName': 'Beijing'}
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
jsonr = response[10:]
|
|
237
|
+
metadata = json.loads(jsonr)
|
|
238
|
+
# CMD ID = 1 BrowseView - VSSL File Browser
|
|
239
|
+
# CMD ID = 3 PlayView (Track Info)
|
|
240
|
+
if (
|
|
241
|
+
TrackMetadataExtKeys.COMMAND_ID in metadata
|
|
242
|
+
and metadata[TrackMetadataExtKeys.COMMAND_ID] == 3
|
|
243
|
+
):
|
|
244
|
+
track_data = metadata[TrackMetadataExtKeys.WINDOW_CONTENTS]
|
|
245
|
+
self.zone.track._map_response_dict(track_data)
|
|
246
|
+
self.zone.transport._map_response_dict(track_data)
|
|
247
|
+
else:
|
|
248
|
+
self._log_debug(
|
|
249
|
+
f"{metadata[TrackMetadataExtKeys.WINDOW_TITLE]} is currently unsupported: {metadata}"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self._log_error(f"Unable to parse JSON. Exception: {e} | Response: {hexl}")
|
|
254
|
+
|
|
255
|
+
#
|
|
256
|
+
# 2D [45]
|
|
257
|
+
# Track Metadata from Track Next and Track Previous responses
|
|
258
|
+
#
|
|
259
|
+
def response_action_2D(self, hexl: list, response: bytes):
|
|
260
|
+
self.response_action_2A(hexl, response)
|
|
261
|
+
|
|
262
|
+
#
|
|
263
|
+
# 32 [50]
|
|
264
|
+
# Track Source Update
|
|
265
|
+
#
|
|
266
|
+
def response_action_32(self, hexl: list, response: bytes):
|
|
267
|
+
self.zone.track.source = int(self._extract_response_data(response))
|
|
268
|
+
self._log_debug(f"Received stream source: {self.zone.track.source}")
|
|
269
|
+
|
|
270
|
+
#
|
|
271
|
+
# 33 [51]
|
|
272
|
+
# Transport State
|
|
273
|
+
#
|
|
274
|
+
# Note: This state is different from the Alpha API
|
|
275
|
+
#
|
|
276
|
+
# Returns:
|
|
277
|
+
# Play = 0
|
|
278
|
+
# Stop = 1
|
|
279
|
+
# Pause = 2
|
|
280
|
+
#
|
|
281
|
+
def response_action_33(self, hexl: list, response: bytes):
|
|
282
|
+
"""
|
|
283
|
+
Alpha API will handle transport state
|
|
284
|
+
|
|
285
|
+
self._log_debug(f"Received transport state 33: {hexl}")
|
|
286
|
+
state = int(self._extract_response_data(response))
|
|
287
|
+
|
|
288
|
+
if state == 0:
|
|
289
|
+
self.zone.transport._set_state(ZoneTransport.States.PLAY)
|
|
290
|
+
elif state == 1:
|
|
291
|
+
self.zone.transport._set_state(ZoneTransport.States.STOP)
|
|
292
|
+
else:
|
|
293
|
+
self.zone.transport._set_state(state)
|
|
294
|
+
|
|
295
|
+
"""
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
#
|
|
299
|
+
# 36 [54]
|
|
300
|
+
# Errors and Success
|
|
301
|
+
#
|
|
302
|
+
# e.g When we play a URL directy, we get "success" on play then "error_nonextsong"
|
|
303
|
+
#
|
|
304
|
+
# success
|
|
305
|
+
# error_playfail
|
|
306
|
+
# error_nonextsong
|
|
307
|
+
#
|
|
308
|
+
def response_action_36(self, hexl: list, response: bytes):
|
|
309
|
+
feedback = self._extract_response_data(response).split("_")
|
|
310
|
+
if len(feedback) > 1:
|
|
311
|
+
self._log_debug(f"Received feedback {feedback[0]}: {feedback[1]}")
|
|
312
|
+
else:
|
|
313
|
+
self._log_debug(f"Received feedback: {feedback[0]}")
|
|
314
|
+
|
|
315
|
+
#
|
|
316
|
+
# 3F [63]
|
|
317
|
+
# Mute Status
|
|
318
|
+
#
|
|
319
|
+
def response_action_3F(self, hexl: list, response: bytes):
|
|
320
|
+
"""
|
|
321
|
+
Alpha API will handle the mute feedback
|
|
322
|
+
|
|
323
|
+
state = self._extract_response_data(response)
|
|
324
|
+
self._log_debug(f"Received mute status: {state}")
|
|
325
|
+
if state == "MUTE":
|
|
326
|
+
self.zone._set_property('mute', True)
|
|
327
|
+
elif state == "UNMUTE":
|
|
328
|
+
self.zone._set_property('mute', False)
|
|
329
|
+
|
|
330
|
+
"""
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
#
|
|
334
|
+
# 40 [64]
|
|
335
|
+
# Zone Volume Feedback
|
|
336
|
+
#
|
|
337
|
+
def response_action_40(self, hexl: list, response: bytes):
|
|
338
|
+
"""
|
|
339
|
+
Alpha API will handle the volume, there is some strange behavior
|
|
340
|
+
that the Bravo API gets a 0 vol when the zone is muted, but then
|
|
341
|
+
the device status responses with the actual volume level, even
|
|
342
|
+
though the zone is muted.
|
|
343
|
+
|
|
344
|
+
vol = int(self._extract_response_data(response))
|
|
345
|
+
self._log_debug(f"Received volume: {vol}%")
|
|
346
|
+
self.zone._set_property('volume', int(vol))
|
|
347
|
+
|
|
348
|
+
"""
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
#
|
|
352
|
+
# 46 [70]
|
|
353
|
+
# Unknown | Speaker active / inactive
|
|
354
|
+
#
|
|
355
|
+
def response_action_46(self, hexl: list, response: bytes):
|
|
356
|
+
self._log_debug(f"Received Unknown 46: {self._extract_response_data(response)}")
|
|
357
|
+
|
|
358
|
+
"""
|
|
359
|
+
This looks to be a stream update, Speaker active and stream input.
|
|
360
|
+
SPEAKER_INACTIVE or SPEAKER_ACTIVE plus the source input e,g 24. example:
|
|
361
|
+
|
|
362
|
+
b'\x00\x00\x02\x00F\x00\xa4\xce\x00\x12SPEAKER_INACTIVE,4\x00\x00\x02\x002\x00P*\x00\x0224'
|
|
363
|
+
b'\x00\x00\x02\x00F\x00\xd2\xac\x00\x13SPEAKER_INACTIVE,24\x00\x00\x02\x001\x00yc\x00\x05-1000'
|
|
364
|
+
ex: SPEAKER_INACTIVE,24
|
|
365
|
+
ex: SPEAKER_INACTIVE,4
|
|
366
|
+
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
#
|
|
370
|
+
# 4E [78]
|
|
371
|
+
# Unknown | Looks to be like a comfirmation feedback
|
|
372
|
+
#
|
|
373
|
+
def response_action_4E(self, hexl: list, response: bytes):
|
|
374
|
+
self._log_debug(f"Received Unknown 4E: {self._extract_response_data(response)}")
|
|
375
|
+
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
self._log_debug(f"Looks to be the play & volume feedback, possibly end of stream / stop feedback?")
|
|
379
|
+
Feecback when rebooting zone
|
|
380
|
+
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
#
|
|
384
|
+
# 4F [79]
|
|
385
|
+
# Unknown | Status Change?!
|
|
386
|
+
#
|
|
387
|
+
def response_action_4F(self, hexl: list, response: bytes):
|
|
388
|
+
self._log_debug(f"Received Unknown 4F {self._extract_response_data(response)}")
|
|
389
|
+
|
|
390
|
+
#
|
|
391
|
+
# 5B [91]
|
|
392
|
+
# MAC Address
|
|
393
|
+
#
|
|
394
|
+
def response_action_5B(self, hexl: list, response: bytes):
|
|
395
|
+
mac = self._extract_response_data(response)
|
|
396
|
+
self._log_debug(f"Received MAC address: {mac}")
|
|
397
|
+
self.zone._set_property("mac_addr", mac)
|
|
398
|
+
|
|
399
|
+
#
|
|
400
|
+
# 70 [112]
|
|
401
|
+
# System Status
|
|
402
|
+
#
|
|
403
|
+
# This is the confirmation that the device actually received the commands.
|
|
404
|
+
# This is a great way to discover the VSSL API
|
|
405
|
+
#
|
|
406
|
+
# NOTE: This will be a feedback for ALL zones not just this zone
|
|
407
|
+
#
|
|
408
|
+
def response_action_70(self, hexl: list, response: bytes):
|
|
409
|
+
length = 4 if hexl[0] == 16 else 10
|
|
410
|
+
cmd = hexl[length:]
|
|
411
|
+
self._log_debug(f"Command confirmation: {response[length:]}")
|
|
412
|
+
|
|
413
|
+
#
|
|
414
|
+
# Default
|
|
415
|
+
# Default Action
|
|
416
|
+
#
|
|
417
|
+
def response_action_default(self, hexl: list, response: bytes):
|
|
418
|
+
string = self._extract_response_data(response)
|
|
419
|
+
self._log_debug(f"Unknown command {hexl[1].upper()}: {string}")
|