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