[FritzCall] ADD: make http/https configurable, fall back to http, if
[enigma2-plugins.git] / fritzcall / src / FritzConnection.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 """
5 fritzconnection.py
6
7 This is a tool to communicate with the FritzBox.
8 All available actions (aka commands) and corresponding parameters are
9 read from the xml-configuration files requested from the FritzBox. So
10 the available actions may change depending on the FritzBox model and
11 firmware.
12 The command-line interface allows the api-inspection.
13 The api can also be inspected by a terminal session:
14
15 >>> import fritzconnection as fc
16 >>> fc.print_api()
17
18 'print_api' takes the optional parameters:
19         address = ip-address
20         port = port number (should not change)
21         user = the username
22         password = password (to access tr64-services)
23
24 In most cases you have to provide the ip (in case you changed the
25 default or have multiple boxes i.e. for multiple WLAN access points).
26 Also you have to send the password to get the complete api.
27
28 License: MIT https://opensource.org/licenses/MIT
29 Source: https://bitbucket.org/kbr/fritzconnection
30 Author: Klaus Bremer
31 Modified to use async communication, content level authentication and plain xml.etree.ElementTree: DrMichael
32 """
33 # pylint: disable=C0111,C0103,C0301,W0603,W0403,C0302,W0312
34
35 __version__ = '0.6'
36
37 import logging, re, md5
38
39 import xml.etree.ElementTree as ET
40 from Components.config import config
41 from twisted.web.client import getPage
42
43 USERAGENT = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"
44
45 # FritzConnection defaults:
46 FRITZ_IP_ADDRESS = '192.168.188.1'
47 FRITZ_TCP_PORT = 49443
48 FRITZ_IGD_DESC_FILE = 'igddesc.xml'
49 FRITZ_TR64_DESC_FILE = 'tr64desc.xml'
50 FRITZ_USERNAME = 'dslf-config'
51
52
53 # version-access:
54 def get_version():
55         return __version__
56
57
58 class FritzConnectionException(Exception): pass
59 class ServiceError(FritzConnectionException): pass
60 class ActionError(FritzConnectionException): pass
61
62
63 class FritzAction(object):
64         """
65         Class representing an action (aka command).
66         Knows how to execute itself.
67         Access to any password-protected action must require HTTP digest
68         authentication.
69         See: http://www.broadband-forum.org/technical/download/TR-064.pdf
70         """
71         logger = logging.getLogger("FritzCall.FritzAction")
72         debug = logger.debug
73         info = logger.info
74         warn = logger.warn
75         error = logger.error
76         exception = logger.exception
77         
78         header = {'soapaction': '',
79                           'content-type': 'text/xml',
80                           'charset': 'utf-8'}
81         envelope = """
82                 <?xml version="1.0" encoding="utf-8"?>
83                 <s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
84                                         xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">%s%s
85                 </s:Envelope>
86                 """
87         header_initchallenge_template = """
88                 <s:Header>
89                         <h:InitChallenge
90                                 xmlns:h="http://soap-authentication.org/digest/2001/10/"
91                                 s:mustUnderstand="1">
92                         <UserID>%s</UserID>
93                         </h:InitChallenge >
94                 </s:Header>
95                 """
96         header_clientauth_template = """
97                 <s:Header>
98                         <h:ClientAuth
99                                 xmlns:h="http://soap-authentication.org/digest/2001/10/"
100                                 s:mustUnderstand="1">
101                         <Nonce>%s</Nonce>
102                         <Auth>%s</Auth>
103                         <UserID>%s</UserID>
104                         <Realm>%s</Realm>
105                         </h:ClientAuth>
106                 </s:Header>
107                 """
108         body_template = """
109                 <s:Body>
110                         <u:%(action_name)s xmlns:u="%(service_type)s">%(arguments)s
111                         </u:%(action_name)s>
112                 </s:Body>
113                 """
114         argument_template = """
115                 <s:%(name)s>%(value)s</s:%(name)s>"""
116         method = 'post'
117         address = port = ""
118         nonce = auth = realm = ""
119
120         def __init__(self, service_type, control_url, action_parameters):
121                 self.service_type = service_type
122                 self.control_url = control_url
123                 self.name = ''
124                 self.arguments = {}
125                 self.password = None
126                 self.__dict__.update(action_parameters)
127
128 #       @property
129 #       def info(self):
130 #               return [self.arguments[argument].info for argument in self.arguments]
131
132         def _body_builder(self, kwargs):
133                 """
134                 Helper method to construct the appropriate SOAP-body to call a
135                 FritzBox-Service.
136                 """
137                 p = {
138                         'action_name': self.name,
139                         'service_type': self.service_type,
140                         'arguments': '',
141                         }
142                 if kwargs:
143                         # self.debug(repr(kwargs))
144                         arguments = [
145                                 self.argument_template % {'name': k, 'value': v}
146                                 for k, v in kwargs.items()
147                         ]
148                         p['arguments'] = ''.join(arguments)
149                 body = self.body_template.strip() % p
150                 # self.debug(body)
151                 return body
152
153         def execute(self, callback, **kwargs):
154                 """
155                 Calls the FritzBox action and returns a dictionary with the arguments.
156                 """
157                 # self.debug("")
158                 headers = self.header.copy()
159                 headers['soapaction'] = '%s#%s' % (self.service_type, self.name)
160 #               headers['Authorization'] = "Digest " + md5Sid
161 #               self.debug("headers: " + repr(headers))
162                 data = self.envelope.strip() % ( self.header_initchallenge_template % config.plugins.FritzCall.username.value,
163                                                                                 self._body_builder(kwargs))
164                 if config.plugins.FritzCall.useHttps.value:
165                         url = 'https://%s:%s%s' % (self.address, self.port, self.control_url)
166                 else:
167                         url = 'http://%s:%s%s' % (self.address, self.port, self.control_url)
168
169                 # self.debug("url: " + url + "\n" + data)
170                 getPage(url,
171                         method = "POST",
172                         agent = USERAGENT,
173                         headers = headers,
174                         postdata = data).addCallback(self._okExecute, callback, **kwargs).addErrback(self._errorExecute, callback)
175
176         def _okExecute(self, content, callback, **kwargs):
177                 # self.debug("")
178                 if self.logger.getEffectiveLevel() == logging.DEBUG:
179                         linkP = open("/tmp/FritzCall_okExecute.xml", "w")
180                         linkP.write(content)
181                         linkP.close()
182                 root = ET.fromstring(content)
183                 if root.find(".//Nonce") != None and root.find(".//Realm") != None:
184                         nonce = root.find(".//Nonce").text
185                         realm = root.find(".//Realm").text
186                         secret = md5.new(config.plugins.FritzCall.username.value + ":" +
187                                                 realm + ":" +
188                                                 self.password).hexdigest()
189                         response = md5.new(secret + ":" + nonce).hexdigest()
190                         # self.debug("user %s, passwort %s", config.plugins.FritzCall.username.value, self.password)
191                         header_clientauth = self.header_clientauth_template % (
192                                                                                                                                 nonce,
193                                                                                                                                 response,
194                                                                                                                                 config.plugins.FritzCall.username.value,
195                                                                                                                                 realm)
196                 else: # Anmeldung im Heimnetz ohne Passwort
197                         self.debug("Anmeldung im Heimnetz ohne Passwort!")
198                         header_clientauth = ""
199
200                 headers = self.header.copy()
201                 headers['soapaction'] = '%s#%s' % (self.service_type, self.name)
202                 # self.debug("headers: " + repr(headers))
203                 data = self.envelope.strip() % ( header_clientauth,
204                                                                                 self._body_builder(kwargs))
205
206                 if config.plugins.FritzCall.useHttps.value:
207                         url = 'https://%s:%s%s' % (self.address, self.port, self.control_url)
208                 else:
209                         url = 'http://%s:%s%s' % (self.address, self.port, self.control_url)
210
211                 # self.debug("url: " + url + "\n" + data)
212                 getPage(url,
213                         method = "POST",
214                         agent = USERAGENT,
215                         headers = headers,
216                         postdata = data).addCallback(self.parse_response, callback).addErrback(self._errorExecute, callback)
217
218         def _errorExecute(self, error, callback):
219                 # text = _("FRITZ!Box - Error getting status: %s") % error.getErrorMessage()
220                 self.error(error)
221                 callback(error)
222
223         def parse_response(self, response, callback):
224                 """
225                 Evaluates the action-call response from a FritzBox.
226                 The response is a xml byte-string.
227                 Returns a dictionary with the received arguments-value pairs.
228                 The values are converted according to the given data_types.
229                 TODO: boolean and signed integers data-types from tr64 responses
230                 """
231                 # self.debug("")
232                 if self.logger.getEffectiveLevel() == logging.DEBUG:
233                         linkP = open("/tmp/FritzCall_parse_response.xml", "w")
234                         linkP.write(response)
235                         linkP.close()
236                 result = {}
237                 root = ET.fromstring(response)
238                 errorCode = root.find(".//{urn:dslforum-org:control-1-0}errorCode")
239                 errorDescription = root.find(".//{urn:dslforum-org:control-1-0}errorDescription")
240                 # self.debug("errorCode: %s, errorDescription; %s", repr(errorCode), repr(errorDescription))
241                 if errorCode is not None:
242                         if errorDescription:
243                                 self.error("ErrorCode: %s, errorDescription: %s", repr(errorCode), repr(errorDescription))
244                         else:
245                                 self.error("ErrorCode: %s, no errorDescription", repr(errorCode))
246                 for argument in self.arguments.values():
247                         # self.debug("Argument: " + argument.name)
248                         try:
249                                 value = root.find('.//%s' % argument.name).text
250                         except AttributeError:
251                                 # will happen by searching for in-parameters and by
252                                 # parsing responses with status_code != 200
253                                 continue
254                         if argument.data_type.startswith('ui'):
255                                 try:
256                                         value = int(value)
257                                 except ValueError:
258                                         # should not happen
259                                         value = None
260                                 except TypeError:
261                                         # raised in case that value is None. Should also not happen.
262                                         value = None
263                         result[argument.name] = value
264                 callback(result)
265
266
267 class FritzActionArgument(object):
268         """Attribute class for arguments."""
269         name = ''
270         direction = ''
271         data_type = ''
272
273         @property
274         def info(self):
275                 return (self.name, self.direction, self.data_type)
276
277
278 class FritzService(object):
279         """Attribute class for service."""
280         logger = logging.getLogger("FritzCall.FritzService")
281         debug = logger.debug
282         info = logger.info
283         warn = logger.warn
284         error = logger.error
285         exception = logger.exception
286
287         def __init__(self, service_type, control_url, scpd_url):
288                 # self.debug("")
289                 self.service_type = service_type
290                 self.control_url = control_url
291                 self.scpd_url = scpd_url
292                 self.actions = {}
293                 self.name = ':'.join(service_type.split(':')[-2:])
294
295 def namespace(element):
296         m = re.match(r'\{.*\}', element.tag)
297         return m.group(0) if m else ''
298
299 class FritzXmlParser(object):
300         """Base class for parsing fritzbox-xml-files."""
301         logger = logging.getLogger("FritzCall.FritzXmlParser")
302         debug = logger.debug
303         info = logger.info
304         warn = logger.warn
305         error = logger.error
306         exception = logger.exception
307
308         def __init__(self, address, port, filename=None, service=None, callback=None):
309                 """Loads and parses an xml-file from a FritzBox."""
310                 # self.debug("addr: %s, port: %s, filename: %s" %(address, port, repr(filename)))
311                 self.service = service
312                 self.callback = callback
313                 if address is None:
314                         source = filename
315                         self.debug("source: %s", source)
316                         tree = ET.parse(source)
317                         self.root = tree.getroot()
318                         self.namespace = namespace(self.root)
319                 else:
320                         self.root = None
321                         if config.plugins.FritzCall.useHttps.value:
322                                 source = 'https://{0}:{1}/{2}'.format(address, port, filename)
323                         else:
324                                 source = 'http://{0}:{1}/{2}'.format(address, port, filename)
325                         self.debug("source: %s", source)
326                         getPage(source,
327                                 method = "GET",).addCallback(self._okInit).addErrback(self._errorInit)
328
329         def _okInit(self, source):
330                 # self.debug("")
331                 self.root = ET.fromstring(source)
332                 self.namespace = namespace(self.root)
333                 if self.service:
334                         self.callback(self.service, self)
335                 else:
336                         self.callback(self)
337
338         def _errorInit(self, error):
339                 self.exception(error)
340                 self.info("Switching to http")
341                 config.plugins.FritzCall.useHttps.value = False
342                 config.plugins.FritzCall.useHttps.save()
343                 source = 'http://{0}:{1}/{2}'.format(address, port, filename)
344                 self.debug("source: %s", source)
345                 getPage(source,
346                                 method = "GET",).addCallback(self._okInit).addErrback(self._errorInit)
347                 
348
349         def nodename(self, name):
350                 #self.debug("name: %s, QName: %s" %(name, ET.QName(self.root, name).text))
351                 """Extends name with the xmlns-prefix to a valid nodename."""
352                 found = re.match('{.*({.*}).*}(.*$)', ET.QName(self.root, name).text)
353                 if found:
354                         # self.debug("result: " + found.group(1) + found.group(2))
355                         return found.group(1) + found.group(2)
356                 else:
357                         return ""
358
359
360 class FritzDescParser(FritzXmlParser):
361         """Class for parsing desc.xml-files."""
362         logger = logging.getLogger("FritzCall.FritzDescParser")
363
364         def get_modelname(self):
365                 """Returns the FritzBox model name."""
366                 xpath = '%s/%s' % (self.nodename('device'), self.nodename('modelName'))
367                 # self.debug("Xpath %s found: %s" % (xpath, self.root.find(xpath).text))
368 #               self.debug("Model name: " + self.root.find(xpath).text)
369                 return self.root.find(xpath).text
370
371         def get_services(self):
372                 """Returns a list of FritzService-objects."""
373                 # self.debug("")
374                 result = []
375                 nodes = self.root.iterfind(".//%s/%s" % (self.nodename('serviceList'), self.nodename('service')), namespaces={'ns': self.namespace})
376                 for node in nodes:
377                         # self.debug("service")
378                         result.append(FritzService(
379                                 node.find(self.nodename('serviceType')).text,
380                                 node.find(self.nodename('controlURL')).text,
381                                 node.find(self.nodename('SCPDURL')).text))
382                 # self.debug("result: " + repr(result))
383                 return result
384
385
386 class FritzSCDPParser(FritzXmlParser):
387         """Class for parsing SCDP.xml-files"""
388         logger = logging.getLogger("FritzCall.FritzXmlParser")
389
390         def __init__(self, address, port, service, filename=None, callback=None):
391                 """
392                 Reads and parses a SCDP.xml-file from FritzBox.
393                 'service' is a tuple of containing:
394                 (serviceType, controlURL, SCPDURL)
395                 'service' is a FritzService object:
396                 """
397                 self.state_variables = {}
398                 # self.debug("Service: " + service.name)
399                 self.service = service
400                 if filename is None:
401                         # access the FritzBox
402                         super(FritzSCDPParser, self).__init__(address, port,
403                                                                                                   service.scpd_url, service=service, callback=callback)
404                 else:
405                         # for testing read the xml-data from a file
406                         super(FritzSCDPParser, self).__init__(None, None, filename=filename, callback=callback)
407
408         def _read_state_variables(self):
409                 """
410                 Reads the stateVariable information from the xml-file.
411                 The information we like to extract are name and dataType so we
412                 can assign them later on to FritzActionArgument-instances.
413                 Returns a dictionary: key:value = name:dataType
414                 """
415                 nodes = self.root.iterfind('.//' + self.namespace + 'stateVariable')
416                 for node in nodes:
417                         key = node.find(self.nodename('name')).text
418                         value = node.find(self.nodename('dataType')).text
419                         self.state_variables[key] = value
420
421         def get_actions(self, action_parameters):
422                 """Returns a list of FritzAction instances."""
423                 # self.debug("")
424                 self._read_state_variables()
425                 actions = []
426                 nodes = self.root.iterfind('.//' + self.namespace + 'action')
427                 for node in nodes:
428                         action = FritzAction(self.service.service_type,
429                                                                  self.service.control_url,
430                                                                  action_parameters)
431                         action.name = node.find(self.nodename('name')).text
432                         # self.debug("node: " + action.name)
433                         action.arguments = self._get_arguments(node)
434                         actions.append(action)
435                 return actions
436
437         def _get_arguments(self, action_node):
438                 """
439                 Returns a dictionary of arguments for the given action_node.
440                 """
441                 arguments = {}
442                 # self.debug(r'.//' + self.namespace + ':argumentList/' + self.namespace + ':argument')
443                 argument_nodes = action_node.iterfind(r'.//' + self.namespace + 'argumentList/' + self.namespace + 'argument')
444                 for argument_node in argument_nodes:
445                         # self.debug("argument")
446                         argument = self._get_argument(argument_node)
447                         arguments[argument.name] = argument
448                 return arguments
449
450         def _get_argument(self, argument_node):
451                 """
452                 Returns a FritzActionArgument instance for the given argument_node.
453                 """
454                 # self.debug("")
455                 argument = FritzActionArgument()
456                 argument.name = argument_node.find(self.nodename('name')).text
457                 argument.direction = argument_node.find(self.nodename('direction')).text
458                 rsv = argument_node.find(self.nodename('relatedStateVariable')).text
459                 # TODO: track malformed xml-nodes (i.e. misspelled)
460                 argument.data_type = self.state_variables.get(rsv, None)
461                 return argument
462
463
464 class FritzConnection(object):
465         """
466         FritzBox-Interface for status-information
467         """
468         logger = logging.getLogger("FritzCall.FritzConnection")
469         debug = logger.debug
470         info = logger.info
471         warn = logger.warn
472         error = logger.error
473         exception = logger.exception
474
475         def __init__(self, address=FRITZ_IP_ADDRESS,
476                                            port=FRITZ_TCP_PORT,
477                                            user=FRITZ_USERNAME,
478                                            password='',
479                                            servicesToGet=None):
480                 # self.debug("")
481                 if password and type(password) is list:
482                         password = password[0]
483                 if user and type(user) is list:
484                         user = user[0]
485                 # The keys of the dictionary are becoming FritzAction instance
486                 # attributes on calling the FritzSCDPParser.get_actions() method
487                 # in self._read_services():
488                 self.action_parameters = {
489                         'address': address,
490                         'port': port,
491                         'user': user,
492                         'password': password
493                 }
494                 self.address = address
495                 self.port = port
496                 self.servicesToGet = servicesToGet
497                 self.modelname = None
498                 self.services = {}
499                 self._read_descriptions(password)
500
501         def _read_descriptions(self, password):
502                 """
503                 Read and evaluate the igddesc.xml file
504                 and the tr64desc.xml file if a password is given.
505                 """
506                 # self.debug("")
507                 descfiles = [FRITZ_IGD_DESC_FILE]
508                 if password:
509                         descfiles.append(FRITZ_TR64_DESC_FILE)
510                 for descfile in descfiles:
511                         # self.debug("descfile: %s", descfile)
512                         try:
513                                 FritzDescParser(self.address, self.port, descfile, callback=self._read_descriptions_cb)
514                         except IOError:
515                                 # failed to load a resource. Can happen on customized models
516                                 # missing the igddesc.xml file.
517                                 # It's save to ignore this error.
518                                 self.error("IOError")
519                                 continue
520
521         def _read_descriptions_cb(self, parser):
522                 # self.debug("")
523                 if not self.modelname:
524                         self.modelname = parser.get_modelname()
525                 # self.debug("parser")
526                 services = parser.get_services()
527                 self._read_services(services)
528
529         def _read_services(self, services):
530                 """Get actions from services."""
531                 # self.debug("")
532                 for service in services:
533                         # self.debug("Service: " + service.name + " Control URL: " + service.control_url+ " SCPD URL: " + service.scpd_url)
534                         # self.debug("servicesToGet: " + repr(self.servicesToGet))
535                         if self.servicesToGet and service.name in self.servicesToGet:
536                                 self.debug("Get service: %s", service.name)
537                                 FritzSCDPParser(self.address, self.port, service, callback=self._read_services_cb)
538
539         def _read_services_cb(self, service, parser):
540                 # self.debug("Service: " + service.name)
541                 actions = parser.get_actions(self.action_parameters)
542                 # not in Python 2.6
543                 # try:
544                         # service.actions = {action.name: action for action in actions}
545                 # except:
546                 service.actions = dict((action.name, action) for action in actions)
547                 # self.debug("Service: " + repr(service))
548                 self.services[service.name] = service
549
550         # @property
551         def actionnames(self):
552                 """
553                 Returns a alphabetical sorted list of tuples with all known
554                 service- and action-names.
555                 """
556                 actions = []
557                 for service_name in sorted(self.services.keys()):
558                         action_names = self.services[service_name].actions.keys()
559                         for action_name in sorted(action_names):
560                                 actions.append((service_name, action_name))
561                 return actions
562
563         def _get_action(self, service_name, action_name):
564                 """
565                 Returns an action-object (an instance of FritzAction) with the
566                 given action_name from the given service.
567                 Raises a ServiceError-Exeption in case of an unknown
568                 service_name and an ActionError in case of an unknown
569                 action_name.
570                 """
571                 # self.debug("")
572                 try:
573                         service = self.services[service_name]
574                 except KeyError:
575                         raise ServiceError('Unknown Service: ' + service_name)
576                 try:
577                         action = service.actions[action_name]
578                 except KeyError:
579                         raise ActionError('Unknown Action: ' + action_name)
580                 return action
581
582         def get_action_arguments(self, service_name, action_name):
583                 """
584                 Returns a list of tuples with all known arguments for the given
585                 service- and action-name combination. The tuples contain the
586                 argument-name, direction and data_type.
587                 """
588                 # self.debug("")
589                 action = self._get_action(service_name, action_name)
590                 return action.info
591
592         def call_action(self, callback, service_name, action_name, **kwargs):
593                 """
594                 Executes the given action. Raise a KeyError on unkown actions.
595                 service_name can end with an identifier ':n' (with n as an
596                 integer) to differentiate between different services with the
597                 same name, like WLANConfiguration:1 or WLANConfiguration:2. In
598                 case the service_name does not end with an identifier the id
599                 ':1' will get added by default.
600                 """
601                 # self.debug("")
602                 if not ':' in service_name:
603                         service_name += ':1'
604                 action = self._get_action(service_name, action_name)
605                 action.execute(callback, **kwargs)
606
607         def reconnect(self):
608                 """
609                 Terminate the connection and reconnects with a new ip.
610                 Will raise a KeyError if this command is unknown (by any means).
611                 """
612                 self.call_action(None, 'WANIPConnection', 'ForceTermination')