-
Notifications
You must be signed in to change notification settings - Fork 2
/
sonos.py
336 lines (273 loc) · 11.7 KB
/
sonos.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# Supress future warning for SoCo
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
import os
from time import sleep
import soco
from soco.events import event_listener
from soco.data_structures import to_didl_string
from threading import Thread
CURRENT_ZONE_FILE=os.getenv('CURRENT_ZONE_FILE')
class Sonos(object):
# The amount the volume is changed
# each time it is increased or decreased
VOLUME_CHANGE = 2
instance = soco.discover().pop()
def __init__(self):
self._current_zone = None
self._zoneListenerThread = None
self._renderingControlSubscription = None
self._avTransportSubscription = None
self._zoneGroupTopologySubscription = None
self._listeningForZoneChanges = False
self.current_zone = self.read_current_zone_file()
# Attempts to return the current zone by reading from the setting file
def read_current_zone_file(self):
try:
with open(CURRENT_ZONE_FILE) as file:
return file.read()
except:
return None
def update_current_zone_file(self):
try:
with open(CURRENT_ZONE_FILE, 'w') as file:
file.write(self.current_zone)
except:
pass
@property
def current_zone(self):
if self._current_zone is None:
# Try to load zone from setting file
zone = self.read_current_zone_file()
match = Sonos.get_zone_by_name(zone)
if match is not None:
self._current_zone = match
else:
# Set it to a random zone
self.current_zone = soco.discover().pop().player_name
return self._current_zone.player_name
@current_zone.setter
def current_zone(self, zoneName):
if zoneName is None or zoneName.strip() == '':
# Set it to a random zone
self._current_zone = soco.discover().pop()
elif self._current_zone is not None and self._current_zone.player_name != zoneName:
# Stop listening for zone changes on current zone
self.stop_listening_for_zone_changes()
# Change zone
self._current_zone = Sonos.get_zone_by_name(zoneName)
else:
self._current_zone = Sonos.get_zone_by_name(zoneName)
if not self.is_coordinator:
self.update_zone_to_coordinator()
self.update_current_zone_file()
@property
def mute(self):
if self._current_zone is not None:
return self._current_zone.mute
@mute.setter
def mute(self, mute):
if self._current_zone is not None:
self._current_zone.mute = mute
# update group mute, if applicable
for member in self.group_members:
zone = Sonos.get_zone_by_name(member)
zone.mute = mute
@property
def volume(self):
if self._current_zone is not None:
return self._current_zone.volume
@volume.setter
def volume(self, volume):
if self._current_zone is not None:
volume_diff = volume - self._current_zone.volume
self._current_zone.volume = volume
# update group volume, if applicable
for member in self.group_members:
zone = Sonos.get_zone_by_name(member)
zone.volume += volume_diff
@property
def play_mode(self):
if self._current_zone is not None:
return self._current_zone.play_mode
@play_mode.setter
def play_mode(self, play_mode):
if self._current_zone is not None:
try:
self._current_zone.play_mode = play_mode
except:
pass
@property
def group_members(self):
''' Return a sorted list of unique group member names or None '''
if self._current_zone is not None:
unique_members = set()
for member in self._current_zone.group.members:
if member.player_name != self._current_zone.player_name:
unique_members.add(member.player_name)
return sorted(unique_members)
return None
@property
def potential_members(self):
''' Return a sorted list of zones that COULD be or ARE a member '''
if self._current_zone is not None:
zones = Sonos.get_zone_names()
# Remove this zone -- can't be a member of yourself
zones.remove(self.current_zone)
return zones
return None
@property
def current_zone_label(self):
''' Return the current zone name or a modified version if there are members in its group '''
if self._current_zone is not None:
num_members = len(self.group_members)
if num_members > 0:
return self._current_zone.player_name + " + {}".format(num_members)
else:
return self._current_zone.player_name
return ''
@property
def is_coordinator(self):
if self._current_zone is not None:
return self._current_zone.player_name == self._current_zone.group.coordinator.player_name
return False
def update_zone_to_coordinator(self):
if self._current_zone is not None:
self.current_zone = self._current_zone.group.coordinator.player_name
def play(self):
if self._current_zone is not None:
self._current_zone.play()
def pause(self):
if self._current_zone is not None:
self._current_zone.pause()
def next(self):
if self._current_zone is not None:
self._current_zone.next()
def previous(self):
if self._current_zone is not None:
self._current_zone.previous()
def group(self, rooms):
''' Joins all the speakers in the list to the current zone '''
if self._current_zone is not None:
for room in rooms:
zone = Sonos.get_zone_by_name(room["name"])
if room['join']:
zone.join(self._current_zone)
else:
zone.unjoin()
def play_track(self, track):
if self._current_zone is not None:
self._current_zone.play_uri(track.resources[0].uri, to_didl_string(track))
def play_playlist(self, playlist, play_mode='NORMAL'):
if self._current_zone is not None:
# Replace the queue with these tracks and start playing
self._current_zone.clear_queue()
self._current_zone.add_to_queue(playlist)
self.play_mode = play_mode
# Start the queue on the first track
self._current_zone.play_from_queue(0)
def play_favorite(self, favorite):
if self._current_zone is not None:
try:
# Works for uri like x-sonosapi-radio (ex. Pandora)
self._current_zone.play_uri(favorite.reference.resources[0].uri, favorite.resource_meta_data)
except:
try:
# Works for uri like x-rincon-cpcontainer (ex. Sound Cloud playlist)
self.play_playlist(favorite.reference)
except:
pass
def listen_for_zone_changes(self, callback):
self._listeningForZoneChanges = True
self._avTransportSubscription = self._current_zone.avTransport.subscribe()
self._renderingControlSubscription = self._current_zone.renderingControl.subscribe()
self._zoneGroupTopologySubscription = self._current_zone.zoneGroupTopology.subscribe()
def listen():
while self._listeningForZoneChanges:
try:
event = self._avTransportSubscription.events.get(timeout=0.1)
# Add in track info as well
event.variables['track'] = self._current_zone.get_current_track_info()
event.variables['tv_playing'] = int(self._current_zone.is_playing_tv)
callback(event.variables)
except:
pass
try:
event = self._renderingControlSubscription.events.get(timeout=0.1)
callback(event.variables)
except:
pass
try:
event = self._zoneGroupTopologySubscription.events.get(timeout=0.1)
callback(event.variables)
except:
pass
self._avTransportSubscription.unsubscribe()
self._renderingControlSubscription.unsubscribe()
self._zoneGroupTopologySubscription.unsubscribe()
event_listener.stop()
self._zoneListenerThread = Thread(target=listen)
self._zoneListenerThread.start()
def stop_listening_for_zone_changes(self, callback=None):
self._listeningForZoneChanges = False
if self._zoneListenerThread is not None: self._zoneListenerThread.join()
if callback: callback()
@classmethod
def artists(cls):
return Sonos.instance.music_library.get_artists(complete_result=True)
@classmethod
def albums(cls):
return Sonos.instance.music_library.get_albums(complete_result=True)
@classmethod
def genres(cls):
return Sonos.instance.music_library.get_genres(complete_result=True)
@classmethod
def playlists(cls):
return Sonos.instance.music_library.get_playlists(complete_result=True)
@classmethod
def favorites(cls):
return Sonos.instance.music_library.get_sonos_favorites(complete_result=True)
@classmethod
def browse(cls, ml_item):
return Sonos.instance.music_library.browse(ml_item,0,100000,True)
@classmethod
def in_party_mode(cls):
''' Returns bool '''
zones = Sonos.get_zone_groups()
# Should only ever be 1 coordinator if we are in party mode
coordinator_zones = [zone for zone in zones if zone["is_coordinator"] == True]
return len(coordinator_zones) == 1
@staticmethod
def get_zone_names():
''' Returns a sorted list of zone names '''
zone_list = list(soco.discover())
zone_names = []
for zone in zone_list:
zone_names.append(zone.player_name)
return sorted(zone_names)
@staticmethod
def get_zone_groups():
''' Returns a sorted list of zone groups '''
zone_list = list(soco.discover())
zones = []
for zone in zone_list:
# Set of members in group that are not itself
unique_members = set()
for member in zone.group.members:
if member.player_name != zone.player_name :
unique_members.add(member.player_name)
zones.append({
"name": zone.player_name,
"is_coordinator": zone.player_name == zone.group.coordinator.player_name,
"members": sorted(unique_members) # Get sorted list from set
})
return sorted(zones)
### Private Methods ###
@staticmethod
def get_zone_by_name(zoneName):
'''Returns a SoCo instance if found, or None if not found'''
zone_list = list(soco.discover())
for zone in zone_list:
if zone.player_name == zoneName:
return zone
return None