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/track.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import base64
|
|
3
|
+
from . import zone
|
|
4
|
+
from typing import Dict, Union
|
|
5
|
+
from urllib.parse import urlparse, urlunparse
|
|
6
|
+
from .data_structure import VsslIntEnum, ZoneDataClass, TrackMetadataExtKeys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TrackMetadata(ZoneDataClass):
|
|
10
|
+
AIRPLAY_COVER_ART = "coverart.jpg"
|
|
11
|
+
|
|
12
|
+
class Keys:
|
|
13
|
+
TITLE = "title"
|
|
14
|
+
ALBUM = "album"
|
|
15
|
+
ARTIST = "artist"
|
|
16
|
+
GENRE = "genre"
|
|
17
|
+
DURATION = "duration"
|
|
18
|
+
PROGRESS = "progress"
|
|
19
|
+
COVER_ART_URL = "cover_art_url"
|
|
20
|
+
SOURCE = "source"
|
|
21
|
+
URL = "url"
|
|
22
|
+
|
|
23
|
+
#
|
|
24
|
+
# Stream Sources
|
|
25
|
+
#
|
|
26
|
+
# DO NOT CHANGE - VSSL Defined
|
|
27
|
+
#
|
|
28
|
+
class Sources(VsslIntEnum):
|
|
29
|
+
NOT_STREAMING = 0
|
|
30
|
+
AIRPLAY = 1
|
|
31
|
+
SPOTIFY = 4
|
|
32
|
+
TUNEIN = 9
|
|
33
|
+
ANALOG_IN = 15
|
|
34
|
+
APPLE_DEVICE = 16
|
|
35
|
+
DIRECT_URL = 17 # e.g play_url
|
|
36
|
+
BLUETOOTH = 19
|
|
37
|
+
TIDAL = 22
|
|
38
|
+
GOOGLECAST = 24
|
|
39
|
+
EXTERNAL = 25
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# Transport Events
|
|
43
|
+
#
|
|
44
|
+
class Events:
|
|
45
|
+
PREFIX = "track."
|
|
46
|
+
CHANGE = PREFIX + "change"
|
|
47
|
+
UPDATES = PREFIX + "updates"
|
|
48
|
+
TITLE_CHANGE = PREFIX + "title_change"
|
|
49
|
+
ALBUM_CHANGE = PREFIX + "album_change"
|
|
50
|
+
ARTIST_CHANGE = PREFIX + "artist_change"
|
|
51
|
+
GENRE_CHANGE = PREFIX + "genre_change"
|
|
52
|
+
DURATION_CHANGE = PREFIX + "duration_change"
|
|
53
|
+
PROGRESS_CHANGE = PREFIX + "progress_change"
|
|
54
|
+
COVER_ART_URL_CHANGE = PREFIX + "cover_art_url_change"
|
|
55
|
+
SOURCE_CHANGE = PREFIX + "source_change"
|
|
56
|
+
URL_CHANGE = PREFIX + "url_change"
|
|
57
|
+
|
|
58
|
+
DEFAULTS = {
|
|
59
|
+
Keys.TITLE: None,
|
|
60
|
+
Keys.ALBUM: None,
|
|
61
|
+
Keys.ARTIST: None,
|
|
62
|
+
Keys.GENRE: None,
|
|
63
|
+
Keys.DURATION: 0,
|
|
64
|
+
Keys.PROGRESS: 0,
|
|
65
|
+
Keys.COVER_ART_URL: None,
|
|
66
|
+
Keys.SOURCE: Sources.NOT_STREAMING,
|
|
67
|
+
Keys.URL: None,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
KEY_MAP = {
|
|
71
|
+
TrackMetadataExtKeys.DURATION: Keys.DURATION,
|
|
72
|
+
TrackMetadataExtKeys.TITLE: Keys.TITLE,
|
|
73
|
+
TrackMetadataExtKeys.ALBUM: Keys.ALBUM,
|
|
74
|
+
TrackMetadataExtKeys.ARTIST: Keys.ARTIST,
|
|
75
|
+
TrackMetadataExtKeys.COVER_ART_URL: Keys.COVER_ART_URL,
|
|
76
|
+
TrackMetadataExtKeys.SOURCE: Keys.SOURCE,
|
|
77
|
+
TrackMetadataExtKeys.GENRE: Keys.GENRE,
|
|
78
|
+
TrackMetadataExtKeys.URL: Keys.URL,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __init__(self, zone: "zone.Zone"):
|
|
82
|
+
self.zone = zone
|
|
83
|
+
|
|
84
|
+
self._title: str = None
|
|
85
|
+
self._album: str = None
|
|
86
|
+
self._artist: str = None
|
|
87
|
+
self._genre: str = None
|
|
88
|
+
self._duration: int = 0
|
|
89
|
+
self._progress: int = 0
|
|
90
|
+
self._cover_art_url: str = None
|
|
91
|
+
self._source = self.Sources.NOT_STREAMING
|
|
92
|
+
self._url: str = None
|
|
93
|
+
|
|
94
|
+
def as_dict(self):
|
|
95
|
+
dic = super().as_dict()
|
|
96
|
+
dic["progress_display"] = self.progress_display
|
|
97
|
+
return dic
|
|
98
|
+
|
|
99
|
+
#
|
|
100
|
+
# VSSL doenst clear some vars on stopping of the stream, so we will do it
|
|
101
|
+
#
|
|
102
|
+
# Doing this will fire the change events on the bus. Instead of conditionally
|
|
103
|
+
# using the getter functions since we want the changes to be propogated
|
|
104
|
+
#
|
|
105
|
+
# VSSL has a happit of caching the last song played, so we need to clear it
|
|
106
|
+
#
|
|
107
|
+
def set_defaults(self):
|
|
108
|
+
for key, default_value in self.DEFAULTS.items():
|
|
109
|
+
self._update_property(key, default_value, True)
|
|
110
|
+
|
|
111
|
+
#
|
|
112
|
+
# Updade a property and emit and event if changed
|
|
113
|
+
#
|
|
114
|
+
# Transport state is ignored when part of a group for its initial pull from master
|
|
115
|
+
#
|
|
116
|
+
def _update_property(self, key: str, new_value, ignore_transport_state=False):
|
|
117
|
+
# Default if stopped
|
|
118
|
+
if self.zone.transport.is_stopped and not ignore_transport_state:
|
|
119
|
+
new_value = self.DEFAULTS[key]
|
|
120
|
+
|
|
121
|
+
if getattr(self, key) != new_value:
|
|
122
|
+
setattr(self, f"_{key}", new_value) # set private var
|
|
123
|
+
new_set_value = getattr(self, key)
|
|
124
|
+
|
|
125
|
+
self.zone._event_publish(
|
|
126
|
+
getattr(self.Events, f"{key.upper()}_CHANGE"), new_set_value
|
|
127
|
+
)
|
|
128
|
+
self.zone._event_publish(self.Events.CHANGE, (key, new_set_value))
|
|
129
|
+
|
|
130
|
+
#
|
|
131
|
+
# Update the track properties from a group master when part of a group.
|
|
132
|
+
# This is handled via the Eventbus
|
|
133
|
+
#
|
|
134
|
+
async def _update_property_from_group_master(
|
|
135
|
+
self, data: Dict[str, int], *args, **kwargs
|
|
136
|
+
) -> None:
|
|
137
|
+
if hasattr(self, data[0]):
|
|
138
|
+
setattr(self, data[0], data[1])
|
|
139
|
+
|
|
140
|
+
#
|
|
141
|
+
# Update from a JSON dict passed
|
|
142
|
+
#
|
|
143
|
+
def _map_response_dict(self, track_data: Dict[str, int]) -> None:
|
|
144
|
+
"""Ignore the track data if zone is part of a group.
|
|
145
|
+
|
|
146
|
+
VSSL has a happit of caching old track meta when part of a group
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
if not self.zone.group.is_member:
|
|
150
|
+
for track_data_key, metadata_key in self.KEY_MAP.items():
|
|
151
|
+
if track_data_key in track_data:
|
|
152
|
+
setattr(self, metadata_key, track_data[track_data_key])
|
|
153
|
+
|
|
154
|
+
""" TODO!
|
|
155
|
+
|
|
156
|
+
Now we added the child, lets propage the track to the new member.
|
|
157
|
+
This only needs to happen once, as after wards the track data will
|
|
158
|
+
be updated by the event bus
|
|
159
|
+
|
|
160
|
+
Sometime, the VSSL responses with old cached track metadata, on the member zones
|
|
161
|
+
but generally it responds with a BrowseView when its a member of a group
|
|
162
|
+
|
|
163
|
+
When a group is created, the child will get the group index first (generally this will be the same
|
|
164
|
+
as the index_id) then its transport state will be
|
|
165
|
+
updated on the VSSL side.
|
|
166
|
+
When the transport state is changed, the zone will request the track meta. VSSL will respond
|
|
167
|
+
with the last cached metadata from the zone and not the correct meta from the current zone master.
|
|
168
|
+
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def _pull_from_zone(self, zone: int) -> None:
|
|
172
|
+
master = self.zone.vssl.get_zone(zone)
|
|
173
|
+
|
|
174
|
+
if not master:
|
|
175
|
+
self.zone._log_error(
|
|
176
|
+
f"Zone {zone} was not avaiable on VSSL, maybe we are not managing it or it has not be initialised yet"
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
for key, default_value in self.DEFAULTS.items():
|
|
181
|
+
if hasattr(master.track, key):
|
|
182
|
+
self._update_property(key, getattr(master.track, key), True)
|
|
183
|
+
|
|
184
|
+
#
|
|
185
|
+
# Track Title
|
|
186
|
+
#
|
|
187
|
+
@property
|
|
188
|
+
def title(self) -> str:
|
|
189
|
+
return self._title
|
|
190
|
+
|
|
191
|
+
@title.setter
|
|
192
|
+
def title(self, value: str) -> None:
|
|
193
|
+
self._update_property(self.Keys.TITLE, value)
|
|
194
|
+
|
|
195
|
+
#
|
|
196
|
+
# Track Album
|
|
197
|
+
#
|
|
198
|
+
@property
|
|
199
|
+
def album(self) -> str:
|
|
200
|
+
return self._album
|
|
201
|
+
|
|
202
|
+
@album.setter
|
|
203
|
+
def album(self, value: str) -> None:
|
|
204
|
+
self._update_property(self.Keys.ALBUM, value)
|
|
205
|
+
|
|
206
|
+
#
|
|
207
|
+
# Track Artist
|
|
208
|
+
#
|
|
209
|
+
@property
|
|
210
|
+
def artist(self) -> str:
|
|
211
|
+
return self._artist
|
|
212
|
+
|
|
213
|
+
@artist.setter
|
|
214
|
+
def artist(self, value: str) -> None:
|
|
215
|
+
self._update_property(self.Keys.ARTIST, value)
|
|
216
|
+
|
|
217
|
+
#
|
|
218
|
+
# Track Genre
|
|
219
|
+
#
|
|
220
|
+
@property
|
|
221
|
+
def genre(self) -> str:
|
|
222
|
+
return self._genre
|
|
223
|
+
|
|
224
|
+
@genre.setter
|
|
225
|
+
def genre(self, value: str) -> None:
|
|
226
|
+
self._update_property(self.Keys.GENRE, value)
|
|
227
|
+
|
|
228
|
+
#
|
|
229
|
+
# Track Duration
|
|
230
|
+
#
|
|
231
|
+
@property
|
|
232
|
+
def duration(self) -> int:
|
|
233
|
+
return self._duration
|
|
234
|
+
|
|
235
|
+
@duration.setter
|
|
236
|
+
def duration(self, value: int) -> None:
|
|
237
|
+
self._update_property(self.Keys.DURATION, value)
|
|
238
|
+
|
|
239
|
+
#
|
|
240
|
+
# Track Duration
|
|
241
|
+
#
|
|
242
|
+
@property
|
|
243
|
+
def progress(self) -> int:
|
|
244
|
+
return self._progress
|
|
245
|
+
|
|
246
|
+
@progress.setter
|
|
247
|
+
def progress(self, value: int) -> None:
|
|
248
|
+
self._update_property(self.Keys.PROGRESS, value)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def progress_display(self):
|
|
252
|
+
milliseconds = self.progress if self.progress != None else 0
|
|
253
|
+
|
|
254
|
+
seconds, milliseconds = divmod(milliseconds, 1000)
|
|
255
|
+
minutes, seconds = divmod(seconds, 60)
|
|
256
|
+
hours, minutes = divmod(minutes, 60)
|
|
257
|
+
|
|
258
|
+
formatted_time = ""
|
|
259
|
+
|
|
260
|
+
if hours > 0:
|
|
261
|
+
formatted_time += "{}:".format(hours)
|
|
262
|
+
|
|
263
|
+
formatted_time += (
|
|
264
|
+
"{:02}:{:02}".format(minutes, seconds)
|
|
265
|
+
if hours > 0
|
|
266
|
+
else "{}:{:02}".format(minutes, seconds)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return formatted_time
|
|
270
|
+
|
|
271
|
+
#
|
|
272
|
+
# Track Cover Art
|
|
273
|
+
#
|
|
274
|
+
@property
|
|
275
|
+
def cover_art_url(self) -> str:
|
|
276
|
+
return self._cover_art_url
|
|
277
|
+
|
|
278
|
+
@cover_art_url.setter
|
|
279
|
+
def cover_art_url(self, value: str) -> None:
|
|
280
|
+
self._update_property(self.Keys.COVER_ART_URL, self.add_host_if_not_url(value))
|
|
281
|
+
|
|
282
|
+
def add_host_if_not_url(self, url):
|
|
283
|
+
"""Using airplay, the cover_art will return a relative url of "coverart.jpg", so we will
|
|
284
|
+
need to add the host.
|
|
285
|
+
|
|
286
|
+
"""
|
|
287
|
+
if not url:
|
|
288
|
+
return None
|
|
289
|
+
# Parse the URL
|
|
290
|
+
parsed_url = urlparse(url)
|
|
291
|
+
default_host = f"http://{self.zone.host}"
|
|
292
|
+
query = ""
|
|
293
|
+
|
|
294
|
+
# Check if the scheme and netloc are missing
|
|
295
|
+
if not parsed_url.scheme or not parsed_url.netloc:
|
|
296
|
+
# Add the default host if it's not a URL
|
|
297
|
+
# Join the default host with the original URL path
|
|
298
|
+
if not default_host.endswith("/") and not url.startswith("/"):
|
|
299
|
+
default_host += "/"
|
|
300
|
+
|
|
301
|
+
# Lets add a query string to the coverart.jpg URL so the latest coverart
|
|
302
|
+
# will always be downloaded if the album changes
|
|
303
|
+
if url == self.AIRPLAY_COVER_ART and self.album:
|
|
304
|
+
encoded_album = (
|
|
305
|
+
base64.urlsafe_b64encode(self.album.encode()).decode().rstrip("=")
|
|
306
|
+
)[:6]
|
|
307
|
+
query = f"?album={encoded_album}"
|
|
308
|
+
|
|
309
|
+
return default_host + url + query
|
|
310
|
+
|
|
311
|
+
# Return the original URL if it is already a valid URL
|
|
312
|
+
return url
|
|
313
|
+
|
|
314
|
+
#
|
|
315
|
+
# Track Source
|
|
316
|
+
#
|
|
317
|
+
@property
|
|
318
|
+
def source(self) -> Sources:
|
|
319
|
+
return self._source
|
|
320
|
+
|
|
321
|
+
@source.setter
|
|
322
|
+
def source(self, src: Sources) -> None:
|
|
323
|
+
if self.Sources.is_valid(src):
|
|
324
|
+
self._update_property(self.Keys.SOURCE, self.Sources(src))
|
|
325
|
+
else:
|
|
326
|
+
self.zone._log_error(f"TrackMetadata.Sources {src} doesnt exist")
|
|
327
|
+
|
|
328
|
+
#
|
|
329
|
+
# Track URL
|
|
330
|
+
#
|
|
331
|
+
@property
|
|
332
|
+
def url(self) -> str:
|
|
333
|
+
return self._url
|
|
334
|
+
|
|
335
|
+
@url.setter
|
|
336
|
+
def url(self, value: str) -> None:
|
|
337
|
+
self._update_property(self.Keys.URL, value)
|
vsslctrl/transport.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Union
|
|
3
|
+
from .track import TrackMetadata
|
|
4
|
+
from .utils import clamp_volume
|
|
5
|
+
from .data_structure import VsslIntEnum, ZoneDataClass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ZoneTransport(ZoneDataClass):
|
|
9
|
+
class States(VsslIntEnum):
|
|
10
|
+
"""Transport States
|
|
11
|
+
|
|
12
|
+
DO NOT CHANGE - VSSL Defined
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
STOP = 0
|
|
16
|
+
PLAY = 1
|
|
17
|
+
PAUSE = 2
|
|
18
|
+
|
|
19
|
+
class Repeat(VsslIntEnum):
|
|
20
|
+
"""Repeat
|
|
21
|
+
|
|
22
|
+
DO NOT CHANGE - VSSL Defined
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
OFF = 0 # No repeat
|
|
26
|
+
ONE = 1 # Repeat single track
|
|
27
|
+
ALL = 2 # Repeat queue / playlist / album
|
|
28
|
+
|
|
29
|
+
class Events:
|
|
30
|
+
"""Transport Events"""
|
|
31
|
+
|
|
32
|
+
PREFIX = "zone.transport."
|
|
33
|
+
STATE_CHANGE = PREFIX + "state_change"
|
|
34
|
+
STATE_CHANGE_STOP = PREFIX + "state_change.stop"
|
|
35
|
+
STATE_CHANGE_PLAY = PREFIX + "state_change.play"
|
|
36
|
+
STATE_CHANGE_PAUSE = PREFIX + "state_change.pause"
|
|
37
|
+
|
|
38
|
+
IS_REPEAT_CHANGE = PREFIX + "is_repeat_change"
|
|
39
|
+
IS_SHUFFLE_CHANGE = PREFIX + "is_shuffle_change"
|
|
40
|
+
HAS_NEXT_CHANGE = PREFIX + "has_next_change"
|
|
41
|
+
HAS_PREV_CHANGE = PREFIX + "has_prev_change"
|
|
42
|
+
|
|
43
|
+
DEFAULTS = {
|
|
44
|
+
"state": States.STOP,
|
|
45
|
+
"is_repeat": Repeat.OFF,
|
|
46
|
+
"is_shuffle": False,
|
|
47
|
+
"has_next": False,
|
|
48
|
+
"has_prev": False,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
KEY_MAP = {
|
|
52
|
+
"Next": "has_next",
|
|
53
|
+
"Prev": "has_prev",
|
|
54
|
+
"Shuffle": "is_shuffle",
|
|
55
|
+
"Repeat": "is_repeat",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self, zone: "zone.Zone"):
|
|
59
|
+
self.zone = zone
|
|
60
|
+
|
|
61
|
+
self._state = self.States.STOP
|
|
62
|
+
self._is_repeat = self.Repeat.OFF
|
|
63
|
+
self._is_shuffle = False
|
|
64
|
+
self._has_next = False
|
|
65
|
+
self._has_prev = False
|
|
66
|
+
|
|
67
|
+
def set_defaults(self):
|
|
68
|
+
"""VSSL doenst clear some vars on stopping of the stream, so we will do it
|
|
69
|
+
|
|
70
|
+
Doing this will fire the change events on the bus. Instead of conditionally
|
|
71
|
+
using the getter functions since we want the changes to be propogated
|
|
72
|
+
|
|
73
|
+
VSSL has a happit of caching the last song played, so we need to clear it
|
|
74
|
+
"""
|
|
75
|
+
for key, default_value in self.DEFAULTS.items():
|
|
76
|
+
set_func = f"_set_{key}"
|
|
77
|
+
if hasattr(self, set_func):
|
|
78
|
+
getattr(self, set_func)(default_value)
|
|
79
|
+
|
|
80
|
+
def _default_on_state_stop(self, value, default=None):
|
|
81
|
+
"""Set value based on transport state."""
|
|
82
|
+
return default if self.state == self.States.STOP else value
|
|
83
|
+
|
|
84
|
+
def _map_response_dict(self, track_data: Dict[str, int]) -> None:
|
|
85
|
+
"""Update from a JSON dict"""
|
|
86
|
+
for track_data_key, metadata_key in self.KEY_MAP.items():
|
|
87
|
+
if track_data_key in track_data:
|
|
88
|
+
self._set_property(metadata_key, track_data[track_data_key])
|
|
89
|
+
|
|
90
|
+
def _set_bool_property(self, prop_key: str, new_value: int, event: str) -> None:
|
|
91
|
+
"""Set a bool property"""
|
|
92
|
+
new_value = self._default_on_state_stop(
|
|
93
|
+
not not new_value, self.DEFAULTS[prop_key]
|
|
94
|
+
)
|
|
95
|
+
cur_value = getattr(self, prop_key)
|
|
96
|
+
|
|
97
|
+
if cur_value != new_value:
|
|
98
|
+
setattr(self, f"_{prop_key}", new_value)
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
#
|
|
102
|
+
# Transport State
|
|
103
|
+
#
|
|
104
|
+
@property
|
|
105
|
+
def state(self):
|
|
106
|
+
return self._state
|
|
107
|
+
|
|
108
|
+
@state.setter
|
|
109
|
+
def state(self, state: int):
|
|
110
|
+
if self.States.is_valid(state):
|
|
111
|
+
state = self.States(state)
|
|
112
|
+
self.zone.api_alpha.request_action_3D(state)
|
|
113
|
+
|
|
114
|
+
# Changing state wont work if we are part of a group
|
|
115
|
+
# So if we are wanting to stop, leave the group
|
|
116
|
+
if state == self.States.STOP and self.zone.group.is_member:
|
|
117
|
+
self.zone.group.leave()
|
|
118
|
+
else:
|
|
119
|
+
self.zone._log_error(f"ZoneTransport.States {state} doesnt exist")
|
|
120
|
+
|
|
121
|
+
def _set_state(self, state: int):
|
|
122
|
+
if self.state != state:
|
|
123
|
+
if self.States.is_valid(state):
|
|
124
|
+
self._state = self.States(state)
|
|
125
|
+
self.zone._event_publish(
|
|
126
|
+
getattr(self.Events, f"STATE_CHANGE_{self.state.name.upper()}"),
|
|
127
|
+
True,
|
|
128
|
+
)
|
|
129
|
+
return True
|
|
130
|
+
else:
|
|
131
|
+
self.zone._log_warning(f"ZoneTransport.States {state} doesnt exist")
|
|
132
|
+
|
|
133
|
+
#
|
|
134
|
+
# Transport Commands
|
|
135
|
+
#
|
|
136
|
+
def play(self):
|
|
137
|
+
self.state = self.States.PLAY
|
|
138
|
+
|
|
139
|
+
def stop(self):
|
|
140
|
+
"""note: stopping a stream will disconnect the client as will pausing an Airplay stream.
|
|
141
|
+
|
|
142
|
+
note2: streams (Airplay, Chromecast, Spotify.Connect) have higher priority than analog inputs.
|
|
143
|
+
Therefore, if both input types are playing to a zone then the higher priority will play. Likewise,
|
|
144
|
+
if both types of input are playing to a zone and the stream is stopped then the zone will switch
|
|
145
|
+
to playing the lower priority content.
|
|
146
|
+
|
|
147
|
+
ref: https://vssl.gitbook.io/vssl-rest-api/zone-control/play-control
|
|
148
|
+
"""
|
|
149
|
+
self.state = self.States.STOP
|
|
150
|
+
|
|
151
|
+
def pause(self):
|
|
152
|
+
self.state = self.States.PAUSE
|
|
153
|
+
|
|
154
|
+
#
|
|
155
|
+
# Transport States
|
|
156
|
+
#
|
|
157
|
+
@property
|
|
158
|
+
def is_playing(self):
|
|
159
|
+
return self.state == self.States.PLAY
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def is_stopped(self):
|
|
163
|
+
return self.state == self.States.STOP
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def is_paused(self):
|
|
167
|
+
return self.state == self.States.PAUSE
|
|
168
|
+
|
|
169
|
+
#
|
|
170
|
+
# Track Control
|
|
171
|
+
#
|
|
172
|
+
def next(self):
|
|
173
|
+
self.zone.api_bravo.request_action_40_next()
|
|
174
|
+
|
|
175
|
+
def prev(self):
|
|
176
|
+
self.zone.api_bravo.request_action_40_prev()
|
|
177
|
+
|
|
178
|
+
def back(self):
|
|
179
|
+
self.prev()
|
|
180
|
+
self.prev()
|
|
181
|
+
|
|
182
|
+
#
|
|
183
|
+
# Is the next button enabled
|
|
184
|
+
#
|
|
185
|
+
@property
|
|
186
|
+
def has_next(self):
|
|
187
|
+
return self._has_next
|
|
188
|
+
|
|
189
|
+
@has_next.setter
|
|
190
|
+
def set_has_next(self, val: int):
|
|
191
|
+
pass # read-only
|
|
192
|
+
|
|
193
|
+
def _set_has_next(self, val: int):
|
|
194
|
+
return self._set_bool_property("has_next", val, self.Events.HAS_NEXT_CHANGE)
|
|
195
|
+
|
|
196
|
+
#
|
|
197
|
+
# Track Prev_flag
|
|
198
|
+
#
|
|
199
|
+
@property
|
|
200
|
+
def has_prev(self):
|
|
201
|
+
return self._has_prev
|
|
202
|
+
|
|
203
|
+
@has_prev.setter
|
|
204
|
+
def has_prev(self, val: int):
|
|
205
|
+
pass # read-only
|
|
206
|
+
|
|
207
|
+
def _set_has_prev(self, val: int):
|
|
208
|
+
return self._set_bool_property("has_prev", val, self.Events.HAS_PREV_CHANGE)
|
|
209
|
+
|
|
210
|
+
#
|
|
211
|
+
# Track Shuffle
|
|
212
|
+
#
|
|
213
|
+
@property
|
|
214
|
+
def is_shuffle(self):
|
|
215
|
+
return self._is_shuffle
|
|
216
|
+
|
|
217
|
+
@is_shuffle.setter
|
|
218
|
+
def is_shuffle(self, val: int):
|
|
219
|
+
pass # read-only
|
|
220
|
+
|
|
221
|
+
def _set_is_shuffle(self, val: int):
|
|
222
|
+
return self._set_bool_property("is_shuffle", val, self.Events.IS_SHUFFLE_CHANGE)
|
|
223
|
+
|
|
224
|
+
#
|
|
225
|
+
# Track Repeat
|
|
226
|
+
#
|
|
227
|
+
@property
|
|
228
|
+
def is_repeat(self):
|
|
229
|
+
return self._is_repeat
|
|
230
|
+
|
|
231
|
+
@is_repeat.setter
|
|
232
|
+
def is_repeat(self, val: int):
|
|
233
|
+
pass # read-only
|
|
234
|
+
|
|
235
|
+
def _set_is_repeat(self, val: int):
|
|
236
|
+
val = self._default_on_state_stop(val, self.DEFAULTS["is_repeat"])
|
|
237
|
+
if self.is_repeat != val:
|
|
238
|
+
if self.Repeat.is_valid(val):
|
|
239
|
+
self._is_repeat = self.Repeat(val)
|
|
240
|
+
return True
|
|
241
|
+
else:
|
|
242
|
+
self.zone._log_error(f"ZoneTransport.Repeat {val} doesnt exist")
|
vsslctrl/utils.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
#
|
|
5
|
+
# Hex to Int
|
|
6
|
+
#
|
|
7
|
+
def hex_to_int(str: str, base: int = 16):
|
|
8
|
+
return int(str, base)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# Clamp Volume
|
|
13
|
+
#
|
|
14
|
+
def clamp_volume(vol: int):
|
|
15
|
+
return max(0, min(int(vol), 100))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#
|
|
19
|
+
# Cancel a task if it exsists
|
|
20
|
+
#
|
|
21
|
+
def cancel_task(task: asyncio.Task):
|
|
22
|
+
if isinstance(task, asyncio.Task) and not task.done():
|
|
23
|
+
try:
|
|
24
|
+
task.cancel()
|
|
25
|
+
except asyncio.CancelledError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#
|
|
30
|
+
# Groups dicts by property
|
|
31
|
+
#
|
|
32
|
+
def group_list_by_property(input_list, property_key):
|
|
33
|
+
grouped_dict = {}
|
|
34
|
+
for item in input_list:
|
|
35
|
+
property_value = item.get(property_key)
|
|
36
|
+
if property_value is not None:
|
|
37
|
+
grouped_dict.setdefault(property_value, []).append(item)
|
|
38
|
+
return grouped_dict
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#
|
|
42
|
+
# Repeat Timer
|
|
43
|
+
#
|
|
44
|
+
class RepeatTimer:
|
|
45
|
+
def __init__(self, interval, function, start_delay=False, *args, **kwargs):
|
|
46
|
+
self.interval = interval
|
|
47
|
+
self.function = function
|
|
48
|
+
self.start_delay = start_delay
|
|
49
|
+
self.args = args
|
|
50
|
+
self.kwargs = kwargs
|
|
51
|
+
self.task = None
|
|
52
|
+
self.running = False
|
|
53
|
+
|
|
54
|
+
async def _run(self):
|
|
55
|
+
while self.running:
|
|
56
|
+
if self.start_delay:
|
|
57
|
+
await asyncio.sleep(self.interval)
|
|
58
|
+
|
|
59
|
+
if asyncio.iscoroutinefunction(self.function):
|
|
60
|
+
await self.function(*self.args, **self.kwargs)
|
|
61
|
+
else:
|
|
62
|
+
self.function(*self.args, **self.kwargs)
|
|
63
|
+
|
|
64
|
+
if not self.start_delay:
|
|
65
|
+
await asyncio.sleep(self.interval)
|
|
66
|
+
|
|
67
|
+
def start(self):
|
|
68
|
+
if not self.running:
|
|
69
|
+
self.running = True
|
|
70
|
+
self.task = asyncio.create_task(self._run())
|
|
71
|
+
|
|
72
|
+
def cancel(self):
|
|
73
|
+
if self.running:
|
|
74
|
+
self.running = False
|
|
75
|
+
self.task.cancel()
|