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/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}")