[FritzCall] FIX more tolerant to login failures, login with no
[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 = 49000
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                 url = 'http://%s:%s%s' % (self.address, self.port, self.control_url)
165
166                 # self.debug("url: " + url + "\n" + data)
167                 getPage(url,
168                         method = "POST",
169                         agent = USERAGENT,
170                         headers = headers,
171                         postdata = data).addCallback(self._okExecute, callback, **kwargs).addErrback(self._errorExecute, callback)
172
173         def _okExecute(self, content, callback, **kwargs):
174                 # self.debug("")
175                 if self.logger.getEffectiveLevel() == logging.DEBUG:
176                         linkP = open("/tmp/FritzCall_okExecute.xml", "w")
177                         linkP.write(content)
178                         linkP.close()
179                 root = ET.fromstring(content)
180                 if root.find(".//Nonce") != None and root.find(".//Realm") != None:
181                         nonce = root.find(".//Nonce").text
182                         realm = root.find(".//Realm").text
183                         secret = md5.new(config.plugins.FritzCall.username.value + ":" +
184                                                 realm + ":" +
185                                                 self.password).hexdigest()
186                         response = md5.new(secret + ":" + nonce).hexdigest()
187                         # self.debug("user %s, passwort %s", config.plugins.FritzCall.username.value, self.password)
188                         header_clientauth = self.header_clientauth_template % (
189                                                                                                                                 nonce,
190                                                                                                                                 response,
191                                                                                                                                 config.plugins.FritzCall.username.value,
192                                                                                                                                 realm)
193                 else: # Anmeldung im Heimnetz ohne Passwort
194                         self.debug("Anmeldung im Heimnetz ohne Passwort!")
195                         header_clientauth = ""
196
197                 headers = self.header.copy()
198                 headers['soapaction'] = '%s#%s' % (self.service_type, self.name)
199                 data = self.envelope.strip() % ( header_clientauth,
200                                                                                 self._body_builder(kwargs))
201
202                 url = 'http://%s:%s%s' % (self.address, self.port, self.control_url)
203                 # self.debug("url: " + url + "\n" + data)
204                 getPage(url,
205                         method = "POST",
206                         agent = USERAGENT,
207                         headers = headers,
208                         postdata = data).addCallback(self.parse_response, callback).addErrback(self._errorExecute, callback)
209
210         def _errorExecute(self, error, callback):
211                 # text = _("FRITZ!Box - Error getting status: %s") % error.getErrorMessage()
212                 self.error(error)
213                 callback(error)
214
215         def parse_response(self, response, callback):
216                 """
217                 Evaluates the action-call response from a FritzBox.
218                 The response is a xml byte-string.
219                 Returns a dictionary with the received arguments-value pairs.
220                 The values are converted according to the given data_types.
221                 TODO: boolean and signed integers data-types from tr64 responses
222                 """
223                 # self.debug("")
224                 if self.logger.getEffectiveLevel() == logging.DEBUG:
225                         linkP = open("/tmp/FritzCall_parse_response.xml", "w")
226                         linkP.write(response)
227                         linkP.close()
228                 result = {}
229                 root = ET.fromstring(response)
230                 errorCode = root.find(".//{urn:dslforum-org:control-1-0}errorCode")
231                 errorDescription = root.find(".//{urn:dslforum-org:control-1-0}errorDescription")
232                 # self.debug("errorCode: %s, errorDescription; %s", repr(errorCode), repr(errorDescription))
233                 if errorCode is not None:
234                         if errorDescription:
235                                 self.error("ErrorCode: %s, errorDescription: %s", repr(errorCode), repr(errorDescription))
236                         else:
237                                 self.error("ErrorCode: %s, no errorDescription", repr(errorCode))
238                 for argument in self.arguments.values():
239                         # self.debug("Argument: " + argument.name)
240                         try:
241                                 value = root.find('.//%s' % argument.name).text
242                         except AttributeError:
243                                 # will happen by searching for in-parameters and by
244                                 # parsing responses with status_code != 200
245                                 continue
246                         if argument.data_type.startswith('ui'):
247                                 try:
248                                         value = int(value)
249                                 except ValueError:
250                                         # should not happen
251                                         value = None
252                                 except TypeError:
253                                         # raised in case that value is None. Should also not happen.
254                                         value = None
255                         result[argument.name] = value
256                 callback(result)
257
258
259 class FritzActionArgument(object):
260         """Attribute class for arguments."""
261         name = ''
262         direction = ''
263         data_type = ''
264
265         @property
266         def info(self):
267                 return (self.name, self.direction, self.data_type)
268
269
270 class FritzService(object):
271         """Attribute class for service."""
272         logger = logging.getLogger("FritzCall.FritzService")
273         debug = logger.debug
274         info = logger.info
275         warn = logger.warn
276         error = logger.error
277         exception = logger.exception
278
279         def __init__(self, service_type, control_url, scpd_url):
280                 # self.debug("")
281                 self.service_type = service_type
282                 self.control_url = control_url
283                 self.scpd_url = scpd_url
284                 self.actions = {}
285                 self.name = ':'.join(service_type.split(':')[-2:])
286
287 def namespace(element):
288         m = re.match(r'\{.*\}', element.tag)
289         return m.group(0) if m else ''
290
291 class FritzXmlParser(object):
292         """Base class for parsing fritzbox-xml-files."""
293         logger = logging.getLogger("FritzCall.FritzXmlParser")
294         debug = logger.debug
295         info = logger.info
296         warn = logger.warn
297         error = logger.error
298         exception = logger.exception
299
300         def __init__(self, address, port, filename=None, service=None, callback=None):
301                 """Loads and parses an xml-file from a FritzBox."""
302                 # self.debug("addr: %s, port: %s, filename: %s" %(address, port, repr(filename)))
303                 self.service = service
304                 self.callback = callback
305                 if address is None:
306                         source = filename
307                         # self.debug("source: %s", source)
308                         tree = ET.parse(source)
309                         self.root = tree.getroot()
310                         self.namespace = namespace(self.root)
311                 else:
312                         self.root = None
313                         source = 'http://{0}:{1}/{2}'.format(address, port, filename)
314                         # self.debug("source: %s", source)
315                         getPage(source,
316                                 method = "GET",).addCallback(self._okInit).addErrback(self._errorInit)
317
318         def _okInit(self, source):
319                 # self.debug("")
320                 self.root = ET.fromstring(source)
321                 self.namespace = namespace(self.root)
322                 if self.service:
323                         self.callback(self.service, self)
324                 else:
325                         self.callback(self)
326
327         def _errorInit(self, error):
328                 self.exception(error)
329
330         def nodename(self, name):
331                 #self.debug("name: %s, QName: %s" %(name, ET.QName(self.root, name).text))
332                 """Extends name with the xmlns-prefix to a valid nodename."""
333                 found = re.match('{.*({.*}).*}(.*$)', ET.QName(self.root, name).text)
334                 if found:
335                         # self.debug("result: " + found.group(1) + found.group(2))
336                         return found.group(1) + found.group(2)
337                 else:
338                         return ""
339
340
341 class FritzDescParser(FritzXmlParser):
342         """Class for parsing desc.xml-files."""
343         logger = logging.getLogger("FritzCall.FritzDescParser")
344
345         def get_modelname(self):
346                 """Returns the FritzBox model name."""
347                 xpath = '%s/%s' % (self.nodename('device'), self.nodename('modelName'))
348                 # self.debug("Xpath %s found: %s" % (xpath, self.root.find(xpath).text))
349 #               self.debug("Model name: " + self.root.find(xpath).text)
350                 return self.root.find(xpath).text
351
352         def get_services(self):
353                 """Returns a list of FritzService-objects."""
354                 # self.debug("")
355                 result = []
356                 nodes = self.root.iterfind(".//%s/%s" % (self.nodename('serviceList'), self.nodename('service')), namespaces={'ns': self.namespace})
357                 for node in nodes:
358                         # self.debug("service")
359                         result.append(FritzService(
360                                 node.find(self.nodename('serviceType')).text,
361                                 node.find(self.nodename('controlURL')).text,
362                                 node.find(self.nodename('SCPDURL')).text))
363                 # self.debug("result: " + repr(result))
364                 return result
365
366
367 class FritzSCDPParser(FritzXmlParser):
368         """Class for parsing SCDP.xml-files"""
369         logger = logging.getLogger("FritzCall.FritzXmlParser")
370
371         def __init__(self, address, port, service, filename=None, callback=None):
372                 """
373                 Reads and parses a SCDP.xml-file from FritzBox.
374                 'service' is a tuple of containing:
375                 (serviceType, controlURL, SCPDURL)
376                 'service' is a FritzService object:
377                 """
378                 self.state_variables = {}
379                 # self.debug("Service: " + service.name)
380                 self.service = service
381                 if filename is None:
382                         # access the FritzBox
383                         super(FritzSCDPParser, self).__init__(address, port,
384                                                                                                   service.scpd_url, service=service, callback=callback)
385                 else:
386                         # for testing read the xml-data from a file
387                         super(FritzSCDPParser, self).__init__(None, None, filename=filename, callback=callback)
388
389         def _read_state_variables(self):
390                 """
391                 Reads the stateVariable information from the xml-file.
392                 The information we like to extract are name and dataType so we
393                 can assign them later on to FritzActionArgument-instances.
394                 Returns a dictionary: key:value = name:dataType
395                 """
396                 nodes = self.root.iterfind('.//' + self.namespace + 'stateVariable')
397                 for node in nodes:
398                         key = node.find(self.nodename('name')).text
399                         value = node.find(self.nodename('dataType')).text
400                         self.state_variables[key] = value
401
402         def get_actions(self, action_parameters):
403                 """Returns a list of FritzAction instances."""
404                 # self.debug("")
405                 self._read_state_variables()
406                 actions = []
407                 nodes = self.root.iterfind('.//' + self.namespace + 'action')
408                 for node in nodes:
409                         action = FritzAction(self.service.service_type,
410                                                                  self.service.control_url,
411                                                                  action_parameters)
412                         action.name = node.find(self.nodename('name')).text
413                         # self.debug("node: " + action.name)
414                         action.arguments = self._get_arguments(node)
415                         actions.append(action)
416                 return actions
417
418         def _get_arguments(self, action_node):
419                 """
420                 Returns a dictionary of arguments for the given action_node.
421                 """
422                 arguments = {}
423                 # self.debug(r'.//' + self.namespace + ':argumentList/' + self.namespace + ':argument')
424                 argument_nodes = action_node.iterfind(r'.//' + self.namespace + 'argumentList/' + self.namespace + 'argument')
425                 for argument_node in argument_nodes:
426                         # self.debug("argument")
427                         argument = self._get_argument(argument_node)
428                         arguments[argument.name] = argument
429                 return arguments
430
431         def _get_argument(self, argument_node):
432                 """
433                 Returns a FritzActionArgument instance for the given argument_node.
434                 """
435                 # self.debug("")
436                 argument = FritzActionArgument()
437                 argument.name = argument_node.find(self.nodename('name')).text
438                 argument.direction = argument_node.find(self.nodename('direction')).text
439                 rsv = argument_node.find(self.nodename('relatedStateVariable')).text
440                 # TODO: track malformed xml-nodes (i.e. misspelled)
441                 argument.data_type = self.state_variables.get(rsv, None)
442                 return argument
443
444
445 class FritzConnection(object):
446         """
447         FritzBox-Interface for status-information
448         """
449         logger = logging.getLogger("FritzCall.FritzConnection")
450         debug = logger.debug
451         info = logger.info
452         warn = logger.warn
453         error = logger.error
454         exception = logger.exception
455
456         def __init__(self, address=FRITZ_IP_ADDRESS,
457                                            port=FRITZ_TCP_PORT,
458                                            user=FRITZ_USERNAME,
459                                            password='',
460                                            servicesToGet=None):
461                 # self.debug("")
462                 if password and type(password) is list:
463                         password = password[0]
464                 if user and type(user) is list:
465                         user = user[0]
466                 # The keys of the dictionary are becoming FritzAction instance
467                 # attributes on calling the FritzSCDPParser.get_actions() method
468                 # in self._read_services():
469                 self.action_parameters = {
470                         'address': address,
471                         'port': port,
472                         'user': user,
473                         'password': password
474                 }
475                 self.address = address
476                 self.port = port
477                 self.servicesToGet = servicesToGet
478                 self.modelname = None
479                 self.services = {}
480                 self._read_descriptions(password)
481
482         def _read_descriptions(self, password):
483                 """
484                 Read and evaluate the igddesc.xml file
485                 and the tr64desc.xml file if a password is given.
486                 """
487                 # self.debug("")
488                 descfiles = [FRITZ_IGD_DESC_FILE]
489                 if password:
490                         descfiles.append(FRITZ_TR64_DESC_FILE)
491                 for descfile in descfiles:
492                         # self.debug("descfile: %s", descfile)
493                         try:
494                                 FritzDescParser(self.address, self.port, descfile, callback=self._read_descriptions_cb)
495                         except IOError:
496                                 # failed to load a resource. Can happen on customized models
497                                 # missing the igddesc.xml file.
498                                 # It's save to ignore this error.
499                                 self.error("IOError")
500                                 continue
501
502         def _read_descriptions_cb(self, parser):
503                 # self.debug("")
504                 if not self.modelname:
505                         self.modelname = parser.get_modelname()
506                 # self.debug("parser")
507                 services = parser.get_services()
508                 self._read_services(services)
509
510         def _read_services(self, services):
511                 """Get actions from services."""
512                 # self.debug("")
513                 for service in services:
514                         # self.debug("Service: " + service.name + " Control URL: " + service.control_url+ " SCPD URL: " + service.scpd_url)
515                         # self.debug("servicesToGet: " + repr(self.servicesToGet))
516                         if self.servicesToGet and service.name in self.servicesToGet:
517                                 self.debug("Get service: %s", service.name)
518                                 FritzSCDPParser(self.address, self.port, service, callback=self._read_services_cb)
519
520         def _read_services_cb(self, service, parser):
521                 # self.debug("Service: " + service.name)
522                 actions = parser.get_actions(self.action_parameters)
523                 service.actions = {action.name: action for action in actions}
524                 self.services[service.name] = service
525
526         # @property
527         def actionnames(self):
528                 """
529                 Returns a alphabetical sorted list of tuples with all known
530                 service- and action-names.
531                 """
532                 actions = []
533                 for service_name in sorted(self.services.keys()):
534                         action_names = self.services[service_name].actions.keys()
535                         for action_name in sorted(action_names):
536                                 actions.append((service_name, action_name))
537                 return actions
538
539         def _get_action(self, service_name, action_name):
540                 """
541                 Returns an action-object (an instance of FritzAction) with the
542                 given action_name from the given service.
543                 Raises a ServiceError-Exeption in case of an unknown
544                 service_name and an ActionError in case of an unknown
545                 action_name.
546                 """
547                 # self.debug("")
548                 try:
549                         service = self.services[service_name]
550                 except KeyError:
551                         raise ServiceError('Unknown Service: ' + service_name)
552                 try:
553                         action = service.actions[action_name]
554                 except KeyError:
555                         raise ActionError('Unknown Action: ' + action_name)
556                 return action
557
558         def get_action_arguments(self, service_name, action_name):
559                 """
560                 Returns a list of tuples with all known arguments for the given
561                 service- and action-name combination. The tuples contain the
562                 argument-name, direction and data_type.
563                 """
564                 # self.debug("")
565                 action = self._get_action(service_name, action_name)
566                 return action.info
567
568         def call_action(self, callback, service_name, action_name, **kwargs):
569                 """
570                 Executes the given action. Raise a KeyError on unkown actions.
571                 service_name can end with an identifier ':n' (with n as an
572                 integer) to differentiate between different services with the
573                 same name, like WLANConfiguration:1 or WLANConfiguration:2. In
574                 case the service_name does not end with an identifier the id
575                 ':1' will get added by default.
576                 """
577                 # self.debug("")
578                 if not ':' in service_name:
579                         service_name += ':1'
580                 action = self._get_action(service_name, action_name)
581                 action.execute(callback, **kwargs)
582
583         def reconnect(self):
584                 """
585                 Terminate the connection and reconnects with a new ip.
586                 Will raise a KeyError if this command is unknown (by any means).
587                 """
588                 self.call_action(None, 'WANIPConnection', 'ForceTermination')