· 6 years ago · Jan 11, 2020, 07:52 PM
1#
2# Spotify Plugin
3#
4# Daan Jansen, Coral Rosoff, 2018
5# https://github.com/DaanJJansen/domoticz-spotify
6#
7
8"""
9<plugin key="Spotify" name="Spotify Plugin" author="djj" version="0.2" wikilink="https://github.com/DaanJJansen/domoticz-spotify" externallink="https://api.spotify.com">
10 <params>
11 <param field="Address" label="Domoticz IP Address" width="200px" required="true" default="localhost"/>
12 <param field="Port" label="Domoticz Port" width="40px" required="true" default="8080"/>
13 <param field="Username" label="Domoticz Username" width="200px" required="false" default=""/>
14 <param field="Password" label="Domoticz Password" width="200px" required="false" default=""/>
15 <param field="Mode4" label="Domoticz encoded credentials" width="200px" required="false" default=""/>
16 <param field="Mode1" label="Client ID" width="200px" required="true" default=""/>
17 <param field="Mode2" label="Client Secret" width="200px" required="true" default=""/>
18 <param field="Mode3" label="Code" width="400px" required="true" default=""/>
19 <param field="Mode5" label="Poll intervall" width="100px" required="true">
20 <options>
21 <option label="None" value=0/>
22 <option label="30 seconds" value=1/>
23 <option label="5 minutes" value=10 default="true"/>
24 <option label="15 minutes" value=30/>
25 <option label="30 minutes" value=60/>
26 <option label="60 minutes" value=120/>
27 </options>
28 </param>
29 <param field="Mode6" label="Debug" width="75px">
30 <options>
31 <option label="True" value="Debug"/>
32 <option label="False" value="Normal" default="True" />
33 </options>
34 </param>
35 </params>
36</plugin>
37"""
38
39try:
40 import Domoticz
41
42 local = False
43except ImportError:
44 local = True
45 import fakeDomoticz as Domoticz
46 from fakeDomoticz import Devices
47 from fakeDomoticz import Parameters
48
49import urllib.request
50import urllib.error
51import urllib.parse
52import base64
53import json
54import time
55
56# DEFINES
57SPOTIFYDEVICES = 1
58
59
60#############################################################################
61# Domoticz call back functions #
62#############################################################################
63class BasePlugin:
64 def __init__(self):
65 self.spotifyToken = {"access_token": "",
66 "refresh_token": "",
67 "retrievaldate": ""
68 }
69 self.spotifySearchParam = ["searchTxt"]
70 self.tokenexpired = 3600
71 self.spotArrDevices = {}
72 self.spotifyAccountUrl = "https://accounts.spotify.com/api/token"
73 self.spotifyApiUrl = "https://api.spotify.com/v1"
74 self.heartbeatCounterPoll = 1
75 self.blError = False
76
77 def onStart(self):
78 if Parameters["Mode6"] == "Debug":
79 Domoticz.Debugging(1)
80
81 for var in ['Mode1', 'Mode2', 'Mode3']:
82 if Parameters[var] == "":
83 Domoticz.Error('No client_id, client_secret and/or code is set in hardware parameters')
84 self.blError = True
85 return None
86
87 if not self.getUserVar():
88 self.blError = True
89 return None
90
91 for key, value in self.spotifyToken.items():
92 if value == '':
93 Domoticz.Log("Not all spotify token variables are available, let's get it")
94 if not self.spotAuthoriseCode():
95 self.blError = True
96 return None
97 break
98
99 self.checkDevices()
100
101 Domoticz.Heartbeat(30)
102
103 def checkDevices(self):
104 Domoticz.Log("Checking if devices exist")
105
106 if SPOTIFYDEVICES not in Devices:
107 Domoticz.Log("Spotify devices selector does not exist, creating device")
108
109 strSelectorNames = 'Off'
110 dictOptions = self.buildDeviceSelector(strSelectorNames)
111
112 Domoticz.Device(Name="devices", Unit=SPOTIFYDEVICES, Used=1, TypeName="Selector Switch", Switchtype=18,
113 Options=dictOptions, Image=8).Create()
114 else:
115 self.updateDeviceSelector()
116
117 def updateDeviceSelector(self):
118 Domoticz.Debug("Updating spotify devices selector")
119 strSelectorNames = Devices[SPOTIFYDEVICES].Options['LevelNames']
120 dictOptions = self.buildDeviceSelector(strSelectorNames)
121
122 if dictOptions != Devices[SPOTIFYDEVICES].Options:
123 Devices[SPOTIFYDEVICES].Update(nValue=Devices[SPOTIFYDEVICES].nValue, sValue=Devices[SPOTIFYDEVICES].sValue,
124 Options=dictOptions)
125
126 def buildDeviceSelector(self, strSelectorNames):
127 spotDevices = self.spotDevices()
128 Domoticz.Debug('JSON Returned from spotify listed available devices: ' + str(spotDevices))
129
130 strSelectorActions = ''
131
132 lstSelectorNames = strSelectorNames.split("|")
133
134 x = 1
135 while x < len(lstSelectorNames):
136 strSelectorActions += '|'
137 x += 1
138
139 intCounter = (len(lstSelectorNames) * 10)
140
141 for device in spotDevices['devices']:
142 if device['name'] not in lstSelectorNames:
143 strSelectorNames += '|' + device['name']
144 strSelectorActions += '|'
145 self.spotArrDevices.update({str(intCounter): device['id']})
146 intCounter += 10
147 else:
148 self.spotArrDevices.update({str(lstSelectorNames.index(device['name']) * 10): device['id']})
149
150 Domoticz.Debug('Local array listing selector level with device ids: ' + str(self.spotArrDevices))
151
152 dictOptions = {"LevelActions": strSelectorActions,
153 "LevelNames": strSelectorNames,
154 "LevelOffHidden": "false",
155 "SelectorStyle": "1"}
156
157 return dictOptions
158
159 def spotGetBearerHeader(self):
160 tokenSecElapsed = time.time() - float(self.spotifyToken['retrievaldate'])
161 if tokenSecElapsed > self.tokenexpired:
162 Domoticz.Log('Token expired, getting new one using refresh_token')
163 self.spotGetRefreshToken()
164
165 return {"Authorization": "Bearer " + self.spotifyToken['access_token']}
166
167 def spotDevices(self):
168 try:
169 url = self.spotifyApiUrl + '/me/player/devices'
170 headers = self.spotGetBearerHeader()
171
172 req = urllib.request.Request(url, headers=headers)
173 response = urllib.request.urlopen(req)
174
175 strResponse = response.read().decode('utf-8')
176 return json.loads(strResponse)
177
178 except urllib.error.URLError as err:
179 Domoticz.Error("Unkown error: code: {code}, msg: {message}".format(
180 code=str(err.code), message=str(err.args)))
181 return None
182
183 def getUserVar(self):
184 try:
185 variables = DomoticzAPI({'type': 'command', 'param': 'getuservariables'})
186
187 if variables:
188 valuestring = ""
189 missingVar = []
190 lstDomoticzVariables = list(self.spotifyToken.keys()) + self.spotifySearchParam
191 if "result" in variables:
192 for intVar in lstDomoticzVariables:
193 intVarName = Parameters["Name"] + '-' + intVar
194 try:
195 result = next((item for item in variables["result"] if item["Name"] == intVarName))
196 if intVar in self.spotifyToken:
197 self.spotifyToken[intVar] = result['Value']
198 Domoticz.Debug(str(result))
199 except:
200 missingVar.append(intVar)
201 else:
202 for intVar in lstDomoticzVariables:
203 missingVar.append(intVar)
204
205 if len(missingVar) > 0:
206 strMissingVar = ','.join(missingVar)
207 Domoticz.Log("User Variable {} does not exist. Creation requested".format(strMissingVar))
208 for variable in missingVar:
209 DomoticzAPI({"type": "command", "param": "adduservariable",
210 "vname": Parameters["Name"] + '-' + variable, "vtype": "2", "vvalue": ""})
211
212 return True
213 else:
214 raise Exception("Cannot read the uservariable holding the persistent variables")
215
216 except Exception as error:
217 Domoticz.Error(str(error))
218
219 def saveUserVar(self):
220 try:
221 for intVar in self.spotifyToken:
222 intVarName = Parameters["Name"] + '-' + intVar
223 DomoticzAPI({"type": "command", "param": "updateuservariable", "vname": intVarName, "vtype": "2",
224 "vvalue": str(self.spotifyToken[intVar])})
225 except Exception as error:
226 Domoticz.Error(str(error))
227
228 def spotGetRefreshToken(self):
229 try:
230 url = self.spotifyAccountUrl
231 headers = self.returnSpotifyBasicHeader()
232
233 data = {'grant_type': 'refresh_token',
234 'refresh_token': self.spotifyToken['refresh_token']}
235 data = urllib.parse.urlencode(data)
236
237 req = urllib.request.Request(url, data.encode('ascii'), headers)
238 response = urllib.request.urlopen(req)
239
240 strResponse = response.read().decode('utf-8')
241 Domoticz.Debug('Spotify response accestoken based on refresh: ' + str(strResponse))
242
243 jsonResponse = json.loads(strResponse)
244
245 self.saveSpotifyToken(jsonResponse)
246 except:
247 Domoticz.Error('Seems something with wrong with token response from spotify')
248
249 def returnSpotifyBasicHeader(self):
250 client_id = Parameters["Mode1"]
251 client_secret = Parameters["Mode2"]
252 login = client_id + ':' + client_secret
253 base64string = base64.b64encode(login.encode())
254 header = {'Authorization': 'Basic ' + base64string.decode('ascii')}
255 Domoticz.Debug('For basic headers using client_id: {client_id}, client_secret: {client_secret}'.format(
256 client_id=client_id, client_secret=client_secret))
257
258 return header
259
260 def spotAuthoriseCode(self):
261 try:
262 code = Parameters["Mode3"]
263 url = self.spotifyAccountUrl
264 data = {'grant_type': 'authorization_code',
265 'code': code,
266 'redirect_uri': 'http://localhost'}
267 Domoticz.Debug('Getting tokens using data: {}'.format(data))
268 data = urllib.parse.urlencode(data)
269
270 headers = self.returnSpotifyBasicHeader()
271 Domoticz.Debug('Getting tokens using header: {}'.format(headers))
272
273 try:
274 req = urllib.request.Request(url, data.encode('ascii'), headers)
275 response = urllib.request.urlopen(req)
276
277 strResponse = response.read().decode('utf-8')
278 Domoticz.Debug('Spotify tokens based on authorisation code: ' + str(strResponse))
279 jsonResponse = json.loads(strResponse)
280
281 self.saveSpotifyToken(jsonResponse)
282
283 return True
284
285 except urllib.error.HTTPError as err:
286 errmsg = "Error occured in request for getting acces_tokens from Spotify, error code: " \
287 "{code}, reason: {reason}.".format(code=err.code, reason=err.reason)
288 if err.code == 400:
289 errmsg += " Seems either client_id, client_secret or code is incorrect. " \
290 "Please note that the code received from Spotify could only be used once. " \
291 "Please get a new one from spotify."
292 Domoticz.Error(errmsg)
293
294 except Exception as error:
295 Domoticz.Error(error)
296
297 def saveSpotifyToken(self, response):
298 try:
299 for intVar in self.spotifyToken:
300 if intVar in response:
301 self.spotifyToken[intVar] = response[intVar]
302 self.spotifyToken['retrievaldate'] = time.time()
303 Domoticz.Log('Succesfully got spotify tokens, saving data in user domoticz user variables')
304 self.saveUserVar()
305 except:
306 Domoticz.Error('Seems something with wrong with token response from spotify')
307
308 def spotSearch(self, search_input, search_type):
309 url = self.spotifyApiUrl + "/search?q={search_query}&type={search_type}&market=NL&limit=10".format(
310 search_query=urllib.parse.quote(search_input), search_type=search_type)
311 Domoticz.Debug('Spotify search url: ' + str(url))
312
313 headers = self.spotGetBearerHeader()
314
315 req = urllib.request.Request(url, headers=headers)
316 response = urllib.request.urlopen(req)
317
318 jsonResponse = json.loads(response.read().decode('utf-8'))
319 foundItems = jsonResponse['{}s'.format(search_type)]['items']
320
321 Domoticz.Debug('First result of spotify search: ' + str(foundItems[0]))
322
323 rsltString = 'Found ' + search_type + ' ' + foundItems[0]['name']
324 if search_type == 'track':
325 tracks = []
326 for track in foundItems:
327 tracks.append(track['uri'])
328 returnData = {"uris": tracks}
329 else:
330 returnData = {"context_uri": foundItems[0]['uri']}
331
332 if search_type == 'album' or search_type == 'track':
333 rsltString += ' by ' + foundItems[0]['artists'][0]['name']
334
335 Domoticz.Log(rsltString)
336 return returnData
337
338 def spotPause(self):
339 try:
340 url = self.spotifyApiUrl + "/me/player/pause"
341 headers = self.spotGetBearerHeader()
342
343 req = urllib.request.Request(url, headers=headers, method='PUT')
344 response = urllib.request.urlopen(req)
345 Domoticz.Log("Succesfully paused track")
346
347 except urllib.error.HTTPError as err:
348 if err.code == 403:
349 Domoticz.Error("User non premium")
350 elif err.code == 400:
351 Domoticz.Error("Device id not found")
352 else:
353 Domoticz.Error("Unkown error, msg: " + str(err.msg))
354
355 def spotCurrent(self):
356 try:
357 url = self.spotifyApiUrl + "/me/player"
358 headers = self.spotGetBearerHeader()
359
360 req = urllib.request.Request(url, headers=headers, method='GET')
361 response = urllib.request.urlopen(req)
362
363 Domoticz.Debug("Succesfully retrieved current playing state")
364 Domoticz.Debug('Retrieved current playing state having code {}'.format(response.code))
365
366 return response
367
368 except urllib.error.HTTPError as err:
369 Domoticz.Error("Unkown error {error}, msg: {message}".format(error=err.code, message=err.msg))
370
371 def spotShuffle(self):
372 try:
373 url = self.spotifyApiUrl + "/me/player/shuffle?state=true"
374 headers = self.spotGetBearerHeader()
375
376 req = urllib.request.Request(url, headers=headers, method='PUT')
377 response = urllib.request.urlopen(req)
378
379 Domoticz.Debug("Succesfully turned on shuffle")
380
381 return response
382
383 except urllib.error.HTTPError as err:
384 Domoticz.Error("Unknown error {error}, msg: {message}".format(error=err.code, message=err.msg))
385
386 def spotPlay(self, input, deviceLvl):
387 try:
388
389 if deviceLvl not in self.spotArrDevices:
390 self.updateDeviceSelector()
391 if deviceLvl not in self.spotArrDevices:
392 raise urllib.error.HTTPError(url='', msg='', hdrs='', fp='', code=404)
393
394 device = self.spotArrDevices[deviceLvl]
395 url = self.spotifyApiUrl + "/me/player/play?device_id=" + device
396 headers = self.spotGetBearerHeader()
397
398 data = json.dumps(input).encode('utf8')
399
400 req = urllib.request.Request(url, headers=headers, data=data, method='PUT')
401 response = urllib.request.urlopen(req)
402 self.updateDomoticzDevice(SPOTIFYDEVICES, 1, str(deviceLvl))
403 Domoticz.Log("Succesfully started playback")
404
405 except urllib.error.HTTPError as err:
406 if err.code == 403:
407 Domoticz.Error("Error playback, you need to be premium member")
408 elif err.code == 400:
409 Domoticz.Error("Error playback, right scope requested?")
410 elif err.code == 404:
411 Domoticz.Error("Device not found, went offline?")
412 else:
413 Domoticz.Error("Unkown error, msg: " + str(err.msg))
414
415 def onHeartbeat(self):
416 if not self.blError:
417 if Parameters["Mode5"] != "0" and self.heartbeatCounterPoll == int(Parameters["Mode5"]):
418 Domoticz.Debug('Polling')
419 response = self.spotCurrent()
420 if response.code == 204 and Devices[SPOTIFYDEVICES].sValue != '0':
421 self.updateDomoticzDevice(SPOTIFYDEVICES, 0, "0")
422 elif response.code == 200:
423 resultJson = json.loads(response.read().decode('utf-8'))
424
425 try:
426 if not resultJson['is_playing']:
427 self.updateDomoticzDevice(SPOTIFYDEVICES, 0, "0")
428 else:
429 lstSelectorLevel = catchDeviceSelectorLvl(resultJson['device']['name'])
430 self.updateDomoticzDevice(SPOTIFYDEVICES, 1, lstSelectorLevel)
431
432 except ValueError:
433 try:
434 Domoticz.Debug(
435 'Playing on device {device_name} which was unkown, trying to update domoticz device to '
436 'correctly update playback information.'.format(
437 device_name=str(resultJson['device']['name'])))
438 self.updateDeviceSelector()
439 lstSelectorLevel = catchDeviceSelectorLvl(resultJson['device']['name'])
440 self.updateDomoticzDevice(SPOTIFYDEVICES, 1, lstSelectorLevel)
441 except ValueError:
442 Domoticz.Error("Current playing device not found by domoticz, cant update")
443
444 except UnicodeEncodeError:
445 # jsonresult is empty, meaning nothing is playing
446 self.updateDomoticzDevice(SPOTIFYDEVICES, 0, "0")
447
448 self.heartbeatCounterPoll = 1
449 else:
450 self.heartbeatCounterPoll += 1
451
452 return True
453
454 def updateDomoticzDevice(self, idx, nValue, sValue):
455 if Devices[idx].sValue != sValue or Devices[idx].nValue != nValue:
456 Domoticz.Debug('Update for device {device_index} with nValue {device_value} and sValue {value_type}'.format(
457 device_index=idx, device_value=nValue, value_type=sValue))
458 Devices[idx].Update(nValue, sValue)
459
460 def onCommand(self, Unit, Command, Level, Hue):
461 Domoticz.Debug(
462 "Spotify: onCommand called for Unit " + str(Unit) + ": Parameter '" + str(Command) + "', Level: " + str(
463 Level))
464 Domoticz.Debug(
465 "nValue={device_value}, sValue={value_type}".format(
466 device_value=str(Devices[SPOTIFYDEVICES].nValue), value_type=str(Devices[SPOTIFYDEVICES].sValue)))
467
468 if Unit == SPOTIFYDEVICES:
469 try:
470 variables = DomoticzAPI({'type': 'command', 'param': 'getuservariables'})
471 except Exception as error:
472 Domoticz.Error(error)
473
474 if Level == 0:
475 # Spotify turned off
476 self.updateDomoticzDevice(Unit, 0, str(Level))
477 self.spotPause()
478
479 else:
480 searchVariable = next(
481 (item for item in variables["result"] if item["Name"] == Parameters["Name"] + '-searchTxt'))
482 searchString = searchVariable['Value']
483 Domoticz.Log('Looking for ' + searchString)
484 searchResult = None
485
486 if searchString != "":
487 for type in ['artist', 'track', 'playlist', 'album']:
488 if type in searchString:
489 strippedSearch = searchString.replace(type, '').lstrip()
490 Domoticz.Debug('Search type: ' + type)
491 Domoticz.Debug('Search string: ' + strippedSearch)
492 searchResult = self.spotSearch(strippedSearch, type)
493 break
494
495 if not searchResult:
496 Domoticz.Error(
497 "No correct type found in search string, use either artist, track, playlist or album")
498 else:
499 self.spotPlay(searchResult, str(Level))
500 self.spotShuffle()
501
502_plugin = BasePlugin()
503
504
505def onStart():
506 _plugin.onStart()
507
508
509def onHeartbeat():
510 _plugin.onHeartbeat()
511
512
513def onCommand(Unit, Command, Level, Hue):
514 _plugin.onCommand(Unit, Command, Level, Hue)
515
516
517#############################################################################
518# Domoticz helper functions #
519#############################################################################
520
521def catchDeviceSelectorLvl(name):
522 lstSelectorNames = Devices[SPOTIFYDEVICES].Options['LevelNames'].split('|')
523 lstSelectorLevel = str(lstSelectorNames.index(name) * 10)
524 return lstSelectorLevel
525
526
527def DomoticzAPI(APICall):
528 resultJson = None
529 url = "http://{}:{}/json.htm?{}".format(Parameters["Address"], Parameters["Port"],
530 urllib.parse.urlencode(APICall, safe="&="))
531 Domoticz.Debug("Calling domoticz API: {}".format(url))
532 try:
533 req = urllib.request.Request(url)
534 if Parameters["Username"] != "":
535 Domoticz.Debug("Add authentification for user: {}".format(Parameters["Username"]))
536 credentials = ('{username}:{password}'.format(
537 username=Parameters["Username"], password=Parameters["Password"]))
538 encoded_credentials = base64.b64encode(credentials.encode('ascii'))
539 req.add_header('Authorization', 'Basic {}'.format(encoded_credentials.decode("ascii")))
540 else:
541 if Parameters["Mode4"] != "":
542 Domoticz.Debug("Add authentification using encoded credentials: {}".format(Parameters["Mode4"]))
543 encoded_credentials = Parameters["Mode4"]
544 req.add_header('Authorization', 'Basic {}'.format(encoded_credentials))
545
546 response = urllib.request.urlopen(req)
547
548 if response.status == 200:
549 resultJson = json.loads(response.read().decode('utf-8'))
550 if resultJson["status"] != "OK":
551 raise Exception("Domoticz API returned an error: status = {}".format(resultJson["status"]))
552 else:
553 raise Exception("Domoticz API: http error = {}".format(response.status))
554 except:
555 raise Exception("Error calling '{}'".format(url))
556
557 return resultJson
558
559
560#############################################################################
561# Local test helpers #
562#############################################################################
563
564if local:
565 onStart()
566
567 # onHeartbeat()
568
569 # onCommand(1,'Off',0,'')
570 onCommand(1, 'Set level', 20, '')