· 7 years ago · Mar 05, 2018, 08:20 PM
1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3#
4# This software has been designed to work with Nibe Uplink API https://api.nibeuplink.com/Home/About
5# It's aimed for Domoticz running on Raspberry Pi
6#
7# This software licensed under the GNU General Public License v3.0
8
9import json
10import os
11import plistlib
12import requests
13from urllib import urlencode
14import sys, getopt
15from datetime import datetime
16import time
17import logging
18
19# Global (module) namespace variables
20redirect_uri = "http://showquerystring.000webhostapp.com/index.php" # Don't alter this or anything else!
21baseEndPointUrl = 'https://api.nibeuplink.com'
22authorizeEndpointUrl = baseEndPointUrl + '/oauth/authorize'
23tokenEndpointUrl = baseEndPointUrl + '/oauth/token'
24cfgFile = sys.path[0] + '/config.json'
25configChanged = False
26newDevicesList = [] # This is a python list
27PROGRAMNAME = 'Domoticz RPC for NIBE Uplink'
28VERSION = '2.0.0'
29MSG_ERROR = 'Error'
30MSG_INFO = 'Info'
31MSG_EXEC = 'Exec info'
32tty = True if os.isatty(sys.stdin.fileno()) else False
33isDebug = False
34isVerbose = False
35
36def query_yes_no(question, default="no"):
37 """
38 Ask a yes/no question via raw_input() and return their answer.
39
40 "question" is a string that is presented to the user.
41 "default" is the presumed answer if the user just hits <Enter>.
42 It must be "yes" (the default), "no" or None (meaning
43 an answer is required of the user).
44
45 The "answer" return value is True for "yes" or False for "no".
46 """
47 valid = {"yes": True, "y": True, "ye": True,
48 "no": False, "n": False}
49 if default is None:
50 prompt = " [y/n] "
51 elif default == "yes":
52 prompt = " [Y/n] "
53 elif default == "no":
54 prompt = " [y/N] "
55 else:
56 raise ValueError("invalid default answer: '%s'" % default)
57
58 while True:
59 sys.stdout.write(question + prompt)
60 choice = raw_input().lower()
61 if default is not None and choice == '':
62 return valid[default]
63 elif choice in valid:
64 return valid[choice]
65 else:
66 sys.stdout.write("Please respond with 'yes' or 'no' "
67 "(or 'y' or 'n').\n")
68
69def connected_to_internet(url='http://www.google.com/', timeout=5):
70 try:
71 _ = requests.head(url, timeout=timeout)
72 return True
73 except requests.ConnectionError:
74 print('No internet connection available.')
75 return False
76
77def default_input(message, defaultVal):
78 if defaultVal:
79 return raw_input( "%s [%s]:" % (message, defaultVal) ) or defaultVal
80 else:
81 return raw_input( "%s :" % (message) )
82
83def create_config():
84 global cfg;cfg = {}
85
86 import socket
87 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
88 s.connect(('8.8.8.8', 0)) # connecting to a UDP address doesn't send packets
89 local_ip_address = s.getsockname()[0]
90 cfg['domoticz'] = {}
91 cfg['domoticz']['hostName'] = default_input('Domoticz web service IP address (or host name)', local_ip_address)
92 cfg['domoticz']['portNumber'] = default_input('Domoticz web service port number', 8080)
93 cfg['domoticz']['protocol'] = ''
94 while cfg['domoticz']['protocol'] <> 'http' and cfg['domoticz']['protocol'] <> 'https':
95 cfg['domoticz']['protocol'] = default_input('Domoticz web service communication protocol (http or https)', 'http')
96 if cfg['domoticz']['protocol'] <> 'http' and cfg['domoticz']['protocol'] <> 'https':
97 print 'Invalid value given for Domoticz web service communication protocol. It must be \'http\' or \'https\''
98 cfg['domoticz']['httpBasicAuth'] = {}
99 cfg['domoticz']['httpBasicAuth']['userName'] = \
100 default_input('Domoticz web service user name (leave blank if no username is needed)', '')
101 cfg['domoticz']['httpBasicAuth']['passWord'] = \
102 default_input('Domoticz web service password (leave blank if no passord is needed)', '')
103 cfg['domoticz']['devices'] = {}
104 cfg['domoticz']['devices']['device'] = []
105 cfg['system'] = {}
106 tmpdir = '/var/tmp' if os.path.isdir('/var/tmp') else '/tmp'
107 cfg['system']['tmpFolder'] = '/xxxx/yyyy'
108 while not os.path.isdir(cfg['system']['tmpFolder']):
109 cfg['system']['tmpFolder'] = default_input('Directory for app logging and storing access tokens', tmpdir)
110 if not os.path.isdir(cfg['system']['tmpFolder']):
111 print 'That isn\'t a valid directory name on Your system! Please try again.'
112 cfg['oAuth2ClientCredentials'] = {}
113 cfg['oAuth2ClientCredentials']['authorizationCode'] = 'xxxx'
114 cfg['oAuth2ClientCredentials']['clientId'] = ''
115 while len(cfg['oAuth2ClientCredentials']['clientId']) <> 32:
116 cfg['oAuth2ClientCredentials']['clientId'] = default_input('Your Nibe UpLink Application\'s Identifier', '')
117 if len(cfg['oAuth2ClientCredentials']['clientId']) <> 32:
118 print 'That doesn\'t look like a valid Identifier. Have a look at https://api.nibeuplink.com/Applications'
119 print 'Please try again'
120 cfg['oAuth2ClientCredentials']['clientSecret'] = ''
121 while len(cfg['oAuth2ClientCredentials']['clientSecret']) <> 44:
122 cfg['oAuth2ClientCredentials']['clientSecret'] = default_input('Your Nibe UpLink Application\'s secret', '')
123 if len(cfg['oAuth2ClientCredentials']['clientSecret']) <> 44:
124 print 'That doesn\'t look like a valid secret. Have a look at https://api.nibeuplink.com/Applications'
125 print 'Please try again'
126 # Do we already have a hardware device named 'NIBEUplink' in Domoticz?
127 payload = dict([('type', 'hardware')])
128 r = domoticzAPI(payload)
129 hwIdx = '0'
130 HWNAME = 'NIBEUplink'
131 if 'result' in r.keys():
132 for hw in r['result']:
133 if hw['Name'] == HWNAME and hw['Enabled'] == 'true':
134 hwIdx = hw['idx']
135 break
136 if hwIdx <> '0':
137 cfg['domoticz']['virtualHwDeviceIdx'] = int(hwIdx)
138 else:
139 # Create a new Hardware Device. We wants it, we needs it. Must have the precious. They stole it from us!
140 payload = dict([('type', 'command'), ('param', 'addhardware'), ('htype', 15), \
141 ('port', 1), ('name', HWNAME), ('enabled', 'true'), ('datatimeout', 0)])
142 r = domoticzAPI(payload)
143 # Now go fishing for the hardware device Idx
144 payload = dict([('type', 'hardware')])
145 r = domoticzAPI(payload)
146 for hw in r['result']:
147 if hw['Name'] == HWNAME and hw['Enabled'] == 'true':
148 hwIdx = hw['idx']
149 break
150 if hwIdx <> '0':
151 cfg['domoticz']['virtualHwDeviceIdx'] = int(hwIdx)
152 else:
153 print 'Can not find the newly created virtual hardware device.'
154 sys.exit(0)
155
156 # ROOM PLAN
157 # Do we already have a room plane named 'NIBEUplink' in Domoticz?
158 payload = dict([('type', 'plans')])
159 r = domoticzAPI(payload)
160 roomIdx = '0'
161 ROOMPLAN = 'NIBEUplink'
162 if 'result' in r.keys(): # Can happen if there are no room plans
163 for room in r['result']:
164 if room['Name'] == ROOMPLAN:
165 roomIdx = room['idx']
166 break
167 if roomIdx <> '0':
168 cfg['domoticz']['roomPlan'] = int(roomIdx)
169 else:
170 # Create a new Room Plan
171 payload = dict([('type', 'command'), ('param', 'addplan'), ('name', ROOMPLAN)])
172 r = domoticzAPI(payload)
173 # Now go fishing for the room plan Idx
174 payload = dict([('type', 'plans')])
175 r = domoticzAPI(payload)
176 for room in r['result']:
177 if room['Name'] == ROOMPLAN:
178 roomIdx = room['idx']
179 break
180 if roomIdx <> '0':
181 cfg['domoticz']['roomPlan'] = int(roomIdx)
182 else:
183 print 'Can not find the newly created room plan.'
184 sys.exit(0)
185 with open(cfgFile, 'w') as outfile:
186 json.dump(cfg, outfile, indent=2, sort_keys=True, separators=(',', ':'))
187 return cfg
188
189def load_config():
190 try:
191 with open(cfgFile) as json_data_file:
192 cfg = json.load(json_data_file)
193 except IOError:
194 # Create a new config file
195 if tty:
196 cfg = create_config()
197 else:
198 sys.exit(0)
199 except:
200 logMessage = 'Can not open the config file ' + cfgFile
201 print logMessage, sys.exc_info()[0]
202 sys.exit(0)
203 return cfg
204
205def domoticzAPI(payload):
206 try:
207 r = requests.get(cfg['domoticz']['protocol'] + '://' + cfg['domoticz']['hostName'] + ':' + \
208 str(cfg['domoticz']['portNumber']) + '/json.htm', \
209 verify=False, \
210 auth=(cfg['domoticz']['httpBasicAuth']['userName'], cfg['domoticz']['httpBasicAuth']['passWord']), \
211 params=payload)
212 except:
213 print('Can not open domoticz URL: \'' + cfg['domoticz']['protocol'] + '://' + cfg['domoticz']['hostName'] + ':' + \
214 str(cfg['domoticz']['portNumber']) + '/json.htm\'', sys.exc_info()[0])
215 sys.exit(0)
216 if r.status_code <> 200:
217 print 'Unexpected status code from Domoticz: ' + str(r.status_code)
218 sys.exit(0)
219 try:
220 rJsonDecoded = r.json()
221 except:
222 print('Can\'t Json decode response from Domoticz.', sys.exc_info()[0])
223 sys.exit(0)
224 if rJsonDecoded['status'] <> 'OK':
225 print 'Unexpected response from Domoticz: ' + rJsonDecoded['status']
226 sys.exit(0)
227 return rJsonDecoded
228
229def logToDomoticz(messageType, logMessage):
230 payload = dict([('type', 'command'), ('param', 'addlogmessage'), \
231 ('message', '(' + messageType+ ') ' + os.path.basename(sys.argv[0]) + ': ' + logMessage)])
232 r = domoticzAPI(payload)
233 return r
234
235def truncate(x, d):
236 return int(x*(10.0**d))/(10.0**d)
237
238def findTruncatedValue(rawValue, displayValue):
239 # Handle a case like this:
240 # "displayValue": "1339.3kWh", "rawValue": 133931
241 # "displayValue": "0.314kWh", "rawValue": 314931
242 # "displayValue": "-245DM", "rawValue": -2456
243 # "displayValue": "1341.6kWh", "rawValue": 134157
244
245 xTest = ''.join(ch for ch in displayValue if ch.isdigit() or ch == '.') # Now "1339.3"
246 if not xTest.lstrip('-').replace('.','',1).isdigit():
247 return ''
248 displayValueNum = float(xTest)
249 rawValue = abs(rawValue) # Now a positive number
250 if displayValueNum == rawValue or displayValueNum == 0 or rawValue == 0: return 1
251 # displayValueNum is now a number and it's not 0
252 if displayValueNum >= 1:
253 if int(rawValue / 10) == int(displayValueNum):
254 return 0.1
255 elif int(rawValue / 100) == int(displayValueNum):
256 return 0.01
257 elif int(rawValue / 1000) == int(displayValueNum):
258 return 0.001
259 else:
260 numDecimalPlaces = len(str(displayValueNum).split('.')[1]) # How many decimal places is there in displayValueNum ?
261 rawValueFloat = float(rawValue)
262 if truncate(rawValueFloat / 10, numDecimalPlaces) == displayValueNum:
263 return 0.1
264 elif truncate(rawValueFloat / 100, numDecimalPlaces) == displayValueNum:
265 return 0.01
266 elif truncate(rawValueFloat / 1000, numDecimalPlaces) == displayValueNum:
267 return 0.001
268 elif truncate(rawValueFloat / 10000, numDecimalPlaces) == displayValueNum:
269 return 0.0001
270 elif truncate(rawValueFloat / 100000, numDecimalPlaces) == displayValueNum:
271 return 0.00001
272 elif truncate(rawValueFloat / 1000000, numDecimalPlaces) == displayValueNum:
273 return 0.000001
274 return ''
275
276def redefineDomoDevice(nibeSystem, nibeResource, nibeCategoryName):
277 global newDevicesList
278 global configChanged
279 for c in cfg['domoticz']['devices']['device']:
280 if c['nibeSystemId'] == nibeSystem['systemId'] and c['nibeParameterId'] == nibeResource['parameterId']:
281 devNameLong = ((nibeSystem['productName'] + ' ' + nibeResource['title'] + ' ' + nibeResource['designation']).title()).strip()
282 if not c['enabled']:
283 devNameLong += ' (Currently not enabled)'
284 elif c['domoticzIdx'] > 0:
285 devNameLong += ' (Enabled and current Domoticz idx: ' + str(c['domoticzIdx']) + ')'
286 if query_yes_no('Redefine : ' + devNameLong, 'no'):
287 configChanged = True # Do not append to newDevicesList
288 else:
289 newDevicesList.append(c)
290
291def getDomoDevice(nibeSystem, nibeResource, nibeCategoryName):
292 unsupportedDevices = [ 0, 47214, 48745]
293 if nibeResource['parameterId'] in unsupportedDevices:
294 #print str(nibeResource['parameterId']) + ' is not a supported device'
295 return {}
296 #print str(nibeResource['nibeResourceId']) + ' is a supported device'
297 for c in cfg['domoticz']['devices']['device']:
298 if c['nibeSystemId'] == nibeSystem['systemId'] and c['nibeParameterId'] == nibeResource['parameterId']:
299 #print 'Found : ' + c['nibeTitle']
300 return c
301
302 if not tty:
303 return {}
304 nibeResource['title'] = nibeResource['title'].strip()
305 devNameLong = ((nibeSystem['productName'] + ' ' + nibeResource['title'] + ' ' + nibeResource['designation']).title()).strip()
306
307 nibeResource['title'] = nibeResource['title'].replace('compressor', 'compr')
308 nibeResource['title'] = nibeResource['title'].replace('compr.', 'compr')
309 if len(nibeResource['title']) > 17:
310 nibeResource['title'] = nibeResource['title'].replace('operating', 'op')
311 if nibeResource['unit'] == 'kWh':
312 # Counter incremental has very little space in the title
313 if len(nibeResource['title']) > 17:
314 nibeResource['title'] = nibeResource['title'].replace('factor', 'fc')
315 if len(nibeResource['title']) > 21:
316 nibeResource['title'] = nibeResource['title'][0:21].strip()
317 elif nibeResource['unit'] == 'A':
318 # Current meter, Add phase number to name
319 nibeResource['title'] = nibeResource['title'] + ' (Phase-' + nibeResource['designation'][-1:] + ')'
320 else:
321 if len(nibeResource['title']) > 36:
322 nibeResource['title'] = nibeResource['title'][0:36].strip()
323 # Avoid dulicate device names for 'heat medium flow'
324 if nibeResource['title'] == 'heat medium flow': nibeResource['title'] = nibeResource['title'] + ' ' + nibeResource['designation']
325 productNameShort = nibeSystem['productName'][5:] if nibeSystem['productName'][0:5] == 'NIBE ' else nibeSystem['productName']
326 devName = (productNameShort + ' ' + nibeResource['title']).title() # Capitalize the first letter of each word
327 createDev = default_input('\n\nFound a new Nibe Uplink resource: \'' + devNameLong + '\'\nShall I enable it and create a Domoticz Virtual Device for it? : Y/N', 'N')
328 createDev = (createDev.upper() == 'Y' or createDev.upper() == 'YES') and True or False
329
330 entry = {}
331 entry['enabled'] = False
332 entry['domoticzIdx'] = 0
333
334 if (nibeResource['displayValue'] == 'no' and nibeResource['rawValue'] == 0) \
335 or (nibeResource['displayValue'] == 'yes' and nibeResource['rawValue'] == 1):
336 entry['domoticzSensorType'] = 6 # Switch
337 elif nibeResource['parameterId'] in {9999999,47412} :# Hard coded for alarm
338 entry['domoticzSensorType'] = 7 # Alert
339 elif nibeResource['parameterId'] == 43416: # Hard coded for compressor starts, nibeResource['unit'] = ''
340 entry['domoticzSensorType'] = 113 # Counter sensor (Ordinary)
341 elif nibeResource['parameterId'] == 10069: # Hard coded for Smart Price Adaption
342 entry['domoticzSensorType'] = 1004 # Custom sensor
343 elif nibeResource['unit'] == '%':
344 entry['domoticzSensorType'] = 2 # Percentage
345 elif unicode(nibeResource['unit']) == u'\xb0'+'C':
346 entry['domoticzSensorType'] = 80 # Temp
347 elif nibeResource['unit'] == 'h' or nibeResource['unit'] == 's':
348 entry['domoticzSensorType'] = 113 # Counter sensor (Ordinary)
349 elif nibeResource['unit'] == 'Hz':
350 entry['domoticzSensorType'] = 1004 # Custom sensor
351 elif nibeResource['unit'] == 'kWh':
352 entry['domoticzSensorType'] = 113 # Counter sensor (Ordinary)
353 elif nibeResource['unit'] == 'kW':
354 entry['domoticzSensorType'] = 248 # Usage Electric
355 elif nibeResource['unit'] == 'A':
356 entry['domoticzSensorType'] = 19 # (Ampere 1-Phase)
357 elif nibeResource['unit'] == 'DM': # Degree minutes
358 entry['domoticzSensorType'] = 1004 # Custom sensor
359 elif nibeResource['parameterId'] in {40050,43124} : # Hard coded for some unit-less devices
360 entry['domoticzSensorType'] = 1004 # Custom sensor
361 elif nibeResource['unit'] == '':
362 entry['domoticzSensorType'] = 5 # Text
363 else:
364 print 'Unknown Domoticz type: \'' + unicode(nibeResource['unit']) + '\''
365 entry['domoticzSensorType'] = 0
366
367 if createDev and entry['domoticzSensorType'] > 0:
368 # Create a Virtual Device
369 payload = dict([('type', 'createvirtualsensor'), ('idx', cfg['domoticz']['virtualHwDeviceIdx']), \
370 ('sensorname', devName), ('sensortype', entry['domoticzSensorType'])])
371 if entry['domoticzSensorType'] == 1004:
372 payload['sensoroptions'] = '1;' + nibeResource['unit']
373 r = domoticzAPI(payload)
374 # Now go fishing for the newly created device idx
375 payload = dict([('type', 'devices')])
376 r = domoticzAPI(payload)
377 devIdx = 0
378 for dev in reversed(r['result']):
379 if dev['Name'] == devName and dev['HardwareID'] == cfg['domoticz']['virtualHwDeviceIdx']:
380 devIdx = dev['idx']
381 break
382 if devIdx <> '0':
383 entry['domoticzIdx'] = int(devIdx)
384 entry['enabled'] = True
385 print 'Created Domoticz virtual device (idx) : ' + str(devIdx)
386 else:
387 print 'Error: Can not find the newly created virtual device.'
388 sys.exit(0)
389 # Add the device to the Domoticz room plan
390 payload = dict([('type', 'command'), ('param', 'addplanactivedevice'), ('idx', cfg['domoticz']['roomPlan']), \
391 ('activetype', 0), ('activeidx', devIdx)])
392 r = domoticzAPI(payload)
393
394 entry['nibeSystemId'] = nibeSystem['systemId']
395 entry['nibeParameterId'] = nibeResource['parameterId']
396 entry['nibeTitle'] = nibeResource['title']
397 entry['nibeDesignation'] = nibeResource['designation']
398 entry['nibeCategoryName'] = nibeCategoryName.strip()
399
400 if nibeResource['parameterId'] == 43084: # This guy may be 0 for the moment making it hard to guess the factor
401 entry['valueFactor'] = 0.01
402 elif nibeResource['parameterId'] == 40016: # Brine out should have 0.1
403 entry['valueFactor'] = 0.1
404 elif nibeResource['parameterId'] == 40079: # Current should have 0.1
405 entry['valueFactor'] = 0.1
406 elif nibeResource['parameterId'] == 40081: # Current should have 0.1
407 entry['valueFactor'] = 0.1
408 elif nibeResource['parameterId'] == 40083: # Current should have 0.1
409 entry['valueFactor'] = 0.1
410 elif nibeResource['displayValue'] == '---':
411 entry['valueFactor'] = 1
412 elif nibeResource['displayValue'] == 'no' or nibeResource['displayValue'] == 'yes' or \
413 nibeResource['rawValue'] == -32768:
414 entry['valueFactor'] = 0
415 elif (nibeResource['rawValue'] == 0) and ('0.000' in nibeResource['displayValue']):
416 entry['valueFactor'] = 0.001
417 elif (nibeResource['rawValue'] == 0) and ('0.00' in nibeResource['displayValue']):
418 entry['valueFactor'] = 0.01
419 elif (nibeResource['rawValue'] == 0) and ('0.0' in nibeResource['displayValue']):
420 entry['valueFactor'] = 0.1 # This may not always be correct
421 elif (nibeResource['rawValue'] == 0):
422 entry['valueFactor'] = 1
423 elif str(nibeResource['rawValue'] * 1000) in nibeResource['displayValue']:
424 entry['valueFactor'] = 1000
425 elif str(nibeResource['rawValue'] * 100) in nibeResource['displayValue']:
426 entry['valueFactor'] = 100
427 elif str(nibeResource['rawValue'] * 10) in nibeResource['displayValue']:
428 entry['valueFactor'] = 10
429 elif str(nibeResource['rawValue']) in nibeResource['displayValue']:
430 entry['valueFactor'] = 1
431 elif str(nibeResource['rawValue'] / 10) in nibeResource['displayValue']:
432 entry['valueFactor'] = 0.1
433 elif str(nibeResource['rawValue'] / 100) in nibeResource['displayValue']:
434 entry['valueFactor'] = 0.01
435 elif findTruncatedValue(nibeResource['rawValue'], nibeResource['displayValue']) <> '':
436 entry['valueFactor'] = findTruncatedValue(nibeResource['rawValue'], nibeResource['displayValue'])
437 else:
438 entry['valueFactor'] = 1
439
440 # Change the Domoticz device if it was just created above
441 # We put this here because now we can access the entry['valueFactor'] now
442 if createDev and entry['domoticzSensorType'] > 0:
443 # When there is a counter created, there is a possibility to change the units and set the offset value
444 domoSurpriseFc = 1000 if nibeResource['unit'] == 'kWh' else 1
445 if entry['domoticzSensorType'] == 113:
446 payload = dict([('type', 'setused'), ('idx', devIdx), ('description', 'Virtual sensor device for ' + devNameLong), \
447 ('switchtype', 0 if nibeResource['unit'] == 'kWh' else 3), \
448 ('addjvalue', nibeResource['rawValue']*entry['valueFactor'] * -1), \
449 ('used', 'true'), ('name', devName), \
450 ('options', 'VmFsdWVRdWFudGl0eSUzQVRpbWUlM0JWYWx1ZVVuaXRzJTNBaCUzQg==' if nibeResource['unit'] == 'h' else '')])
451 r = domoticzAPI(payload)
452 # The options sent above is the string 'ValueQuantity:Time;ValueUnits:h;' that has been URL encoded + Base64 encoded
453 cfg['domoticz']['devices']['device'].append(entry)
454 with open(cfgFile, 'w') as outfile:
455 json.dump(cfg, outfile, indent=2, sort_keys=True, separators=(',', ':'))
456 configChanged = True
457
458 return entry
459
460def updateDomoDevice(domoDevice, nibeResource):
461 if not domoDevice['enabled']:
462 return
463 # Only update if the new value differs from the device value
464 # or if the device has not been updated for a while
465 payload = dict([('type', 'devices'), ('rid', domoDevice['domoticzIdx'])])
466 r = domoticzAPI(payload)
467
468 # Domoticz has another surprise going on when it comes to counters ... Energy (kWh) Gas (m3) Water (m3)
469 # The counter will be treated with the divider which is defined in the parameters in the application settings.
470 # For example if the counter is set to "Water" and the value is passed as liters, the divider must set to 1000
471 # (as the unit is m3).
472 # It should be used both when reading and writing values
473 domoSurpriseFc = 1000 if nibeResource['unit'] == 'kWh' else 1
474
475 # TODO check more cases
476
477 #print r['result'][0]['Data'] #data
478 #print r['result'][0]['LastUpdate']
479
480 if not 'result' in r.keys():
481 errMess = 'Failure getting data for domoticz device idx: ' + str(domoDevice['domoticzIdx'])
482 print errMess
483 logToDomoticz(MSG_ERROR, errMess)
484 return
485
486 xTest = ''.join(ch for ch in r['result'][0]['Data'] if ch.isdigit() or ch == '.' or ch == '-') # Now "-1339.3"
487 if xTest.lstrip('-').replace('.','',1).isdigit():
488 domoValueNum = float(xTest) * domoSurpriseFc # This contains the Domoticz device's value as a number
489 else:
490 domoValueNum = 0
491
492 # Now, looking for a reason to update the sensor
493
494 # Does the Domotic's sensor need an update in order not to time out?
495 sensorTimedOut = False
496 if 'HaveTimeout' in r['result'][0]:
497 if (r['result'][0]['HaveTimeout'] and ((datetime.now() - datetime.strptime(r['result'][0]['LastUpdate'], '%Y-%m-%d %H:%M:%S')).seconds >= 3000)):
498 sensorTimedOut = True
499
500 # Does the value reported from Nibe differ from the Domoticz device's value?
501 valueChanged = False
502 if domoDevice['domoticzSensorType'] == 7: # Handling the ALERT Device type
503 domoCompareValue = r['result'][0]['Level']
504 testValue = r['result'][0]['Data']
505 if testValue == 'Everything seems to be fine':
506 domoCompareValue = '9' # Old version used 'Everything seems to be fine', it's to long to fit. This test can be removed in next version.
507 nibeCompareValue = 4 if nibeResource['rawValue'] <> 0 else 1
508 elif domoDevice['domoticzSensorType'] == 6: # Handling the SWITCH Device type
509 domoCompareValue = r['result'][0]['Status']
510 nibeCompareValue = 'On' if nibeResource['rawValue'] == 1 else 'Off'
511 elif domoDevice['domoticzSensorType'] == 113:# These guys use an offset value that we need to deal with
512 # Comparing floats in Python is not as simple as it sounds, using str() as a workaround
513 if nibeResource['unit'] == 'kWh':
514 nibeCompareValue = str(float(nibeResource['rawValue']*domoDevice['valueFactor']*domoSurpriseFc))
515 domoCompareValue = str((domoValueNum / domoSurpriseFc - r['result'][0]['AddjValue'])*domoSurpriseFc)
516 else:
517 # Don't use fractionals
518 nibeCompareValue = int(nibeResource['rawValue']*domoDevice['valueFactor'])
519 domoCompareValue = int(domoValueNum / domoSurpriseFc - r['result'][0]['AddjValue'])
520 elif domoDevice['domoticzSensorType'] == 248 and nibeResource['unit'] == 'kW': # Usage Electric
521 nibeCompareValue = str(float(nibeResource['rawValue']*domoDevice['valueFactor']*1000))
522 domoCompareValue = str(domoValueNum)
523 else:
524 nibeCompareValue = str(float(nibeResource['rawValue']*domoDevice['valueFactor']))
525 domoCompareValue = str(domoValueNum)
526
527 if nibeCompareValue <> domoCompareValue: valueChanged = True
528
529 if isDebug:
530 print r['result'][0]['Name']
531 print 'N: ' + str(nibeCompareValue)
532 print 'D: ' + str(domoCompareValue)
533 print
534 elif isVerbose and (valueChanged or sensorTimedOut):
535 sayThis = 'Updating Domoticz device \'' + r['result'][0]['Name'] + '\' idx: ' + str(domoDevice['domoticzIdx']) + ' due to:'
536 if valueChanged: sayThis += ' <value changed>. New value is: ' + str(nibeCompareValue) + \
537 '. Old value was: ' + str(domoCompareValue) + '.'
538 if sensorTimedOut: sayThis += ' <time condition>'
539 print sayThis
540
541 if not valueChanged and not sensorTimedOut:
542 return
543
544 if domoDevice['domoticzSensorType'] == 7: # Handling the ALERT Device type
545 payload = dict([('type', 'command'), ('param', 'udevice'), ('idx', domoDevice['domoticzIdx']), \
546 ('nvalue', nibeCompareValue), ('svalue', 'OK' if nibeCompareValue == 1 else 'Alert!')])
547 elif domoDevice['domoticzSensorType'] == 6: # Handling the SWITCH Device type
548 payload = dict([('type', 'command'), ('param', 'switchlight'), ('idx', domoDevice['domoticzIdx']), \
549 ('switchcmd', nibeCompareValue)])
550 else: # All other sensor types
551 payload = dict([('type', 'command'), ('param', 'udevice'), ('idx', domoDevice['domoticzIdx']), \
552 ('nvalue', 0), ('svalue', nibeCompareValue)])
553 r = domoticzAPI(payload)
554
555# Retrieve the authorization code
556# It will only run if the variable cfg['oAuth2ClientCredentials']['authorizationCode'] has not been set
557def retrieve_authorization_code():
558 authorization_code_req = {
559 "response_type": 'code',
560 "client_id": cfg['oAuth2ClientCredentials']['clientId'],
561 "state": 'xyz',
562 "access_type": 'offline',
563 "redirect_uri": redirect_uri,
564 "scope": (r'READSYSTEM' +
565 r' WRITESYSTEM')
566 }
567
568 r = requests.get(authorizeEndpointUrl + "?%s" % urlencode(authorization_code_req),
569 allow_redirects=False)
570 print '\nAuthorization Code retrieval\n==========================\nCopy the URL below and paste into the adress bar of a web browser. After granting access on Nibe Uplink, Your browser will show You the Authorization Code. Then enter that code below .\n'
571 url = r.headers.get('location')
572 print baseEndPointUrl + url + '\n'
573
574 while len(cfg['oAuth2ClientCredentials']['authorizationCode']) <> 401:
575 cfg['oAuth2ClientCredentials']['authorizationCode'] = default_input('Authorization Code', '')
576 if len(cfg['oAuth2ClientCredentials']['authorizationCode']) <> 401:
577 print 'That doesn\'t look like a valid Authorization Code. Please try again.'
578 with open(cfgFile, 'w') as outfile:
579 json.dump(cfg, outfile, indent=2, sort_keys=True, separators=(',', ':'))
580 configChanged = True
581 return
582
583# Request new OAuth2 tokens
584def requestTokens(grant_type, refreshToken):
585 logToDomoticz(MSG_INFO, 'Requesting acess tokens using the ' + grant_type + ' grant type')
586 logging.basicConfig(filename=logFile,level=logging.DEBUG,format='%(asctime)s %(levelname)s %(message)s',filemode='w')
587 data={}
588 try:
589 if grant_type == 'authorization_code':
590 data={'grant_type' : grant_type, 'code' : cfg['oAuth2ClientCredentials']['authorizationCode'], 'client_id' : cfg['oAuth2ClientCredentials']['clientId'], 'client_secret' : cfg['oAuth2ClientCredentials']['clientSecret'], 'redirect_uri' : redirect_uri}
591 elif grant_type == 'refresh_token':
592 logging.info('Using Refresh Token: %s' % refreshToken)
593 data={'grant_type' : grant_type, 'refresh_token' : refreshToken, 'client_id' : cfg['oAuth2ClientCredentials']['clientId'], 'client_secret' : cfg['oAuth2ClientCredentials']['clientSecret']}
594 getTokens = requests.post(tokenEndpointUrl, data)
595 getTokens.raise_for_status()
596 newTokens = getTokens.json()
597 accessToken = newTokens['access_token']
598 expiresIn = newTokens['expires_in']
599 expiration = int(time.time()) + expiresIn
600 refreshToken = newTokens['refresh_token']
601 plistlib.writePlist({'Access Token':accessToken,'Refresh Token':refreshToken,'Expiration': expiration,}, tokenDictionaryFile)
602
603 logging.info('Got Access Token: %s' % accessToken)
604 logging.info('The Access Token is valid for : %s seconds' % expiresIn)
605 logging.info('Got Refresh Token: %s' % refreshToken)
606 #tokenPlist = plistlib.readPlist(tokenDictionaryFile)
607 except requests.exceptions.RequestException, e:
608 logMessage = 'Can\'t generate tokens: %s' % e
609 logging.error('========== ' + logMessage + ' ==========')
610 logToDomoticz(MSG_ERROR, logMessage)
611 if tty:
612 print logMessage
613 print 'The Authorization Code might be too old. Clearing it out so that You can request a new. Please run this script again.'
614 cfg['oAuth2ClientCredentials']['authorizationCode'] = 'xxxx'
615 with open(cfgFile, 'w') as outfile:
616 json.dump(cfg, outfile, indent=2, sort_keys=True, separators=(',', ':'))
617 print '\n\nBelow is some debugging help:\n=============================='
618 for k, v in data.iteritems():
619 print k, ' = ', v
620 print '\n\n'
621 sys.exit(0)
622 except:
623 logMessage = 'Can\'t create the token dictionary file'
624 logging.error('========== ' + logMessage + ' ==========')
625 logToDomoticz(MSG_ERROR, logMessage)
626 if tty:
627 print logMessage
628 sys.exit(0)
629 return accessToken
630
631# Validate the OAuth2 tokens
632def validateTokens():
633 # First let's read the Token Dictionary plist
634 accessTokenValid = False
635 try:
636 tokenPlist = plistlib.readPlist(tokenDictionaryFile)
637 refreshToken = tokenPlist["Refresh Token"]
638 accessToken = tokenPlist["Access Token"]
639 expiration = tokenPlist["Expiration"]
640 if expiration - time.time() > 30:
641 accessTokenValid = True
642 logMessage = 'Current access token valid for ' + str(int(expiration - time.time())) + ' seconds'
643 if isVerbose:
644 logToDomoticz(MSG_INFO, logMessage)
645 except:
646 if not tty:
647 logToDomoticz(MSG_ERROR, 'You need to run \'python ' + os.path.realpath(__file__) + '\' from a console to obtain a new Authorization Code')
648 sys.exit(0)
649 # No file!? Man that's bad. Maybe this is the first time the script runs. Let's make an Authorization Request
650 errorText = 'There is no dictionary file ' + tokenDictionaryFile + '' \
651 + ' (That is perfectly normal if this is the first time that the script runs)'
652 logToDomoticz(MSG_ERROR, errorText)
653 if tty:
654 print errorText
655 accessToken = requestTokens('authorization_code', '')
656 accessTokenValid = True
657 if not accessTokenValid:
658 # The old refresh token is used to obtain a new access token and a new refresh token
659 accessToken = requestTokens('refresh_token', refreshToken)
660 return accessToken
661
662def get_system_list(accessToken):
663 authorization_header = {"Authorization": "Bearer %s" % accessToken}
664 r = requests.get(baseEndPointUrl + "/api/v1/systems", headers=authorization_header)
665 if r.status_code == requests.codes.ok:
666 if isDebug: print 'HTTP/1.1 200 OK'
667 else:
668 print "Nibe server responded with an error code: ", r.status_code
669 sys.exit(0)
670 if isDebug: print "get_system_list: ", r.text, "\n"
671 return r.text
672
673def get_system_status(accessToken, systemid):
674 authorization_header = {"Authorization": "Bearer %s" % accessToken}
675 r = requests.get(baseEndPointUrl + "/api/v1/systems/" + str(systemid) + "/status/system", headers=authorization_header)
676 if isDebug: print "get_system_status: ", r.text, "\n"
677 return r.text
678
679def get_system_config(accessToken, systemid):
680 authorization_header = {"Authorization": "Bearer %s" % accessToken}
681 r = requests.get(baseEndPointUrl + "/api/v1/systems/" + str(systemid) + "/config", headers=authorization_header)
682 if isDebug: print "get_system_config: ", r.text, "\n"
683 return r.text
684
685def get_system_unit_status(accessToken, systemid, systemUnitId):
686 authorization_header = {"Authorization": "Bearer %s" % accessToken}
687 r = requests.get(baseEndPointUrl + "/api/v1/systems/" + str(systemid) + "/status/systemUnit/" + systemUnitId, headers=authorization_header)
688 if isDebug: print "get_system_unit_status: ", r.text, "\n"
689 return r.text
690
691def get_serviceinfoCategories(accessToken, systemid):
692 authorization_header = {"Authorization": "Bearer %s" % accessToken}
693 r = requests.get(baseEndPointUrl + "/api/v1/systems/" + str(systemid) + "/serviceinfo/categories?parameters=true", headers=authorization_header)
694 if isDebug: print "get_serviceinfoCategories: ", r.text + '\n'
695 return r.text
696
697def list_systems(accessToken):
698 systemlist = json.loads(get_system_list(accessToken))
699 if isVerbose: print 'Number of systems: ' + str(systemlist['numItems'])
700 if isVerbose: print ''
701 for nibeSystem in systemlist['objects']:
702 if isVerbose: print 'Product Name: ' + nibeSystem['productName']
703 if isVerbose: print 'Serial number: ' + nibeSystem['serialNumber']
704 if isVerbose: print 'System ID: ' + str(nibeSystem['systemId'])
705 if isVerbose: print 'Has alarmed: ' + str(nibeSystem['hasAlarmed'])
706 if isVerbose: print ''
707
708 # climate system 1
709 systemUnitId = 0
710 if isDebug: systemUnitStatus0 = json.loads(get_system_unit_status(accessToken, nibeSystem['systemId'], str(systemUnitId)))
711 if isDebug: systemStatus = json.loads(get_system_status(accessToken, nibeSystem['systemId']))
712 serviceinfoCategories = json.loads(get_serviceinfoCategories(accessToken, nibeSystem['systemId']))
713
714 # Append the hasAlarmed to the serviceinfoCategories parameters
715 has_alarmed_dict = {'parameterId': 9999999,
716 'name': '9999999',
717 'title': 'system alarm',
718 'designation': '',
719 'unit': '',
720 'displayValue': str(int(nibeSystem['hasAlarmed'])),
721 'rawValue': int(nibeSystem['hasAlarmed'])
722 }
723 for s in serviceinfoCategories:
724 if s['categoryId'] == 'STATUS' :
725 s['parameters'].append(has_alarmed_dict)
726
727 if redefineDevices:
728 for s in serviceinfoCategories:
729 for nibeResource in s['parameters']: # nibeResources:
730 redefineDomoDevice(nibeSystem, nibeResource, s['name'])
731 if configChanged:
732 cfg['domoticz']['devices']['device'] = newDevicesList # Replace the devices list in config file
733 with open(cfgFile, 'w') as outfile:
734 json.dump(cfg, outfile, indent=2, sort_keys=True, separators=(',', ':'))
735 print '\n\n\n'
736 for s in serviceinfoCategories:
737 for nibeResource in s['parameters']: # nibeResources:
738 #catResources = json.loads(p)
739 domoDevice = getDomoDevice(nibeSystem, nibeResource, s['name'])
740 if domoDevice: updateDomoDevice(domoDevice, nibeResource)
741 #print "Unit: " + p['unit']
742 if configChanged:
743 logMessage = 'Updated the config file at ' + cfgFile
744 logToDomoticz(MSG_INFO, logMessage)
745 if isVerbose: print logMessage
746
747def print_help(argv):
748 print 'usage: ' + os.path.basename(__file__) + ' [option] [-C domoticzDeviceidx|all] \nOptions and arguments'
749 print '-d : debug output (also --debug)'
750 print '-h : print this help message and exit (also --help)'
751 print '-v : verbose'
752 print '-V : print the version number and exit (also --version)'
753 print '-R : redefines devices in the config file (also --redefine)'
754
755def main(argv):
756 global isDebug
757 global isVerbose
758 global redefineDevices;redefineDevices = False
759 try:
760 opts, args = getopt.getopt(argv, 'dhvVR', ['help', 'debug', 'version', 'redefine'])
761 except getopt.GetoptError:
762 print_help(argv)
763 sys.exit(2)
764 for opt, arg in opts:
765 if opt in ('-h', '--help'):
766 print_help(argv)
767 sys.exit(0)
768 elif opt in ('-d', '--debug'):
769 isDebug = True
770 elif opt in ('-v'):
771 isVerbose = True
772 elif opt in ('-V', '--version'):
773 print PROGRAMNAME + ' ' + VERSION
774 sys.exit(0)
775 elif opt in ("-R", "--redefine"):
776 redefineDevices = True
777
778 if isDebug: print 'Debug is on'
779 if not tty: time.sleep( 5 ) # Give Domoticz some time to settle down from other commands running exactly at the 00 sec
780 global cfg; cfg = load_config()
781 global logFile; logFile = os.path.join(cfg['system']['tmpFolder'], os.path.basename(sys.argv[0]) + '.log')
782 global tokenDictionaryFile; tokenDictionaryFile = os.path.join(cfg['system']['tmpFolder'], 'nibeUpLink.plist')
783
784 if not connected_to_internet():
785 logToDomoticz(MSG_ERROR, 'No internet connection available')
786 sys.exit(0)
787
788 msgProgInfo = PROGRAMNAME + ' Version ' + VERSION
789 msgProgInfo += ' running on TTY console...' if tty else ' running as a CRON job...'
790 if isVerbose:
791 print msgProgInfo
792 logToDomoticz(MSG_EXEC, msgProgInfo)
793
794
795 if len(cfg['oAuth2ClientCredentials']['authorizationCode']) <> 401:
796 if tty:
797 retrieve_authorization_code()
798 else:
799 sys.exit(0)
800
801 accessToken = validateTokens()
802 if redefineDevices and tty:
803 print '\nYou have requested to redefine the devices. By doing that, for each device found in your Nibe system, you will be asked if you want to redefine it. After going through the complete list, for any device that You answered \'Yes\' You will be asked if You want to create a Domoticz device for it.\n'
804 if not query_yes_no('Are You sure that you\'d like to redefine the devices', 'no'):
805 sys.exit(0)
806 list_systems(accessToken)
807 sys.exit(0)
808
809if __name__ == '__main__':
810 main(sys.argv[1:])