1 | #!/usr/bin/env python
|
---|
2 | # -*- coding: utf-8 -*-
|
---|
3 | # $Id: fritzboxdect.py 110 2017-12-18 23:48:49Z bird $
|
---|
4 |
|
---|
5 | """
|
---|
6 | Fritz!box DECT sockets.
|
---|
7 |
|
---|
8 | Generates power consumption, current power usage, and temperature graphs.
|
---|
9 |
|
---|
10 |
|
---|
11 | Configuration:
|
---|
12 |
|
---|
13 | [fritzboxdect]
|
---|
14 | env.fritzboxdect_ip [ip addresses of the fritzboxes]
|
---|
15 | env.fritzboxdect_password [passwords of the frizboxes]
|
---|
16 |
|
---|
17 | #%# family=auto contrib
|
---|
18 | #%# capabilities=autoconf
|
---|
19 | """
|
---|
20 |
|
---|
21 | __copyright__ = \
|
---|
22 | """
|
---|
23 | Copyright (c) 2017 Knut St. Osmundsen <bird-kStuff-spamix@anduin.net>
|
---|
24 |
|
---|
25 | Permission is hereby granted, free of charge, to any person
|
---|
26 | obtaining a copy of this software and associated documentation
|
---|
27 | files (the "Software"), to deal in the Software without
|
---|
28 | restriction, including without limitation the rights to use,
|
---|
29 | copy, modify, merge, publish, distribute, sublicense, and/or sell
|
---|
30 | copies of the Software, and to permit persons to whom the
|
---|
31 | Software is furnished to do so, subject to the following
|
---|
32 | conditions:
|
---|
33 |
|
---|
34 | The above copyright notice and this permission notice shall be
|
---|
35 | included in all copies or substantial portions of the Software.
|
---|
36 |
|
---|
37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
---|
38 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
---|
39 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
---|
40 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
---|
41 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
---|
42 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
---|
43 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
---|
44 | OTHER DEALINGS IN THE SOFTWARE.
|
---|
45 | """
|
---|
46 | __version__ = "$Revision: 110 $"
|
---|
47 |
|
---|
48 |
|
---|
49 | # Standard Python imports.
|
---|
50 | import hashlib
|
---|
51 | import httplib
|
---|
52 | import os
|
---|
53 | import sys
|
---|
54 | from xml.etree import ElementTree
|
---|
55 | from xml.dom import minidom
|
---|
56 |
|
---|
57 | if sys.version_info[0] < 3:
|
---|
58 | from urllib2 import quote as urllib_quote;
|
---|
59 | from urllib import urlencode as urllib_urlencode;
|
---|
60 | else:
|
---|
61 | from urllib.parse import quote as urllib_quote; # pylint: disable=F0401,E0611
|
---|
62 | from urllib.parse import urlencode as urllib_urlencode; # pylint: disable=F0401,E0611
|
---|
63 |
|
---|
64 |
|
---|
65 | ## Wheter to display debug messages.
|
---|
66 | g_fDebug = len(os.environ.get('debug', '')) > 0;
|
---|
67 |
|
---|
68 |
|
---|
69 | class FritzBoxConnection(object):
|
---|
70 | """
|
---|
71 | A HTTP(S) connection to a fritz!box.
|
---|
72 | """
|
---|
73 |
|
---|
74 | ## The user agent string to use.
|
---|
75 | g_UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"
|
---|
76 |
|
---|
77 | def __init__(self, sServer, sPassword, iPort = 80):
|
---|
78 | #
|
---|
79 | # Connect to the fritz!box.
|
---|
80 | #
|
---|
81 | oConn = httplib.HTTPConnection('%s:%s' % (sServer, iPort));
|
---|
82 | self.oConn = oConn;
|
---|
83 |
|
---|
84 | #
|
---|
85 | # Login - gets a SID back.
|
---|
86 | #
|
---|
87 | dLoginHdrs = {
|
---|
88 | "Accept": "application/xml",
|
---|
89 | "Content-Type": "text/plain",
|
---|
90 | "User-Agent": self.g_UserAgent,
|
---|
91 | };
|
---|
92 | oConn.request("GET", '/login_sid.lua', '', dLoginHdrs)
|
---|
93 | oResp = oConn.getresponse();
|
---|
94 | sData = oResp.read();
|
---|
95 | if oResp.status != 200:
|
---|
96 | raise Exception('login_sid.lua response: %s %s' % (oResp.status, oResp.reason,));
|
---|
97 | oXmlData = minidom.parseString(sData);
|
---|
98 | aoElmSids = oXmlData.getElementsByTagName('SID');
|
---|
99 | sSid = aoElmSids[0].firstChild.data;
|
---|
100 | if sSid == '0000000000000000':
|
---|
101 | # Hash the password and compose reply text.
|
---|
102 | aoElmChallenges = oXmlData.getElementsByTagName('Challenge');
|
---|
103 | sChallange = aoElmChallenges[0].firstChild.data;
|
---|
104 | sReplyHashedText = ('%s-%s' % (sChallange, sPassword)).decode('iso-8859-1').encode('utf-16le');
|
---|
105 | oReplyHash = hashlib.md5();
|
---|
106 | oReplyHash.update(sReplyHashedText);
|
---|
107 | sReplyText = '%s-%s' % (sChallange, oReplyHash.hexdigest().lower());
|
---|
108 |
|
---|
109 | # Sent it.
|
---|
110 | dReplyHdrs = {
|
---|
111 | "Accept": "text/html,application/xhtml+xml,application/xml",
|
---|
112 | "Content-Type": "application/x-www-form-urlencoded",
|
---|
113 | "User-Agent": self.g_UserAgent,
|
---|
114 | };
|
---|
115 | oConn.request("GET", '/login_sid.lua?' + urllib_urlencode({'response': sReplyText,}), '', dReplyHdrs)
|
---|
116 | oResp = oConn.getresponse();
|
---|
117 | sData = oResp.read();
|
---|
118 | if oResp.status != 200:
|
---|
119 | raise Exception('login_sid.lua response: %s %s' % (oResp.status, oResp.reason,));
|
---|
120 | oXmlData = minidom.parseString(sData);
|
---|
121 | aoElmSids = oXmlData.getElementsByTagName('SID');
|
---|
122 | sSid = aoElmSids[0].firstChild.data;
|
---|
123 | if sSid == '0000000000000000':
|
---|
124 | raise Exception('login failure');
|
---|
125 | # Remember the SID.
|
---|
126 | self.sSid = sSid;
|
---|
127 |
|
---|
128 | def getPage(self, sUrl, asArgs):
|
---|
129 | """
|
---|
130 | Retrieves the given page.
|
---|
131 |
|
---|
132 | We would like to use a directory for the arguments, but fritzy is picky
|
---|
133 | about the ordering.
|
---|
134 | """
|
---|
135 |
|
---|
136 | dPageHdrs = {
|
---|
137 | "Accept": "application/xml,text/xml",
|
---|
138 | "Content-Type": "text/plain",
|
---|
139 | "User-Agent": self.g_UserAgent,
|
---|
140 | };
|
---|
141 |
|
---|
142 | sUrl = sUrl + '?sid=' + self.sSid;
|
---|
143 | for sArg in asArgs:
|
---|
144 | sName, sValue = sArg.split('=')
|
---|
145 | sUrl += '&' + urllib_quote(sName) + '=' + urllib_quote(sValue);
|
---|
146 | if g_fDebug:
|
---|
147 | print('debug: sUrl: %s' % (sUrl,));
|
---|
148 |
|
---|
149 | self.oConn.request("GET", sUrl, '', dPageHdrs)
|
---|
150 | oResp = self.oConn.getresponse();
|
---|
151 | sData = oResp.read();
|
---|
152 | if oResp.status != 200:
|
---|
153 | raise Exception('%s response: %s %s' % (sUrl, oResp.status, oResp.reason,));
|
---|
154 | return sData;
|
---|
155 |
|
---|
156 |
|
---|
157 | @staticmethod
|
---|
158 | def getPages(sUrl, asArgs):
|
---|
159 | """
|
---|
160 | Gets an array of pages from each of the frizboxes.
|
---|
161 | """
|
---|
162 | asRet = [];
|
---|
163 | asIps = os.environ.get('fritzboxdect_ip', '10.42.2.1 10.42.1.50').split();
|
---|
164 | if len(asIps) == 0:
|
---|
165 | raise Exception('environment variable fritzboxdect_ip is empty')
|
---|
166 | asPasswords = os.environ.get('fritzboxdect_password', '').split();
|
---|
167 | if len(asPasswords) == 0:
|
---|
168 | raise Exception('environment variable fritzboxdect_password is empty')
|
---|
169 | if g_fDebug:
|
---|
170 | print('debug: asIps=%s asPasswords=%s' % (asIps, asPasswords,));
|
---|
171 |
|
---|
172 | for i, sIp in enumerate(asIps):
|
---|
173 | sPassword = asPasswords[i] if i < len(asPasswords) else asPasswords[-1];
|
---|
174 | oConn = FritzBoxConnection(sIp, sPassword);
|
---|
175 | asRet.append(oConn.getPage(sUrl, asArgs));
|
---|
176 | del oConn;
|
---|
177 | return asRet;
|
---|
178 |
|
---|
179 |
|
---|
180 |
|
---|
181 | # XML output example:
|
---|
182 | # <devicelist version="1">
|
---|
183 | # <device identifier="11657 0072338" id="16" functionbitmask="2944" fwversion="03.87" manufacturer="AVM"
|
---|
184 | # productname="FRITZ!DECT 210">
|
---|
185 | # <present>1</present>
|
---|
186 | # <name>Socket #1</name>
|
---|
187 | # <switch>
|
---|
188 | # <state>1</state>
|
---|
189 | # <mode>manuell</mode>
|
---|
190 | # <lock>1</lock>
|
---|
191 | # <devicelock>0</devicelock>
|
---|
192 | # </switch>
|
---|
193 | # <powermeter>
|
---|
194 | # <power>2730</power>
|
---|
195 | # <energy>3602</energy>
|
---|
196 | # </powermeter>
|
---|
197 | # <temperature>
|
---|
198 | # <celsius>255</celsius>
|
---|
199 | # <offset>0</offset>
|
---|
200 | # </temperature>
|
---|
201 | # </device>
|
---|
202 | # </devicelist>
|
---|
203 |
|
---|
204 | g_sPage = '/webservices/homeautoswitch.lua'
|
---|
205 | g_fBitEnergy = 1 << 7;
|
---|
206 | g_fBitTemp = 1 << 8;
|
---|
207 |
|
---|
208 | def getAllDeviceElements():
|
---|
209 | """
|
---|
210 | Connects to the fritz!boxes and gets the devicelistinfos.
|
---|
211 | Returns array of device elements, sorted by name.
|
---|
212 | """
|
---|
213 | asData = FritzBoxConnection.getPages(g_sPage, ['switchcmd=getdevicelistinfos']);
|
---|
214 | aoRet = [];
|
---|
215 | for sData in asData:
|
---|
216 | oXmlRoot = ElementTree.fromstring(sData);
|
---|
217 | for oElmDevice in oXmlRoot.findall('device'):
|
---|
218 | aoRet.append(oElmDevice);
|
---|
219 |
|
---|
220 | def getKey(oElmDevice):
|
---|
221 | oName = oElmDevice.find('name');
|
---|
222 | if oName is not None:
|
---|
223 | return oName.text;
|
---|
224 | return oElmDevice.get('identifier');
|
---|
225 |
|
---|
226 | return sorted(aoRet, key = getKey);
|
---|
227 |
|
---|
228 |
|
---|
229 | def getDeviceVarName(oElmDevice):
|
---|
230 | """
|
---|
231 | Gets the graph variable name for the device.
|
---|
232 | """
|
---|
233 | sAin = oElmDevice.get('identifier');
|
---|
234 | sAin = ''.join(sAin.split());
|
---|
235 | return 'ain_%s' % (sAin,);
|
---|
236 |
|
---|
237 |
|
---|
238 | def getDeviceBitmask(oElmDevice):
|
---|
239 | """
|
---|
240 | Gets the device bitmask.
|
---|
241 | """
|
---|
242 | sBitmask = oElmDevice.get('functionbitmask');
|
---|
243 | try:
|
---|
244 | fBitmask = int(sBitmask);
|
---|
245 | except:
|
---|
246 | sProduct = oElmDevice.get('productname');
|
---|
247 | if sProduct == 'FRITZ!DECT 210' or sProduct == 'FRITZ!DECT 200':
|
---|
248 | fBitmask = 0xb80;
|
---|
249 | elif sProduct == 'FRITZ!DECT 100':
|
---|
250 | fBitmask = 0x500;
|
---|
251 | else:
|
---|
252 | fBitmask = 0;
|
---|
253 | return fBitmask;
|
---|
254 |
|
---|
255 |
|
---|
256 | def printValues():
|
---|
257 | """
|
---|
258 | Prints the values.
|
---|
259 | """
|
---|
260 | aoElmDevices = getAllDeviceElements()
|
---|
261 |
|
---|
262 | print('multigraph power_consumption')
|
---|
263 | uTotal = 0;
|
---|
264 | for oElmDevice in aoElmDevices:
|
---|
265 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
266 | if getDeviceBitmask(oElmDevice) & g_fBitEnergy:
|
---|
267 | oElmPowerMeter = oElmDevice.find('powermeter');
|
---|
268 | if oElmPowerMeter is not None:
|
---|
269 | sValue = oElmPowerMeter.find('energy').text;
|
---|
270 | if sValue is not None:
|
---|
271 | sValue = oElmPowerMeter.find('energy').text.strip();
|
---|
272 | if sValue:
|
---|
273 | print('%s_wh.value %s' % (sVarNm, sValue,));
|
---|
274 | try: uTotal += int(sValue)
|
---|
275 | except: pass;
|
---|
276 | print('total_wh.value %s' % (uTotal,));
|
---|
277 |
|
---|
278 | print('multigraph power_usage')
|
---|
279 | for oElmDevice in aoElmDevices:
|
---|
280 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
281 | if getDeviceBitmask(oElmDevice) & g_fBitEnergy:
|
---|
282 | oElmPowerMeter = oElmDevice.find('powermeter');
|
---|
283 | if oElmPowerMeter is not None:
|
---|
284 | sValue = oElmPowerMeter.find('power').text;
|
---|
285 | if sValue is not None:
|
---|
286 | sValue = sValue.strip();
|
---|
287 | if sValue:
|
---|
288 | try: uTotal += int(sValue)
|
---|
289 | except: pass;
|
---|
290 | while len(sValue) < 4:
|
---|
291 | sValue = '0' + sValue;
|
---|
292 | print('%s_w.value %s.%s' % (sVarNm, sValue[:-3] , sValue[-3:]));
|
---|
293 | print('total_wh.value %.3f' % (uTotal / 1000.0,));
|
---|
294 |
|
---|
295 | print('multigraph temp')
|
---|
296 | dAvg = {};
|
---|
297 | for oElmDevice in aoElmDevices:
|
---|
298 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
299 | if getDeviceBitmask(oElmDevice) & g_fBitTemp:
|
---|
300 | oElmTemp = oElmDevice.find('temperature');
|
---|
301 | if oElmTemp is not None:
|
---|
302 | sValue = oElmTemp.find('celsius').text;
|
---|
303 | if sValue is not None:
|
---|
304 | sValue = sValue.strip();
|
---|
305 | if sValue:
|
---|
306 | print('%s_c.value %s.%s' % (sVarNm, sValue[:-1] if len(sValue) > 0 else '0', sValue[-1:]));
|
---|
307 | if oElmDevice.find('name') is not None:
|
---|
308 | try: uValue = int(sValue);
|
---|
309 | except: pass;
|
---|
310 | else:
|
---|
311 | sFloor = oElmDevice.find('name').text[:2];
|
---|
312 | if sFloor not in dAvg:
|
---|
313 | dAvg[sFloor] = (1, uValue);
|
---|
314 | else:
|
---|
315 | dAvg[sFloor] = (dAvg[sFloor][0] + 1, dAvg[sFloor][1] + uValue);
|
---|
316 | for sVarNm in sorted(dAvg.keys()):
|
---|
317 | print('avg_%s_c.value %.1f' % (sVarNm, dAvg[sVarNm][1] / 10.0 / dAvg[sVarNm][0]));
|
---|
318 | # done
|
---|
319 |
|
---|
320 |
|
---|
321 | def printConfig():
|
---|
322 | """
|
---|
323 | Prints the configuration.
|
---|
324 | """
|
---|
325 | aoElmDevices = getAllDeviceElements()
|
---|
326 |
|
---|
327 | print('multigraph power_consumption')
|
---|
328 | print('graph_title Power consumption');
|
---|
329 | print('graph_vlabel Wh');
|
---|
330 | print('graph_args --base 1000');
|
---|
331 | print("graph_category house");
|
---|
332 | for oElmDevice in aoElmDevices:
|
---|
333 | if getDeviceBitmask(oElmDevice) & g_fBitEnergy:
|
---|
334 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
335 | print('%s_wh.label %s' % (sVarNm, oElmDevice.find('name').text,));
|
---|
336 | print('%s_wh.type COUNTER' % (sVarNm,));
|
---|
337 | print('%s_wh.draw LINE1' % (sVarNm,));
|
---|
338 | print('total_wh.label total');
|
---|
339 | print('total_wh.type COUNTER');
|
---|
340 | print('total_wh.draw LINE1');
|
---|
341 |
|
---|
342 | print('multigraph power_usage')
|
---|
343 | print('graph_title Power usage');
|
---|
344 | print('graph_vlabel W');
|
---|
345 | print('graph_args --base 1000');
|
---|
346 | print("graph_category house");
|
---|
347 | print('graph_info Current power usage around the house');
|
---|
348 | for oElmDevice in aoElmDevices:
|
---|
349 | if getDeviceBitmask(oElmDevice) & g_fBitEnergy:
|
---|
350 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
351 | print('%s_w.label %s' % (sVarNm, oElmDevice.find('name').text,));
|
---|
352 | print('%s_w.type GAUGE' % (sVarNm,));
|
---|
353 | print('%s_w.draw LINE1' % (sVarNm,));
|
---|
354 | print('total_w.label total');
|
---|
355 | print('total_w.type COUNTER');
|
---|
356 | print('total_w.draw LINE1');
|
---|
357 |
|
---|
358 | print('multigraph temp')
|
---|
359 | print('graph_title Temperature');
|
---|
360 | print('graph_args --base 1000');
|
---|
361 | print('graph_vlabel Degrees (C)');
|
---|
362 | print('graph_scale no');
|
---|
363 | print('graph_category house');
|
---|
364 | print('graph_info Temperatures around the house');
|
---|
365 | print('temperature.type GAUGE');
|
---|
366 | dAvg = {};
|
---|
367 | for oElmDevice in aoElmDevices:
|
---|
368 | if getDeviceBitmask(oElmDevice) & g_fBitTemp:
|
---|
369 | sVarNm = getDeviceVarName(oElmDevice);
|
---|
370 | sName = oElmDevice.find('name').text;
|
---|
371 | print('%s_c.label %s' % (sVarNm, sName,));
|
---|
372 | print('%s_c.type GAUGE' % (sVarNm,));
|
---|
373 | print('%s_c.draw LINE1' % (sVarNm,));
|
---|
374 | dAvg[sName[:2]] = 1;
|
---|
375 | for sVarNm in sorted(dAvg.keys()):
|
---|
376 | print('avg_%s_c.label Average %s' % (sVarNm, sVarNm,));
|
---|
377 | print('avg_%s_c.type GAUGE' % (sVarNm,));
|
---|
378 | print('avg_%s_c.draw LINE1' % (sVarNm,));
|
---|
379 |
|
---|
380 |
|
---|
381 | def main(asArgs):
|
---|
382 | """
|
---|
383 | C-like main.
|
---|
384 | """
|
---|
385 | if len(asArgs) == 2 and asArgs[1] == 'config':
|
---|
386 | try: printConfig();
|
---|
387 | except Exception as oXcpt:
|
---|
388 | sys.exit('Failed to retreive configuration (%s)' % (oXcpt,));
|
---|
389 | return 0;
|
---|
390 |
|
---|
391 | if len(asArgs) == 2 and asArgs[1] == 'autoconfig':
|
---|
392 | print("yes");
|
---|
393 | return 0;
|
---|
394 |
|
---|
395 | if len(asArgs) == 1 \
|
---|
396 | or (len(asArgs) == 2 and asArgs[1] == 'fetch'):
|
---|
397 | try: printValues();
|
---|
398 | except Exception as oXcpt:
|
---|
399 | sys.exit('Failed to retreive data (%s)' % (oXcpt,));
|
---|
400 | return 0;
|
---|
401 |
|
---|
402 | sys.exit('Unknown request (%s)' % (asArgs,));
|
---|
403 |
|
---|
404 |
|
---|
405 | if __name__ == '__main__':
|
---|
406 | sys.exit(main(sys.argv))
|
---|
407 |
|
---|