[106] | 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 |
|
---|
[108] | 65 | ## Wheter to display debug messages.
|
---|
| 66 | g_fDebug = len(os.environ.get('debug', '')) > 0;
|
---|
[106] | 67 |
|
---|
[108] | 68 |
|
---|
[106] | 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);
|
---|
[108] | 146 | if g_fDebug:
|
---|
[106] | 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();
|
---|
[108] | 164 | if len(asIps) == 0:
|
---|
| 165 | raise Exception('environment variable fritzboxdect_ip is empty')
|
---|
[106] | 166 | asPasswords = os.environ.get('fritzboxdect_password', '').split();
|
---|
[108] | 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 |
|
---|
[106] | 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:
|
---|
[110] | 269 | sValue = oElmPowerMeter.find('energy').text;
|
---|
| 270 | if sValue is not None:
|
---|
| 271 | sValue = oElmPowerMeter.find('energy').text.strip();
|
---|
[106] | 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:
|
---|
[110] | 284 | sValue = oElmPowerMeter.find('power').text;
|
---|
| 285 | if sValue is not None:
|
---|
| 286 | sValue = sValue.strip();
|
---|
[106] | 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:
|
---|
[110] | 302 | sValue = oElmTemp.find('celsius').text;
|
---|
| 303 | if sValue is not None:
|
---|
| 304 | sValue = sValue.strip();
|
---|
[106] | 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()):
|
---|
[109] | 317 | print('avg_%s_c.value %.1f' % (sVarNm, dAvg[sVarNm][1] / 10.0 / dAvg[sVarNm][0]));
|
---|
[106] | 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()):
|
---|
[109] | 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,));
|
---|
[106] | 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 |
|
---|