From 4137f7cd8a784f5b0705b35cb6984ec1ce06924c Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Tue, 21 May 2019 18:16:30 -0400 Subject: [PATCH 1/9] mypy refactor --- .coveragerc | 4 + .gitignore | 3 + .pylintrc | 440 +++++++++++++++++++++++++ aioupnp/__main__.py | 54 +-- aioupnp/commands.py | 255 +++++++------- aioupnp/device.py | 99 +++--- aioupnp/fault.py | 12 - aioupnp/gateway.py | 231 ++++++++----- aioupnp/interfaces.py | 51 +++ aioupnp/protocols/m_search_patterns.py | 24 +- aioupnp/protocols/multicast.py | 47 ++- aioupnp/protocols/scpd.py | 96 ++++-- aioupnp/protocols/ssdp.py | 129 +++++--- aioupnp/serialization/scpd.py | 48 +-- aioupnp/serialization/soap.py | 85 ++--- aioupnp/serialization/ssdp.py | 190 ++++++----- aioupnp/serialization/xml.py | 81 +++++ aioupnp/upnp.py | 30 +- aioupnp/util.py | 119 +++---- setup.py | 2 +- 20 files changed, 1357 insertions(+), 643 deletions(-) create mode 100644 .coveragerc create mode 100644 .pylintrc create mode 100644 aioupnp/interfaces.py create mode 100644 aioupnp/serialization/xml.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d3ae8b7 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + tests/* + stubs/* diff --git a/.gitignore b/.gitignore index 28fe439..42d95e7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ _trial_temp/ build/ dist/ +html/ +index.html +mypy-html.css .coverage .mypy_cache/ aioupnp.spec diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..c97b3d2 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,440 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS,schema + +# Add files or directories matching the regex patterns to the +# blacklist. The regex matches against base names, not paths. +# `\.#.*` - add emacs tmp files to the blacklist +ignore-patterns=\.#.* + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +# extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable= + anomalous-backslash-in-string, + arguments-differ, + attribute-defined-outside-init, + bad-continuation, + bare-except, + broad-except, + cell-var-from-loop, + consider-iterating-dictionary, + dangerous-default-value, + duplicate-code, + fixme, + global-statement, + inherit-non-class, + invalid-name, + len-as-condition, + locally-disabled, + logging-not-lazy, + missing-docstring, + no-else-return, + no-init, + no-member, + no-self-use, + protected-access, + redefined-builtin, + redefined-outer-name, + redefined-variable-type, + relative-import, + signature-differs, + super-init-not-called, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-nested-blocks, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + trailing-newlines, + undefined-loop-variable, + ungrouped-imports, + unnecessary-lambda, + unused-argument, + unused-variable, + wildcard-import, + wrong-import-order, + wrong-import-position, + deprecated-lambda, + simplifiable-if-statement, + unidiomatic-typecheck, + global-at-module-level, + inconsistent-return-statements, + keyword-arg-before-vararg, + assignment-from-no-return, + useless-return, + assignment-from-none, + stop-iteration-return + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_$|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[BASIC] + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter,input + +# Good variable names which should always be accepted, separated by a comma +# allow `d` as its used frequently for deferred callback chains +good-names=i,j,k,ex,Run,_,d + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules=leveldb,distutils +# Ignoring distutils because: https://github.com/PyCQA/pylint/issues/73 + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). This supports can work +# with qualified names. +# ignored-classes= + + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec,miniupnpc + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=10 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=8 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/aioupnp/__main__.py b/aioupnp/__main__.py index cd8fae8..abef621 100644 --- a/aioupnp/__main__.py +++ b/aioupnp/__main__.py @@ -1,8 +1,11 @@ -import logging import sys +import asyncio +import logging import textwrap +import typing from collections import OrderedDict from aioupnp.upnp import UPnP +from aioupnp.commands import SOAPCommands log = logging.getLogger("aioupnp") handler = logging.StreamHandler() @@ -16,17 +19,18 @@ base_usage = "\n".join(textwrap.wrap( 100, subsequent_indent=' ', break_long_words=False)) + "\n" -def get_help(command): - fn = getattr(UPnP, command) - params = command + " " + " ".join(["[--%s=<%s>]" % (k, k) for k in fn.__annotations__ if k != 'return']) +def get_help(command: str) -> str: + annotations = UPnP.get_annotations(command) + params = command + " " + " ".join(["[--%s=<%s>]" % (k, str(v)) for k, v in annotations.items() if k != 'return']) return base_usage + "\n".join( textwrap.wrap(params, 100, initial_indent=' ', subsequent_indent=' ', break_long_words=False) ) -def main(argv=None, loop=None): - argv = argv or sys.argv - commands = [n for n in dir(UPnP) if hasattr(getattr(UPnP, n, None), "_cli")] +def main(argv: typing.Optional[typing.List[typing.Optional[str]]] = None, + loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> int: + argv = argv or list(sys.argv) + commands = list(SOAPCommands.SOAP_COMMANDS) help_str = "\n".join(textwrap.wrap( " | ".join(commands), 100, initial_indent=' ', subsequent_indent=' ', break_long_words=False )) @@ -41,14 +45,16 @@ def main(argv=None, loop=None): "For help with a specific command:" \ " aioupnp help \n" % (base_usage, help_str) - args = argv[1:] + args: typing.List[str] = [str(arg) for arg in argv[1:]] if args[0] in ['help', '-h', '--help']: if len(args) > 1: if args[1] in commands: - sys.exit(get_help(args[1])) - sys.exit(print(usage)) + print(get_help(args[1])) + return 0 + print(usage) + return 0 - defaults = { + defaults: typing.Dict[str, typing.Union[bool, str, int]] = { 'debug_logging': False, 'interface': 'default', 'gateway_address': '', @@ -57,22 +63,22 @@ def main(argv=None, loop=None): 'unicast': False } - options = OrderedDict() + options: typing.Dict[str, typing.Union[bool, str, int]] = OrderedDict() command = None for arg in args: if arg.startswith("--"): if "=" in arg: k, v = arg.split("=") + options[k.lstrip('--')] = v else: - k, v = arg, True - k = k.lstrip('--') - options[k] = v + options[arg.lstrip('--')] = True else: command = arg break if not command: print("no command given") - sys.exit(print(usage)) + print(usage) + return 0 kwargs = {} for arg in args[len(options)+1:]: if arg.startswith("--"): @@ -81,18 +87,24 @@ def main(argv=None, loop=None): kwargs[k] = v else: break - for k, v in defaults.items(): + for k in defaults: if k not in options: - options[k] = v + options[k] = defaults[k] if options.pop('debug_logging'): log.setLevel(logging.DEBUG) + lan_address: str = str(options.pop('lan_address')) + gateway_address: str = str(options.pop('gateway_address')) + timeout: int = int(options.pop('timeout')) + interface: str = str(options.pop('interface')) + unicast: bool = bool(options.pop('unicast')) + UPnP.run_cli( - command.replace('-', '_'), options, options.pop('lan_address'), options.pop('gateway_address'), - options.pop('timeout'), options.pop('interface'), options.pop('unicast'), kwargs, loop + command.replace('-', '_'), options, lan_address, gateway_address, timeout, interface, unicast, kwargs, loop ) + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/aioupnp/commands.py b/aioupnp/commands.py index d202d22..4e514bf 100644 --- a/aioupnp/commands.py +++ b/aioupnp/commands.py @@ -1,64 +1,67 @@ -import logging +import asyncio import time import typing -from typing import Tuple, Union, List +import functools +import logging +from typing import Tuple from aioupnp.protocols.scpd import scpd_post +from aioupnp.device import Service log = logging.getLogger(__name__) -none_or_str = Union[None, str] -return_type_lambas = { - Union[None, str]: lambda x: x if x is not None and str(x).lower() not in ['none', 'nil'] else None -} -def safe_type(t): - if t is typing.Tuple: - return tuple - if t is typing.List: - return list - if t is typing.Dict: - return dict - if t is typing.Set: - return set - return t +def soap_optional_str(x: typing.Optional[str]) -> typing.Optional[str]: + return x if x is not None and str(x).lower() not in ['none', 'nil'] else None -class SOAPCommand: - def __init__(self, gateway_address: str, service_port: int, control_url: str, service_id: bytes, method: str, - param_types: dict, return_types: dict, param_order: list, return_order: list, loop=None) -> None: - self.gateway_address = gateway_address - self.service_port = service_port - self.control_url = control_url - self.service_id = service_id - self.method = method - self.param_types = param_types - self.param_order = param_order - self.return_types = return_types - self.return_order = return_order - self.loop = loop - self._requests: typing.List = [] +def soap_bool(x: typing.Optional[str]) -> bool: + return False if not x or str(x).lower() in ['false', 'False'] else True - async def __call__(self, **kwargs) -> typing.Union[None, typing.Dict, typing.List, typing.Tuple]: - if set(kwargs.keys()) != set(self.param_types.keys()): - raise Exception("argument mismatch: %s vs %s" % (kwargs.keys(), self.param_types.keys())) - soap_kwargs = {n: safe_type(self.param_types[n])(kwargs[n]) for n in self.param_types.keys()} + +def recast_single_result(t, result): + if t is bool: + return soap_bool(result) + if t is str: + return soap_optional_str(result) + return t(result) + + +def recast_return(return_annotation, result, result_keys: typing.List[str]): + if return_annotation is None: + return None + if len(result_keys) == 1: + assert len(result_keys) == 1 + single_result = result[result_keys[0]] + return recast_single_result(return_annotation, single_result) + + annotated_args: typing.List[type] = list(return_annotation.__args__) + assert len(annotated_args) == len(result_keys) + recast_results: typing.List[typing.Optional[typing.Union[str, int, bool, bytes]]] = [] + for type_annotation, result_key in zip(annotated_args, result_keys): + recast_results.append(recast_single_result(type_annotation, result[result_key])) + return tuple(recast_results) + + +def soap_command(fn): + @functools.wraps(fn) + async def wrapper(self: 'SOAPCommands', **kwargs): + if not self.is_registered(fn.__name__): + return fn(self, **kwargs) + service = self.get_service(fn.__name__) + assert service.controlURL is not None + assert service.serviceType is not None response, xml_bytes, err = await scpd_post( - self.control_url, self.gateway_address, self.service_port, self.method, self.param_order, - self.service_id, self.loop, **soap_kwargs + service.controlURL, self._base_address.decode(), self._port, fn.__name__, self._registered[service][fn.__name__][0], + service.serviceType.encode(), self._loop, **kwargs ) if err is not None: - self._requests.append((soap_kwargs, xml_bytes, None, err, time.time())) + + self._requests.append((fn.__name__, kwargs, xml_bytes, None, err, time.time())) raise err - if not response: - result = None - else: - recast_result = tuple([safe_type(self.return_types[n])(response.get(n)) for n in self.return_order]) - if len(recast_result) == 1: - result = recast_result[0] - else: - result = recast_result - self._requests.append((soap_kwargs, xml_bytes, result, None, time.time())) + result = recast_return(fn.__annotations__.get('return'), response, self._registered[service][fn.__name__][1]) + self._requests.append((fn.__name__, kwargs, xml_bytes, result, None, time.time())) return result + return wrapper class SOAPCommands: @@ -72,7 +75,7 @@ class SOAPCommands: to their expected types. """ - SOAP_COMMANDS = [ + SOAP_COMMANDS: typing.List[str] = [ 'AddPortMapping', 'GetNATRSIPStatus', 'GetGenericPortMappingEntry', @@ -91,59 +94,63 @@ class SOAPCommands: 'GetTotalPacketsReceived', 'X_GetICSStatistics', 'GetDefaultConnectionService', - 'NewDefaultConnectionService', - 'NewEnabledForInternet', 'SetDefaultConnectionService', 'SetEnabledForInternet', 'GetEnabledForInternet', - 'NewActiveConnectionIndex', 'GetMaximumActiveConnections', 'GetActiveConnections' ] - def __init__(self): - self._registered = set() + def __init__(self, loop: asyncio.AbstractEventLoop, base_address: bytes, port: int) -> None: + self._loop = loop + self._registered: typing.Dict[Service, + typing.Dict[str, typing.Tuple[typing.List[str], typing.List[str]]]] = {} + self._base_address = base_address + self._port = port + self._requests: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, + typing.Optional[typing.Dict[str, typing.Any]], + typing.Optional[Exception], float]] = [] - def register(self, base_ip: bytes, port: int, name: str, control_url: str, - service_type: bytes, inputs: List, outputs: List, loop=None) -> None: - if name not in self.SOAP_COMMANDS or name in self._registered: + def is_registered(self, name: str) -> bool: + if name not in self.SOAP_COMMANDS: + raise ValueError("unknown command") + for service in self._registered.values(): + if name in service: + return True + return False + + def get_service(self, name: str) -> Service: + if name not in self.SOAP_COMMANDS: + raise ValueError("unknown command") + for service, commands in self._registered.items(): + if name in commands: + return service + raise ValueError(name) + + def register(self, name: str, service: Service, inputs: typing.List[str], outputs: typing.List[str]) -> None: + # control_url: str, service_type: bytes, + if name not in self.SOAP_COMMANDS: raise AttributeError(name) - current = getattr(self, name) - annotations = current.__annotations__ - return_types = annotations.get('return', None) - if return_types: - if hasattr(return_types, '__args__'): - return_types = tuple([return_type_lambas.get(a, a) for a in return_types.__args__]) - elif isinstance(return_types, type): - return_types = (return_types,) - return_types = {r: t for r, t in zip(outputs, return_types)} - param_types = {} - for param_name, param_type in annotations.items(): - if param_name == "return": - continue - param_types[param_name] = param_type - command = SOAPCommand( - base_ip.decode(), port, control_url, service_type, - name, param_types, return_types, inputs, outputs, loop=loop - ) - setattr(command, "__doc__", current.__doc__) - setattr(self, command.method, command) - self._registered.add(command.method) + if self.is_registered(name): + raise AttributeError(f"{name} is already a registered SOAP command") + if service not in self._registered: + self._registered[service] = {} + self._registered[service][name] = inputs, outputs - @staticmethod - async def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, - NewInternalClient: str, NewEnabled: int, NewPortMappingDescription: str, - NewLeaseDuration: str) -> None: + @soap_command + async def AddPortMapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, + NewInternalClient: str, NewEnabled: int, NewPortMappingDescription: str, + NewLeaseDuration: str) -> None: """Returns None""" raise NotImplementedError() - @staticmethod - async def GetNATRSIPStatus() -> Tuple[bool, bool]: + @soap_command + async def GetNATRSIPStatus(self) -> Tuple[bool, bool]: """Returns (NewRSIPAvailable, NewNATEnabled)""" raise NotImplementedError() - @staticmethod - async def GetGenericPortMappingEntry(NewPortMappingIndex: int) -> Tuple[str, int, str, int, str, + @soap_command + async def GetGenericPortMappingEntry(self, NewPortMappingIndex: int) -> Tuple[str, int, str, int, str, bool, str, int]: """ Returns (NewRemoteHost, NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled, @@ -151,100 +158,100 @@ class SOAPCommands: """ raise NotImplementedError() - @staticmethod - async def GetSpecificPortMappingEntry(NewRemoteHost: str, NewExternalPort: int, + @soap_command + async def GetSpecificPortMappingEntry(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> Tuple[int, str, bool, str, int]: """Returns (NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration)""" raise NotImplementedError() - @staticmethod - async def SetConnectionType(NewConnectionType: str) -> None: + @soap_command + async def SetConnectionType(self, NewConnectionType: str) -> None: """Returns None""" raise NotImplementedError() - @staticmethod - async def GetExternalIPAddress() -> str: + @soap_command + async def GetExternalIPAddress(self) -> str: """Returns (NewExternalIPAddress)""" raise NotImplementedError() - @staticmethod - async def GetConnectionTypeInfo() -> Tuple[str, str]: + @soap_command + async def GetConnectionTypeInfo(self) -> Tuple[str, str]: """Returns (NewConnectionType, NewPossibleConnectionTypes)""" raise NotImplementedError() - @staticmethod - async def GetStatusInfo() -> Tuple[str, str, int]: + @soap_command + async def GetStatusInfo(self) -> Tuple[str, str, int]: """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" raise NotImplementedError() - @staticmethod - async def ForceTermination() -> None: + @soap_command + async def ForceTermination(self) -> None: """Returns None""" raise NotImplementedError() - @staticmethod - async def DeletePortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None: + @soap_command + async def DeletePortMapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None: """Returns None""" raise NotImplementedError() - @staticmethod - async def RequestConnection() -> None: + @soap_command + async def RequestConnection(self) -> None: """Returns None""" raise NotImplementedError() - @staticmethod - async def GetCommonLinkProperties(): + @soap_command + async def GetCommonLinkProperties(self) -> Tuple[str, int, int, str]: """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)""" raise NotImplementedError() - @staticmethod - async def GetTotalBytesSent(): + @soap_command + async def GetTotalBytesSent(self) -> int: """Returns (NewTotalBytesSent)""" raise NotImplementedError() - @staticmethod - async def GetTotalBytesReceived(): + @soap_command + async def GetTotalBytesReceived(self) -> int: """Returns (NewTotalBytesReceived)""" raise NotImplementedError() - @staticmethod - async def GetTotalPacketsSent(): + @soap_command + async def GetTotalPacketsSent(self) -> int: """Returns (NewTotalPacketsSent)""" raise NotImplementedError() - @staticmethod - def GetTotalPacketsReceived(): + @soap_command + async def GetTotalPacketsReceived(self) -> int: """Returns (NewTotalPacketsReceived)""" raise NotImplementedError() - @staticmethod - async def X_GetICSStatistics() -> Tuple[int, int, int, int, str, str]: + @soap_command + async def X_GetICSStatistics(self) -> Tuple[int, int, int, int, str, str]: """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)""" raise NotImplementedError() - @staticmethod - async def GetDefaultConnectionService(): + @soap_command + async def GetDefaultConnectionService(self) -> str: """Returns (NewDefaultConnectionService)""" raise NotImplementedError() - @staticmethod - async def SetDefaultConnectionService(NewDefaultConnectionService: str) -> None: + @soap_command + async def SetDefaultConnectionService(self, NewDefaultConnectionService: str) -> None: """Returns (None)""" raise NotImplementedError() - @staticmethod - async def SetEnabledForInternet(NewEnabledForInternet: bool) -> None: + @soap_command + async def SetEnabledForInternet(self, NewEnabledForInternet: bool) -> None: raise NotImplementedError() - @staticmethod - async def GetEnabledForInternet() -> bool: + @soap_command + async def GetEnabledForInternet(self) -> bool: raise NotImplementedError() - @staticmethod - async def GetMaximumActiveConnections(NewActiveConnectionIndex: int): + @soap_command + async def GetMaximumActiveConnections(self, NewActiveConnectionIndex: int): raise NotImplementedError() - @staticmethod - async def GetActiveConnections() -> Tuple[str, str]: + @soap_command + async def GetActiveConnections(self) -> Tuple[str, str]: """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" raise NotImplementedError() diff --git a/aioupnp/device.py b/aioupnp/device.py index 8502840..d6d84d4 100644 --- a/aioupnp/device.py +++ b/aioupnp/device.py @@ -1,23 +1,33 @@ +from collections import OrderedDict +import typing import logging -from typing import List log = logging.getLogger(__name__) class CaseInsensitive: - def __init__(self, **kwargs) -> None: - for k, v in kwargs.items(): + def __init__(self, **kwargs: typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], typing.List[typing.Any]]]) -> None: + keys: typing.List[str] = list(kwargs.keys()) + for k in keys: if not k.startswith("_"): - setattr(self, k, v) + assert k in kwargs + setattr(self, k, kwargs[k]) - def __getattr__(self, item): - for k in self.__class__.__dict__.keys(): + def __getattr__(self, item: str) -> typing.Union[str, typing.Dict[str, typing.Any], typing.List]: + keys: typing.List[str] = list(self.__class__.__dict__.keys()) + for k in keys: if k.lower() == item.lower(): - return self.__dict__.get(k) + value: typing.Optional[typing.Union[str, typing.Dict[str, typing.Any], + typing.List]] = self.__dict__.get(k) + assert value is not None and isinstance(value, (str, dict, list)) + return value raise AttributeError(item) - def __setattr__(self, item, value): - for k, v in self.__class__.__dict__.items(): + def __setattr__(self, item: str, + value: typing.Union[str, typing.Dict[str, typing.Any], typing.List]) -> None: + assert isinstance(value, (str, dict)), ValueError(f"got type {str(type(value))}, expected str") + keys: typing.List[str] = list(self.__class__.__dict__.keys()) + for k in keys: if k.lower() == item.lower(): self.__dict__[k] = value return @@ -26,52 +36,57 @@ class CaseInsensitive: return raise AttributeError(item) - def as_dict(self) -> dict: - return { - k: v for k, v in self.__dict__.items() if not k.startswith("_") and not callable(v) - } + def as_dict(self) -> typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], typing.List]]: + result: typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], typing.List]] = OrderedDict() + keys: typing.List[str] = list(self.__dict__.keys()) + for k in keys: + if not k.startswith("_"): + result[k] = self.__getattr__(k) + return result class Service(CaseInsensitive): - serviceType = None - serviceId = None - controlURL = None - eventSubURL = None - SCPDURL = None + serviceType: typing.Optional[str] = None + serviceId: typing.Optional[str] = None + controlURL: typing.Optional[str] = None + eventSubURL: typing.Optional[str] = None + SCPDURL: typing.Optional[str] = None class Device(CaseInsensitive): - serviceList = None - deviceList = None - deviceType = None - friendlyName = None - manufacturer = None - manufacturerURL = None - modelDescription = None - modelName = None - modelNumber = None - modelURL = None - serialNumber = None - udn = None - upc = None - presentationURL = None - iconList = None + serviceList: typing.Optional[typing.Dict[str, typing.Union[typing.Dict[str, typing.Any], typing.List]]] = None + deviceList: typing.Optional[typing.Dict[str, typing.Union[typing.Dict[str, typing.Any], typing.List]]] = None + deviceType: typing.Optional[str] = None + friendlyName: typing.Optional[str] = None + manufacturer: typing.Optional[str] = None + manufacturerURL: typing.Optional[str] = None + modelDescription: typing.Optional[str] = None + modelName: typing.Optional[str] = None + modelNumber: typing.Optional[str] = None + modelURL: typing.Optional[str] = None + serialNumber: typing.Optional[str] = None + udn: typing.Optional[str] = None + upc: typing.Optional[str] = None + presentationURL: typing.Optional[str] = None + iconList: typing.Optional[str] = None - def __init__(self, devices: List, services: List, **kwargs) -> None: + def __init__(self, devices: typing.List['Device'], services: typing.List[Service], + **kwargs: typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], typing.List]]) -> None: super(Device, self).__init__(**kwargs) if self.serviceList and "service" in self.serviceList: - new_services = self.serviceList["service"] - if isinstance(new_services, dict): - new_services = [new_services] - services.extend([Service(**service) for service in new_services]) + if isinstance(self.serviceList['service'], dict): + assert isinstance(self.serviceList['service'], dict) + svc_list: typing.Dict[str, typing.Any] = self.serviceList['service'] + services.append(Service(**svc_list)) + elif isinstance(self.serviceList['service'], list): + services.extend(Service(**svc) for svc in self.serviceList["service"]) + if self.deviceList: for kw in self.deviceList.values(): if isinstance(kw, dict): - d = Device(devices, services, **kw) - devices.append(d) + devices.append(Device(devices, services, **kw)) elif isinstance(kw, list): for _inner_kw in kw: - d = Device(devices, services, **_inner_kw) - devices.append(d) + devices.append(Device(devices, services, **_inner_kw)) else: log.warning("failed to parse device:\n%s", kw) diff --git a/aioupnp/fault.py b/aioupnp/fault.py index 6acb914..5aa2630 100644 --- a/aioupnp/fault.py +++ b/aioupnp/fault.py @@ -1,14 +1,2 @@ -from aioupnp.util import flatten_keys -from aioupnp.constants import FAULT, CONTROL - - class UPnPError(Exception): pass - - -def handle_fault(response: dict) -> dict: - if FAULT in response: - fault = flatten_keys(response[FAULT], "{%s}" % CONTROL) - error_description = fault['detail']['UPnPError']['errorDescription'] - raise UPnPError(error_description) - return response diff --git a/aioupnp/gateway.py b/aioupnp/gateway.py index ad04143..1ed4b1e 100644 --- a/aioupnp/gateway.py +++ b/aioupnp/gateway.py @@ -1,9 +1,10 @@ +import re import logging -import socket +import typing import asyncio from collections import OrderedDict -from typing import Dict, List, Union, Type -from aioupnp.util import get_dict_val_case_insensitive, BASE_PORT_REGEX, BASE_ADDRESS_REGEX +from typing import Dict, List, Union +from aioupnp.util import get_dict_val_case_insensitive from aioupnp.constants import SPEC_VERSION, SERVICE from aioupnp.commands import SOAPCommands from aioupnp.device import Device, Service @@ -15,77 +16,94 @@ from aioupnp.fault import UPnPError log = logging.getLogger(__name__) -return_type_lambas = { - Union[None, str]: lambda x: x if x is not None and str(x).lower() not in ['none', 'nil'] else None -} +BASE_ADDRESS_REGEX = re.compile("^(http:\/\/\d*\.\d*\.\d*\.\d*:\d*)\/.*$".encode()) +BASE_PORT_REGEX = re.compile("^http:\/\/\d*\.\d*\.\d*\.\d*:(\d*)\/.*$".encode()) -def get_action_list(element_dict: dict) -> List: # [(, [, ...], [ typing.List[typing.Tuple[str, typing.List[str], typing.List[str]]]: service_info = flatten_keys(element_dict, "{%s}" % SERVICE) + result: typing.List[typing.Tuple[str, typing.List[str], typing.List[str]]] = [] if "actionList" in service_info: action_list = service_info["actionList"] else: - return [] + return result if not len(action_list): # it could be an empty string - return [] + return result - result: list = [] - if isinstance(action_list["action"], dict): - arg_dicts = action_list["action"]['argumentList']['argument'] - if not isinstance(arg_dicts, list): # when there is one arg - arg_dicts = [arg_dicts] - return [[ - action_list["action"]['name'], - [i['name'] for i in arg_dicts if i['direction'] == 'in'], - [i['name'] for i in arg_dicts if i['direction'] == 'out'] - ]] - for action in action_list["action"]: - if not action.get('argumentList'): - result.append((action['name'], [], [])) + action = action_list["action"] + if isinstance(action, dict): + arg_dicts: typing.List[typing.Dict[str, str]] = [] + if not isinstance(action['argumentList']['argument'], list): # when there is one arg + arg_dicts.extend([action['argumentList']['argument']]) else: - arg_dicts = action['argumentList']['argument'] - if not isinstance(arg_dicts, list): # when there is one arg - arg_dicts = [arg_dicts] + arg_dicts.extend(action['argumentList']['argument']) + + result.append((action_list["action"]['name'], [i['name'] for i in arg_dicts if i['direction'] == 'in'], + [i['name'] for i in arg_dicts if i['direction'] == 'out'])) + return result + assert isinstance(action, list) + for _action in action: + if not _action.get('argumentList'): + result.append((_action['name'], [], [])) + else: + if not isinstance(_action['argumentList']['argument'], list): # when there is one arg + arg_dicts = [_action['argumentList']['argument']] + else: + arg_dicts = _action['argumentList']['argument'] result.append(( - action['name'], + _action['name'], [i['name'] for i in arg_dicts if i['direction'] == 'in'], [i['name'] for i in arg_dicts if i['direction'] == 'out'] )) return result +def parse_location(location: bytes) -> typing.Tuple[bytes, int]: + base_address_result: typing.List[bytes] = BASE_ADDRESS_REGEX.findall(location) + base_address = base_address_result[0] + port_result: typing.List[bytes] = BASE_PORT_REGEX.findall(location) + port = int(port_result[0]) + return base_address, port + + class Gateway: - def __init__(self, ok_packet: SSDPDatagram, m_search_args: OrderedDict, lan_address: str, - gateway_address: str) -> None: + commands: SOAPCommands + + def __init__(self, ok_packet: SSDPDatagram, m_search_args: typing.Dict[str, typing.Union[int, str]], + lan_address: str, gateway_address: str, + loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: + self._loop = loop or asyncio.get_event_loop() self._ok_packet = ok_packet self._m_search_args = m_search_args self._lan_address = lan_address - self.usn = (ok_packet.usn or '').encode() - self.ext = (ok_packet.ext or '').encode() - self.server = (ok_packet.server or '').encode() - self.location = (ok_packet.location or '').encode() - self.cache_control = (ok_packet.cache_control or '').encode() - self.date = (ok_packet.date or '').encode() - self.urn = (ok_packet.st or '').encode() + self.usn: bytes = (ok_packet.usn or '').encode() + self.ext: bytes = (ok_packet.ext or '').encode() + self.server: bytes = (ok_packet.server or '').encode() + self.location: bytes = (ok_packet.location or '').encode() + self.cache_control: bytes = (ok_packet.cache_control or '').encode() + self.date: bytes = (ok_packet.date or '').encode() + self.urn: bytes = (ok_packet.st or '').encode() - self._xml_response = b"" - self._service_descriptors: Dict = {} - self.base_address = BASE_ADDRESS_REGEX.findall(self.location)[0] - self.port = int(BASE_PORT_REGEX.findall(self.location)[0]) + self._xml_response: bytes = b"" + self._service_descriptors: Dict[str, bytes] = {} + + self.base_address, self.port = parse_location(self.location) self.base_ip = self.base_address.lstrip(b"http://").split(b":")[0] assert self.base_ip == gateway_address.encode() self.path = self.location.split(b"%s:%i/" % (self.base_ip, self.port))[1] - self.spec_version = None - self.url_base = None + self.spec_version: typing.Optional[str] = None + self.url_base: typing.Optional[str] = None - self._device: Union[None, Device] = None - self._devices: List = [] - self._services: List = [] + self._device: typing.Optional[Device] = None + self._devices: List[Device] = [] + self._services: List[Service] = [] - self._unsupported_actions: Dict = {} - self._registered_commands: Dict = {} - self.commands = SOAPCommands() + self._unsupported_actions: Dict[str, typing.List[str]] = {} + self._registered_commands: Dict[str, str] = {} + self.commands = SOAPCommands(self._loop, self.base_ip, self.port) def gateway_descriptor(self) -> dict: r = { @@ -102,14 +120,15 @@ class Gateway: def manufacturer_string(self) -> str: if not self.devices: return "UNKNOWN GATEWAY" - device = list(self.devices.values())[0] - return "%s %s" % (device.manufacturer, device.modelName) + devices: typing.List[Device] = list(self.devices.values()) + device = devices[0] + return f"{device.manufacturer} {device.modelName}" @property - def services(self) -> Dict: + def services(self) -> Dict[str, Service]: if not self._device: return {} - return {service.serviceType: service for service in self._services} + return {str(service.serviceType): service for service in self._services} @property def devices(self) -> Dict: @@ -117,28 +136,29 @@ class Gateway: return {} return {device.udn: device for device in self._devices} - def get_service(self, service_type: str) -> Union[Type[Service], None]: + def get_service(self, service_type: str) -> typing.Optional[Service]: for service in self._services: - if service.serviceType.lower() == service_type.lower(): + if service.serviceType and service.serviceType.lower() == service_type.lower(): return service return None @property - def soap_requests(self) -> List: - soap_call_infos = [] - for name in self._registered_commands.keys(): - if not hasattr(getattr(self.commands, name), "_requests"): - continue - soap_call_infos.extend([ - (name, request_args, raw_response, decoded_response, soap_error, ts) - for ( - request_args, raw_response, decoded_response, soap_error, ts - ) in getattr(self.commands, name)._requests - ]) + def soap_requests(self) -> typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, + typing.Optional[typing.Dict[str, typing.Any]], + typing.Optional[Exception], float]]: + soap_call_infos: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, + typing.Optional[typing.Dict[str, typing.Any]], + typing.Optional[Exception], float]] = [] + soap_call_infos.extend([ + (name, request_args, raw_response, decoded_response, soap_error, ts) + for ( + name, request_args, raw_response, decoded_response, soap_error, ts + ) in self.commands._requests + ]) soap_call_infos.sort(key=lambda x: x[5]) return soap_call_infos - def debug_gateway(self) -> Dict: + def debug_gateway(self) -> Dict[str, Union[str, bytes, int, Dict, List]]: return { 'manufacturer_string': self.manufacturer_string, 'gateway_address': self.base_ip, @@ -156,9 +176,11 @@ class Gateway: @classmethod async def _discover_gateway(cls, lan_address: str, gateway_address: str, timeout: int = 30, - igd_args: OrderedDict = None, loop=None, unicast: bool = False): - ignored: set = set() - required_commands = [ + igd_args: typing.Optional[typing.Dict[str, typing.Union[int, str]]] = None, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + unicast: bool = False) -> 'Gateway': + ignored: typing.Set[str] = set() + required_commands: typing.List[str] = [ 'AddPortMapping', 'DeletePortMapping', 'GetExternalIPAddress' @@ -166,20 +188,21 @@ class Gateway: while True: if not igd_args: m_search_args, datagram = await fuzzy_m_search( - lan_address, gateway_address, timeout, loop, ignored, unicast + lan_address, gateway_address, timeout, loop, ignored, unicast ) else: m_search_args = OrderedDict(igd_args) datagram = await m_search(lan_address, gateway_address, igd_args, timeout, loop, ignored, unicast) try: - gateway = cls(datagram, m_search_args, lan_address, gateway_address) + gateway = cls(datagram, m_search_args, lan_address, gateway_address, loop=loop) log.debug('get gateway descriptor %s', datagram.location) await gateway.discover_commands(loop) - requirements_met = all([required in gateway._registered_commands for required in required_commands]) + requirements_met = all([gateway.commands.is_registered(required) for required in required_commands]) if not requirements_met: not_met = [ - required for required in required_commands if required not in gateway._registered_commands + required for required in required_commands if not gateway.commands.is_registered(required) ] + assert datagram.location is not None log.debug("found gateway %s at %s, but it does not implement required soap commands: %s", gateway.manufacturer_string, gateway.location, not_met) ignored.add(datagram.location) @@ -188,13 +211,17 @@ class Gateway: log.debug('found gateway device %s', datagram.location) return gateway except (asyncio.TimeoutError, UPnPError) as err: + assert datagram.location is not None log.debug("get %s failed (%s), looking for other devices", datagram.location, str(err)) ignored.add(datagram.location) continue @classmethod async def discover_gateway(cls, lan_address: str, gateway_address: str, timeout: int = 30, - igd_args: OrderedDict = None, loop=None, unicast: bool = None): + igd_args: typing.Optional[typing.Dict[str, typing.Union[int, str]]] = None, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + unicast: typing.Optional[bool] = None) -> 'Gateway': + loop = loop or asyncio.get_event_loop() if unicast is not None: return await cls._discover_gateway(lan_address, gateway_address, timeout, igd_args, loop, unicast) @@ -205,7 +232,7 @@ class Gateway: cls._discover_gateway( lan_address, gateway_address, timeout, igd_args, loop, unicast=False ) - ], return_when=asyncio.tasks.FIRST_COMPLETED) + ], return_when=asyncio.tasks.FIRST_COMPLETED, loop=loop) for task in pending: task.cancel() @@ -214,56 +241,78 @@ class Gateway: task.exception() except asyncio.CancelledError: pass + results: typing.List[asyncio.Future['Gateway']] = list(done) + return results[0].result() - return list(done)[0].result() - - async def discover_commands(self, loop=None): + async def discover_commands(self, loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: response, xml_bytes, get_err = await scpd_get(self.path.decode(), self.base_ip.decode(), self.port, loop=loop) self._xml_response = xml_bytes if get_err is not None: raise get_err - self.spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION) - self.url_base = get_dict_val_case_insensitive(response, "urlbase") + spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION) + if isinstance(spec_version, bytes): + self.spec_version = spec_version.decode() + else: + self.spec_version = spec_version + url_base = get_dict_val_case_insensitive(response, "urlbase") + if isinstance(url_base, bytes): + self.url_base = url_base.decode() + else: + self.url_base = url_base if not self.url_base: self.url_base = self.base_address.decode() if response: - device_dict = get_dict_val_case_insensitive(response, "device") + source_keys: typing.List[str] = list(response.keys()) + matches: typing.List[str] = list(filter(lambda x: x.lower() == "device", source_keys)) + match_key = matches[0] + match: dict = response[match_key] + # if not len(match): + # return None + # if len(match) > 1: + # raise KeyError("overlapping keys") + # if len(match) == 1: + # matched_key: typing.AnyStr = match[0] + # return source[matched_key] + # raise KeyError("overlapping keys") + self._device = Device( - self._devices, self._services, **device_dict + self._devices, self._services, **match ) else: self._device = Device(self._devices, self._services) for service_type in self.services.keys(): await self.register_commands(self.services[service_type], loop) + return None - async def register_commands(self, service: Service, loop=None): + async def register_commands(self, service: Service, + loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: if not service.SCPDURL: raise UPnPError("no scpd url") + if not service.serviceType: + raise UPnPError("no service type") log.debug("get descriptor for %s from %s", service.serviceType, service.SCPDURL) - service_dict, xml_bytes, get_err = await scpd_get(service.SCPDURL, self.base_ip.decode(), self.port) + service_dict, xml_bytes, get_err = await scpd_get(service.SCPDURL, self.base_ip.decode(), self.port, loop=loop) self._service_descriptors[service.SCPDURL] = xml_bytes if get_err is not None: log.debug("failed to get descriptor for %s from %s", service.serviceType, service.SCPDURL) if xml_bytes: log.debug("response: %s", xml_bytes.decode()) - return + return None if not service_dict: - return + return None action_list = get_action_list(service_dict) - for name, inputs, outputs in action_list: try: - self.commands.register(self.base_ip, self.port, name, service.controlURL, service.serviceType.encode(), - inputs, outputs, loop) + self.commands.register(name, service, inputs, outputs) self._registered_commands[name] = service.serviceType log.debug("registered %s::%s", service.serviceType, name) except AttributeError: - s = self._unsupported_actions.get(service.serviceType, []) - s.append(name) - self._unsupported_actions[service.serviceType] = s + self._unsupported_actions.setdefault(service.serviceType, []) + self._unsupported_actions[service.serviceType].append(name) log.debug("available command for %s does not have a wrapper implemented: %s %s %s", service.serviceType, name, inputs, outputs) log.debug("registered service %s", service.serviceType) + return None diff --git a/aioupnp/interfaces.py b/aioupnp/interfaces.py new file mode 100644 index 0000000..78f11ad --- /dev/null +++ b/aioupnp/interfaces.py @@ -0,0 +1,51 @@ +import socket +from collections import OrderedDict +import typing +import netifaces + + +def get_netifaces(): + return netifaces + + +def ifaddresses(iface: str): + return get_netifaces().ifaddresses(iface) + + +def _get_interfaces(): + return get_netifaces().interfaces() + + +def _get_gateways(): + return get_netifaces().gateways() + + +def get_interfaces() -> typing.Dict[str, typing.Tuple[str, str]]: + gateways = _get_gateways() + infos = gateways[socket.AF_INET] + assert isinstance(infos, list), TypeError(f"expected list from netifaces, got a dict") + interface_infos: typing.List[typing.Tuple[str, str, bool]] = infos + result: typing.Dict[str, typing.Tuple[str, str]] = OrderedDict( + (interface_name, (router_address, ifaddresses(interface_name)[netifaces.AF_INET][0]['addr'])) + for router_address, interface_name, _ in interface_infos + ) + for interface_name in _get_interfaces(): + if interface_name in ['lo', 'localhost'] or interface_name in result: + continue + addresses = ifaddresses(interface_name) + if netifaces.AF_INET in addresses: + address = addresses[netifaces.AF_INET][0]['addr'] + gateway_guess = ".".join(address.split(".")[:-1] + ["1"]) + result[interface_name] = (gateway_guess, address) + _default = gateways['default'] + assert isinstance(_default, dict), TypeError(f"expected dict from netifaces, got a list") + default: typing.Dict[int, typing.Tuple[str, str]] = _default + result['default'] = result[default[netifaces.AF_INET][1]] + return result + + +def get_gateway_and_lan_addresses(interface_name: str) -> typing.Tuple[str, str]: + for iface_name, (gateway, lan) in get_interfaces().items(): + if interface_name == iface_name: + return gateway, lan + return '', '' diff --git a/aioupnp/protocols/m_search_patterns.py b/aioupnp/protocols/m_search_patterns.py index 722e5e3..5aed709 100644 --- a/aioupnp/protocols/m_search_patterns.py +++ b/aioupnp/protocols/m_search_patterns.py @@ -44,10 +44,11 @@ ST characters in the domain name must be replaced with hyphens in accordance with RFC 2141. """ +import typing from collections import OrderedDict from aioupnp.constants import SSDP_DISCOVER, SSDP_HOST -SEARCH_TARGETS = [ +SEARCH_TARGETS: typing.List[str] = [ 'upnp:rootdevice', 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', 'urn:schemas-wifialliance-org:device:WFADevice:1', @@ -58,7 +59,8 @@ SEARCH_TARGETS = [ ] -def format_packet_args(order: list, **kwargs): +def format_packet_args(order: typing.List[str], + kwargs: typing.Dict[str, typing.Union[int, str]]) -> typing.Dict[str, typing.Union[int, str]]: args = [] for o in order: for k, v in kwargs.items(): @@ -68,18 +70,18 @@ def format_packet_args(order: list, **kwargs): return OrderedDict(args) -def packet_generator(): +def packet_generator() -> typing.Iterator[typing.Dict[str, typing.Union[int, str]]]: for st in SEARCH_TARGETS: order = ["HOST", "MAN", "MX", "ST"] - yield format_packet_args(order, HOST=SSDP_HOST, MAN='"%s"' % SSDP_DISCOVER, MX=1, ST=st) - yield format_packet_args(order, Host=SSDP_HOST, Man='"%s"' % SSDP_DISCOVER, MX=1, ST=st) - yield format_packet_args(order, HOST=SSDP_HOST, MAN=SSDP_DISCOVER, MX=1, ST=st) - yield format_packet_args(order, Host=SSDP_HOST, Man=SSDP_DISCOVER, MX=1, ST=st) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': '"%s"' % SSDP_DISCOVER, 'MX': 1, 'ST': st}) + yield format_packet_args(order, {'Host': SSDP_HOST, 'Man': '"%s"' % SSDP_DISCOVER, 'MX': 1, 'ST': st}) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': SSDP_DISCOVER, 'MX': 1, 'ST': st}) + yield format_packet_args(order, {'Host': SSDP_HOST, 'Man': SSDP_DISCOVER, 'MX': 1, 'ST': st}) order = ["HOST", "MAN", "ST", "MX"] - yield format_packet_args(order, HOST=SSDP_HOST, MAN='"%s"' % SSDP_DISCOVER, MX=1, ST=st) - yield format_packet_args(order, HOST=SSDP_HOST, MAN=SSDP_DISCOVER, MX=1, ST=st) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': '"%s"' % SSDP_DISCOVER, 'MX': 1, 'ST': st}) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': SSDP_DISCOVER, 'MX': 1, 'ST': st}) order = ["HOST", "ST", "MAN", "MX"] - yield format_packet_args(order, HOST=SSDP_HOST, MAN='"%s"' % SSDP_DISCOVER, MX=1, ST=st) - yield format_packet_args(order, HOST=SSDP_HOST, MAN=SSDP_DISCOVER, MX=1, ST=st) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': '"%s"' % SSDP_DISCOVER, 'MX': 1, 'ST': st}) + yield format_packet_args(order, {'HOST': SSDP_HOST, 'MAN': SSDP_DISCOVER, 'MX': 1, 'ST': st}) diff --git a/aioupnp/protocols/multicast.py b/aioupnp/protocols/multicast.py index 2068dc6..452b304 100644 --- a/aioupnp/protocols/multicast.py +++ b/aioupnp/protocols/multicast.py @@ -1,45 +1,70 @@ import struct import socket +import typing from asyncio.protocols import DatagramProtocol -from asyncio.transports import DatagramTransport +from asyncio.transports import BaseTransport +from unittest import mock + + +def _get_sock(transport: typing.Optional[BaseTransport]) -> typing.Optional[socket.socket]: + if transport is None or not hasattr(transport, "_extra"): + return None + sock: typing.Optional[socket.socket] = transport.get_extra_info('socket', None) + assert sock is None or isinstance(sock, socket.SocketType) or isinstance(sock, mock.MagicMock) + return sock class MulticastProtocol(DatagramProtocol): def __init__(self, multicast_address: str, bind_address: str) -> None: self.multicast_address = multicast_address self.bind_address = bind_address - self.transport: DatagramTransport + self.transport: typing.Optional[BaseTransport] = None @property - def sock(self) -> socket.socket: - s: socket.socket = self.transport.get_extra_info(name='socket') - return s + def sock(self) -> typing.Optional[socket.socket]: + return _get_sock(self.transport) def get_ttl(self) -> int: - return self.sock.getsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL) + sock = self.sock + if not sock: + raise ValueError("not connected") + return sock.getsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL) def set_ttl(self, ttl: int = 1) -> None: - self.sock.setsockopt( + sock = self.sock + if not sock: + return None + sock.setsockopt( socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('b', ttl) ) + return None def join_group(self, multicast_address: str, bind_address: str) -> None: - self.sock.setsockopt( + sock = self.sock + if not sock: + return None + sock.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(multicast_address) + socket.inet_aton(bind_address) ) + return None def leave_group(self, multicast_address: str, bind_address: str) -> None: - self.sock.setsockopt( + sock = self.sock + if not sock: + raise ValueError("not connected") + sock.setsockopt( socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, socket.inet_aton(multicast_address) + socket.inet_aton(bind_address) ) + return None - def connection_made(self, transport) -> None: + def connection_made(self, transport: BaseTransport) -> None: self.transport = transport + return None @classmethod - def create_multicast_socket(cls, bind_address: str): + def create_multicast_socket(cls, bind_address: str) -> socket.socket: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((bind_address, 0)) diff --git a/aioupnp/protocols/scpd.py b/aioupnp/protocols/scpd.py index 5b61212..3543ba8 100644 --- a/aioupnp/protocols/scpd.py +++ b/aioupnp/protocols/scpd.py @@ -2,7 +2,6 @@ import logging import typing import re from collections import OrderedDict -from xml.etree import ElementTree import asyncio from asyncio.protocols import Protocol from aioupnp.fault import UPnPError @@ -18,16 +17,22 @@ log = logging.getLogger(__name__) HTTP_CODE_REGEX = re.compile(b"^HTTP[\/]{0,1}1\.[1|0] (\d\d\d)(.*)$") -def parse_headers(response: bytes) -> typing.Tuple[OrderedDict, int, bytes]: +def parse_http_response_code(http_response: bytes) -> typing.Tuple[bytes, bytes]: + parsed: typing.List[typing.Tuple[bytes, bytes]] = HTTP_CODE_REGEX.findall(http_response) + return parsed[0] + + +def parse_headers(response: bytes) -> typing.Tuple[typing.Dict[bytes, bytes], int, bytes]: lines = response.split(b'\r\n') - headers = OrderedDict([ + headers: typing.Dict[bytes, bytes] = OrderedDict([ (l.split(b':')[0], b':'.join(l.split(b':')[1:]).lstrip(b' ').rstrip(b' ')) for l in response.split(b'\r\n') ]) if len(lines) != len(headers): raise ValueError("duplicate headers") - http_response = tuple(headers.keys())[0] - response_code, message = HTTP_CODE_REGEX.findall(http_response)[0] + header_keys: typing.List[bytes] = list(headers.keys()) + http_response = header_keys[0] + response_code, message = parse_http_response_code(http_response) del headers[http_response] return headers, int(response_code), message @@ -40,37 +45,42 @@ class SCPDHTTPClientProtocol(Protocol): and devices respond with an invalid HTTP version line """ - def __init__(self, message: bytes, finished: asyncio.Future, soap_method: str=None, - soap_service_id: str=None) -> None: + def __init__(self, message: bytes, finished: 'asyncio.Future[typing.Tuple[bytes, int, bytes]]', + soap_method: typing.Optional[str] = None, soap_service_id: typing.Optional[str] = None) -> None: self.message = message self.response_buff = b"" self.finished = finished self.soap_method = soap_method self.soap_service_id = soap_service_id - - self._response_code: int = 0 - self._response_msg: bytes = b"" - self._content_length: int = 0 + self._response_code = 0 + self._response_msg = b"" + self._content_length = 0 self._got_headers = False - self._headers: dict = {} + self._headers: typing.Dict[bytes, bytes] = {} self._body = b"" + self.transport: typing.Optional[asyncio.WriteTransport] = None - def connection_made(self, transport): - transport.write(self.message) + def connection_made(self, transport: asyncio.BaseTransport) -> None: + assert isinstance(transport, asyncio.WriteTransport) + self.transport = transport + self.transport.write(self.message) + return None - def data_received(self, data): + def data_received(self, data: bytes) -> None: self.response_buff += data for i, line in enumerate(self.response_buff.split(b'\r\n')): if not line: # we hit the blank line between the headers and the body if i == (len(self.response_buff.split(b'\r\n')) - 1): - return # the body is still yet to be written + return None # the body is still yet to be written if not self._got_headers: self._headers, self._response_code, self._response_msg = parse_headers( b'\r\n'.join(self.response_buff.split(b'\r\n')[:i]) ) - content_length = get_dict_val_case_insensitive(self._headers, b'Content-Length') + content_length = get_dict_val_case_insensitive( + self._headers, b'Content-Length' + ) if content_length is None: - return + return None self._content_length = int(content_length or 0) self._got_headers = True body = b'\r\n'.join(self.response_buff.split(b'\r\n')[i+1:]) @@ -86,21 +96,28 @@ class SCPDHTTPClientProtocol(Protocol): ) ) ) - return + return None + return None -async def scpd_get(control_url: str, address: str, port: int, loop=None) -> typing.Tuple[typing.Dict, bytes, - typing.Optional[Exception]]: - loop = loop or asyncio.get_event_loop_policy().get_event_loop() - finished: asyncio.Future = asyncio.Future() +async def scpd_get(control_url: str, address: str, port: int, + loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> typing.Tuple[typing.Dict[str, typing.Any], bytes, + typing.Optional[Exception]]: + loop = loop or asyncio.get_event_loop() packet = serialize_scpd_get(control_url, address) - transport, protocol = await loop.create_connection( - lambda : SCPDHTTPClientProtocol(packet, finished), address, port + finished: asyncio.Future[typing.Tuple[bytes, int, bytes]] = asyncio.Future(loop=loop) + proto_factory: typing.Callable[[], SCPDHTTPClientProtocol] = lambda: SCPDHTTPClientProtocol(packet, finished) + connect_tup: typing.Tuple[asyncio.BaseTransport, asyncio.BaseProtocol] = await loop.create_connection( + proto_factory, address, port ) + protocol = connect_tup[1] + transport = connect_tup[0] assert isinstance(protocol, SCPDHTTPClientProtocol) + error = None + wait_task: typing.Awaitable[typing.Tuple[bytes, int, bytes]] = asyncio.wait_for(protocol.finished, 1.0, loop=loop) try: - body, response_code, response_msg = await asyncio.wait_for(finished, 1.0) + body, response_code, response_msg = await wait_task except asyncio.TimeoutError: error = UPnPError("get request timed out") body = b'' @@ -112,24 +129,31 @@ async def scpd_get(control_url: str, address: str, port: int, loop=None) -> typi if not error: try: return deserialize_scpd_get_response(body), body, None - except ElementTree.ParseError as err: + except Exception as err: error = UPnPError(err) + return {}, body, error async def scpd_post(control_url: str, address: str, port: int, method: str, param_names: list, service_id: bytes, - loop=None, **kwargs) -> typing.Tuple[typing.Dict, bytes, typing.Optional[Exception]]: - loop = loop or asyncio.get_event_loop_policy().get_event_loop() - finished: asyncio.Future = asyncio.Future() + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + **kwargs: typing.Dict[str, typing.Any] + ) -> typing.Tuple[typing.Dict, bytes, typing.Optional[Exception]]: + loop = loop or asyncio.get_event_loop() + finished: asyncio.Future[typing.Tuple[bytes, int, bytes]] = asyncio.Future(loop=loop) packet = serialize_soap_post(method, param_names, service_id, address.encode(), control_url.encode(), **kwargs) - transport, protocol = await loop.create_connection( - lambda : SCPDHTTPClientProtocol( - packet, finished, soap_method=method, soap_service_id=service_id.decode(), - ), address, port + proto_factory: typing.Callable[[], SCPDHTTPClientProtocol] = lambda:\ + SCPDHTTPClientProtocol(packet, finished, soap_method=method, soap_service_id=service_id.decode()) + connect_tup: typing.Tuple[asyncio.BaseTransport, asyncio.BaseProtocol] = await loop.create_connection( + proto_factory, address, port ) + protocol = connect_tup[1] + transport = connect_tup[0] assert isinstance(protocol, SCPDHTTPClientProtocol) + try: - body, response_code, response_msg = await asyncio.wait_for(finished, 1.0) + wait_task: typing.Awaitable[typing.Tuple[bytes, int, bytes]] = asyncio.wait_for(finished, 1.0, loop=loop) + body, response_code, response_msg = await wait_task except asyncio.TimeoutError: return {}, b'', UPnPError("Timeout") except UPnPError as err: @@ -140,5 +164,5 @@ async def scpd_post(control_url: str, address: str, port: int, method: str, para return ( deserialize_soap_post_response(body, method, service_id.decode()), body, None ) - except (ElementTree.ParseError, UPnPError) as err: + except Exception as err: return {}, body, UPnPError(err) diff --git a/aioupnp/protocols/ssdp.py b/aioupnp/protocols/ssdp.py index 7a4e694..a43e117 100644 --- a/aioupnp/protocols/ssdp.py +++ b/aioupnp/protocols/ssdp.py @@ -3,8 +3,8 @@ import binascii import asyncio import logging import typing +import socket from collections import OrderedDict -from asyncio.futures import Future from asyncio.transports import DatagramTransport from aioupnp.fault import UPnPError from aioupnp.serialization.ssdp import SSDPDatagram @@ -18,32 +18,48 @@ log = logging.getLogger(__name__) class SSDPProtocol(MulticastProtocol): - def __init__(self, multicast_address: str, lan_address: str, ignored: typing.Set[str] = None, - unicast: bool = False) -> None: + def __init__(self, multicast_address: str, lan_address: str, ignored: typing.Optional[typing.Set[str]] = None, + unicast: bool = False, loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: super().__init__(multicast_address, lan_address) + self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + self.transport: typing.Optional[DatagramTransport] = None self._unicast = unicast self._ignored: typing.Set[str] = ignored or set() # ignored locations - self._pending_searches: typing.List[typing.Tuple[str, str, Future, asyncio.Handle]] = [] - self.notifications: typing.List = [] + self._pending_searches: typing.List[typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle]] = [] + self.notifications: typing.List[SSDPDatagram] = [] + self.connected = asyncio.Event(loop=self.loop) - def disconnect(self): + def connection_made(self, transport) -> None: + # assert isinstance(transport, asyncio.DatagramTransport), str(type(transport)) + super().connection_made(transport) + self.connected.set() + + def disconnect(self) -> None: if self.transport: + try: + self.leave_group(self.multicast_address, self.bind_address) + except ValueError: + pass + except Exception: + log.exception("unexpected error leaving multicast group") self.transport.close() + self.connected.clear() while self._pending_searches: pending = self._pending_searches.pop()[2] if not pending.cancelled() and not pending.done(): pending.cancel() + return None def _callback_m_search_ok(self, address: str, packet: SSDPDatagram) -> None: if packet.location in self._ignored: - return - tmp: typing.List = [] - set_futures: typing.List = [] - while self._pending_searches: - t: tuple = self._pending_searches.pop() - a, s = t[0], t[1] - if (address == a) and (s in [packet.st, "upnp:rootdevice"]): - f: Future = t[2] + return None + # TODO: fix this + tmp: typing.List[typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle]] = [] + set_futures: typing.List[asyncio.Future[SSDPDatagram]] = [] + while len(self._pending_searches): + t: typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle] = self._pending_searches.pop() + if (address == t[0]) and (t[1] in [packet.st, "upnp:rootdevice"]): + f: asyncio.Future[SSDPDatagram] = t[2] if f not in set_futures: set_futures.append(f) if not f.done(): @@ -52,38 +68,41 @@ class SSDPProtocol(MulticastProtocol): tmp.append(t) while tmp: self._pending_searches.append(tmp.pop()) + return None - def send_many_m_searches(self, address: str, packets: typing.List[SSDPDatagram]): + def _send_m_search(self, address: str, packet: SSDPDatagram) -> None: dest = address if self._unicast else SSDP_IP_ADDRESS - for packet in packets: - log.debug("send m search to %s: %s", dest, packet.st) - self.transport.sendto(packet.encode().encode(), (dest, SSDP_PORT)) + if not self.transport: + raise UPnPError("SSDP transport not connected") + log.debug("send m search to %s: %s", dest, packet.st) + self.transport.sendto(packet.encode().encode(), (dest, SSDP_PORT)) + return None - async def m_search(self, address: str, timeout: float, datagrams: typing.List[OrderedDict]) -> SSDPDatagram: - fut: Future = Future() - packets: typing.List[SSDPDatagram] = [] + async def m_search(self, address: str, timeout: float, + datagrams: typing.List[typing.Dict[str, typing.Union[str, int]]]) -> SSDPDatagram: + fut: asyncio.Future[SSDPDatagram] = asyncio.Future(loop=self.loop) for datagram in datagrams: - packet = SSDPDatagram(SSDPDatagram._M_SEARCH, datagram) + packet = SSDPDatagram("M-SEARCH", datagram) assert packet.st is not None - self._pending_searches.append((address, packet.st, fut)) - packets.append(packet) - self.send_many_m_searches(address, packets), - return await fut + self._pending_searches.append( + (address, packet.st, fut, self.loop.call_soon(self._send_m_search, address, packet)) + ) + return await asyncio.wait_for(fut, timeout) - def datagram_received(self, data, addr) -> None: + def datagram_received(self, data: bytes, addr: typing.Tuple[str, int]) -> None: # type: ignore if addr[0] == self.bind_address: - return + return None try: packet = SSDPDatagram.decode(data) log.debug("decoded packet from %s:%i: %s", addr[0], addr[1], packet) except UPnPError as err: log.error("failed to decode SSDP packet from %s:%i (%s): %s", addr[0], addr[1], err, binascii.hexlify(data)) - return + return None if packet._packet_type == packet._OK: self._callback_m_search_ok(addr[0], packet) - return + return None # elif packet._packet_type == packet._NOTIFY: # log.debug("%s:%i sent us a notification: %s", packet) # if packet.nt == SSDP_ROOT_DEVICE: @@ -104,17 +123,18 @@ class SSDPProtocol(MulticastProtocol): # return -async def listen_ssdp(lan_address: str, gateway_address: str, loop=None, - ignored: typing.Set[str] = None, unicast: bool = False) -> typing.Tuple[DatagramTransport, - SSDPProtocol, str, str]: - loop = loop or asyncio.get_event_loop_policy().get_event_loop() +async def listen_ssdp(lan_address: str, gateway_address: str, loop: typing.Optional[asyncio.AbstractEventLoop] = None, + ignored: typing.Optional[typing.Set[str]] = None, + unicast: bool = False) -> typing.Tuple[SSDPProtocol, str, str]: + loop = loop or asyncio.get_event_loop() try: - sock = SSDPProtocol.create_multicast_socket(lan_address) - listen_result: typing.Tuple = await loop.create_datagram_endpoint( + sock: socket.socket = SSDPProtocol.create_multicast_socket(lan_address) + listen_result: typing.Tuple[asyncio.BaseTransport, asyncio.BaseProtocol] = await loop.create_datagram_endpoint( lambda: SSDPProtocol(SSDP_IP_ADDRESS, lan_address, ignored, unicast), sock=sock ) - transport: DatagramTransport = listen_result[0] - protocol: SSDPProtocol = listen_result[1] + transport = listen_result[0] + protocol = listen_result[1] + assert isinstance(protocol, SSDPProtocol) except Exception as err: print(err) raise UPnPError(err) @@ -125,30 +145,31 @@ async def listen_ssdp(lan_address: str, gateway_address: str, loop=None, protocol.disconnect() raise UPnPError(err) - return transport, protocol, gateway_address, lan_address + return protocol, gateway_address, lan_address -async def m_search(lan_address: str, gateway_address: str, datagram_args: OrderedDict, timeout: int = 1, - loop=None, ignored: typing.Set[str] = None, - unicast: bool = False) -> SSDPDatagram: - transport, protocol, gateway_address, lan_address = await listen_ssdp( +async def m_search(lan_address: str, gateway_address: str, datagram_args: typing.Dict[str, typing.Union[int, str]], + timeout: int = 1, loop: typing.Optional[asyncio.AbstractEventLoop] = None, + ignored: typing.Set[str] = None, unicast: bool = False) -> SSDPDatagram: + protocol, gateway_address, lan_address = await listen_ssdp( lan_address, gateway_address, loop, ignored, unicast ) try: - return await asyncio.wait_for( - protocol.m_search(address=gateway_address, timeout=timeout, datagrams=[datagram_args]), timeout - ) + return await protocol.m_search(address=gateway_address, timeout=timeout, datagrams=[datagram_args]) except (asyncio.TimeoutError, asyncio.CancelledError): raise UPnPError("M-SEARCH for {}:{} timed out".format(gateway_address, SSDP_PORT)) finally: protocol.disconnect() -async def _fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = 30, loop=None, - ignored: typing.Set[str] = None, unicast: bool = False) -> typing.List[OrderedDict]: - transport, protocol, gateway_address, lan_address = await listen_ssdp( +async def _fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = 30, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + ignored: typing.Set[str] = None, + unicast: bool = False) -> typing.List[typing.Dict[str, typing.Union[int, str]]]: + protocol, gateway_address, lan_address = await listen_ssdp( lan_address, gateway_address, loop, ignored, unicast ) + await protocol.connected.wait() packet_args = list(packet_generator()) batch_size = 2 batch_timeout = float(timeout) / float(len(packet_args)) @@ -157,7 +178,7 @@ async def _fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = packet_args = packet_args[batch_size:] log.debug("sending batch of %i M-SEARCH attempts", batch_size) try: - await asyncio.wait_for(protocol.m_search(gateway_address, batch_timeout, args), batch_timeout) + await protocol.m_search(gateway_address, batch_timeout, args) protocol.disconnect() return args except asyncio.TimeoutError: @@ -166,9 +187,11 @@ async def _fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = raise UPnPError("M-SEARCH for {}:{} timed out".format(gateway_address, SSDP_PORT)) -async def fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = 30, loop=None, - ignored: typing.Set[str] = None, unicast: bool = False) -> typing.Tuple[OrderedDict, - SSDPDatagram]: +async def fuzzy_m_search(lan_address: str, gateway_address: str, timeout: int = 30, + loop: typing.Optional[asyncio.AbstractEventLoop] = None, + ignored: typing.Set[str] = None, + unicast: bool = False) -> typing.Tuple[typing.Dict[str, + typing.Union[int, str]], SSDPDatagram]: # we don't know which packet the gateway replies to, so send small batches at a time args_to_try = await _fuzzy_m_search(lan_address, gateway_address, timeout, loop, ignored, unicast) # check the args in the batch that got a reply one at a time to see which one worked diff --git a/aioupnp/serialization/scpd.py b/aioupnp/serialization/scpd.py index 75377cd..7694455 100644 --- a/aioupnp/serialization/scpd.py +++ b/aioupnp/serialization/scpd.py @@ -1,8 +1,9 @@ import re -from typing import Dict -from xml.etree import ElementTree -from aioupnp.constants import XML_VERSION, DEVICE, ROOT -from aioupnp.util import etree_to_dict, flatten_keys +from typing import Dict, Any, List, Tuple +from aioupnp.fault import UPnPError +from aioupnp.constants import XML_VERSION +from aioupnp.serialization.xml import xml_to_dict +from aioupnp.util import flatten_keys CONTENT_PATTERN = re.compile( @@ -28,34 +29,38 @@ def serialize_scpd_get(path: str, address: str) -> bytes: if not path.startswith("/"): path = "/" + path return ( - ( - 'GET %s HTTP/1.1\r\n' - 'Accept-Encoding: gzip\r\n' - 'Host: %s\r\n' - 'Connection: Close\r\n' - '\r\n' - ) % (path, host) + f'GET {path} HTTP/1.1\r\n' + f'Accept-Encoding: gzip\r\n' + f'Host: {host}\r\n' + f'Connection: Close\r\n' + f'\r\n' ).encode() -def deserialize_scpd_get_response(content: bytes) -> Dict: +def deserialize_scpd_get_response(content: bytes) -> Dict[str, Any]: if XML_VERSION.encode() in content: - parsed = CONTENT_PATTERN.findall(content) - content = b'' if not parsed else parsed[0][0] - xml_dict = etree_to_dict(ElementTree.fromstring(content.decode())) + parsed: List[Tuple[bytes, bytes]] = CONTENT_PATTERN.findall(content) + xml_dict = xml_to_dict((b'' if not parsed else parsed[0][0]).decode()) return parse_device_dict(xml_dict) return {} -def parse_device_dict(xml_dict: dict) -> Dict: +def parse_device_dict(xml_dict: Dict[str, Any]) -> Dict[str, Any]: keys = list(xml_dict.keys()) + found = False for k in keys: - m = XML_ROOT_SANITY_PATTERN.findall(k) + m: List[Tuple[str, str, str, str, str, str]] = XML_ROOT_SANITY_PATTERN.findall(k) if len(m) == 3 and m[1][0] and m[2][5]: - schema_key = m[1][0] - root = m[2][5] - xml_dict = flatten_keys(xml_dict, "{%s}" % schema_key)[root] + schema_key: str = m[1][0] + root: str = m[2][5] + flattened = flatten_keys(xml_dict, "{%s}" % schema_key) + if root not in flattened: + raise UPnPError("root device not found") + xml_dict = flattened[root] + found = True break + if not found: + raise UPnPError("device not found") result = {} for k, v in xml_dict.items(): if isinstance(xml_dict[k], dict): @@ -65,10 +70,9 @@ def parse_device_dict(xml_dict: dict) -> Dict: if len(parsed_k) == 2: inner_d[parsed_k[0]] = inner_v else: - assert len(parsed_k) == 3 + assert len(parsed_k) == 3, f"expected len=3, got {len(parsed_k)}" inner_d[parsed_k[1]] = inner_v result[k] = inner_d else: result[k] = v - return result diff --git a/aioupnp/serialization/soap.py b/aioupnp/serialization/soap.py index fd5b906..9ebc07a 100644 --- a/aioupnp/serialization/soap.py +++ b/aioupnp/serialization/soap.py @@ -1,64 +1,65 @@ import re -from xml.etree import ElementTree -from aioupnp.util import etree_to_dict, flatten_keys -from aioupnp.fault import handle_fault, UPnPError -from aioupnp.constants import XML_VERSION, ENVELOPE, BODY +import typing +from aioupnp.util import flatten_keys +from aioupnp.fault import UPnPError +from aioupnp.constants import XML_VERSION, ENVELOPE, BODY, FAULT, CONTROL +from aioupnp.serialization.xml import xml_to_dict CONTENT_NO_XML_VERSION_PATTERN = re.compile( "(\)".encode() ) -def serialize_soap_post(method: str, param_names: list, service_id: bytes, gateway_address: bytes, - control_url: bytes, **kwargs) -> bytes: - args = "".join("<%s>%s" % (n, kwargs.get(n), n) for n in param_names) - soap_body = ('\r\n%s\r\n' - '%s' % ( - XML_VERSION, method, service_id.decode(), - args, method)) +def serialize_soap_post(method: str, param_names: typing.List[str], service_id: bytes, gateway_address: bytes, + control_url: bytes, **kwargs: typing.Dict[str, str]) -> bytes: + args = "".join(f"<{n}>{kwargs.get(n)}" for n in param_names) + soap_body = (f'\r\n{XML_VERSION}\r\n' + f'{args}') if "http://" in gateway_address.decode(): host = gateway_address.decode().split("http://")[1] else: host = gateway_address.decode() return ( - ( - 'POST %s HTTP/1.1\r\n' - 'Host: %s\r\n' - 'User-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\n' - 'Content-Length: %i\r\n' - 'Content-Type: text/xml\r\n' - 'SOAPAction: \"%s#%s\"\r\n' - 'Connection: Close\r\n' - 'Cache-Control: no-cache\r\n' - 'Pragma: no-cache\r\n' - '%s' - '\r\n' - ) % ( - control_url.decode(), # could be just / even if it shouldn't be - host, - len(soap_body), - service_id.decode(), # maybe no quotes - method, - soap_body - ) + f'POST {control_url.decode()} HTTP/1.1\r\n' # could be just / even if it shouldn't be + f'Host: {host}\r\n' + f'User-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\n' + f'Content-Length: {len(soap_body)}\r\n' + f'Content-Type: text/xml\r\n' + f'SOAPAction: \"{service_id.decode()}#{method}\"\r\n' + f'Connection: Close\r\n' + f'Cache-Control: no-cache\r\n' + f'Pragma: no-cache\r\n' + f'{soap_body}' + f'\r\n' ).encode() -def deserialize_soap_post_response(response: bytes, method: str, service_id: str) -> dict: - parsed = CONTENT_NO_XML_VERSION_PATTERN.findall(response) +def deserialize_soap_post_response(response: bytes, method: str, + service_id: str) -> typing.Dict[str, typing.Dict[str, str]]: + parsed: typing.List[typing.List[bytes]] = CONTENT_NO_XML_VERSION_PATTERN.findall(response) content = b'' if not parsed else parsed[0][0] - content_dict = etree_to_dict(ElementTree.fromstring(content.decode())) + content_dict = xml_to_dict(content.decode()) envelope = content_dict[ENVELOPE] - response_body = flatten_keys(envelope[BODY], "{%s}" % service_id) - body = handle_fault(response_body) # raises UPnPError if there is a fault + if not isinstance(envelope[BODY], dict): + # raise UPnPError('blank response') + return {} # TODO: raise + response_body: typing.Dict[str, typing.Dict[str, typing.Dict[str, str]]] = flatten_keys( + envelope[BODY], f"{'{' + service_id + '}'}" + ) + if not response_body: + # raise UPnPError('blank response') + return {} # TODO: raise + if FAULT in response_body: + fault: typing.Dict[str, typing.Dict[str, typing.Dict[str, str]]] = flatten_keys( + response_body[FAULT], "{%s}" % CONTROL + ) + raise UPnPError(fault['detail']['UPnPError']['errorDescription']) response_key = None - if not body: - return {} - for key in body: + for key in response_body: if method in key: response_key = key break if not response_key: - raise UPnPError("unknown response fields for %s: %s" % (method, body)) - return body[response_key] + raise UPnPError(f"unknown response fields for {method}: {response_body}") + return response_body[response_key] diff --git a/aioupnp/serialization/ssdp.py b/aioupnp/serialization/ssdp.py index 824bbfe..99a3808 100644 --- a/aioupnp/serialization/ssdp.py +++ b/aioupnp/serialization/ssdp.py @@ -3,43 +3,67 @@ import logging import binascii import json from collections import OrderedDict -from typing import List +from typing import List, Optional, Dict, Union, Tuple, Callable from aioupnp.fault import UPnPError from aioupnp.constants import line_separator log = logging.getLogger(__name__) _template = "(?i)^(%s):[ ]*(.*)$" - - -ssdp_datagram_patterns = { - 'host': (re.compile("(?i)^(host):(.*)$"), str), - 'st': (re.compile(_template % 'st'), str), - 'man': (re.compile(_template % 'man'), str), - 'mx': (re.compile(_template % 'mx'), int), - 'nt': (re.compile(_template % 'nt'), str), - 'nts': (re.compile(_template % 'nts'), str), - 'usn': (re.compile(_template % 'usn'), str), - 'location': (re.compile(_template % 'location'), str), - 'cache_control': (re.compile(_template % 'cache[-|_]control'), str), - 'server': (re.compile(_template % 'server'), str), -} - vendor_pattern = re.compile("^([\w|\d]*)\.([\w|\d]*\.com):([ \"|\w|\d\:]*)$") -class SSDPDatagram(object): +def match_vendor(line: str) -> Optional[Tuple[str, str]]: + match: List[Tuple[str, str, str]] = vendor_pattern.findall(line) + if match: + vendor_key: str = match[-1][0].lstrip(" ").rstrip(" ") + vendor_value: str = match[-1][2].lstrip(" ").rstrip(" ") + return vendor_key, vendor_value + return None + + +def compile_find(pattern: str) -> Callable[[str], Optional[str]]: + p = re.compile(pattern) + + def find(line: str) -> Optional[str]: + result: List[List[str]] = [] + for outer in p.findall(line): + result.append([]) + for inner in outer: + result[-1].append(inner) + if result: + return result[-1][-1].lstrip(" ").rstrip(" ") + return None + + return find + + +ssdp_datagram_patterns: Dict[str, Callable[[str], Optional[str]]] = { + 'host': compile_find("(?i)^(host):(.*)$"), + 'st': compile_find(_template % 'st'), + 'man': compile_find(_template % 'man'), + 'mx': compile_find(_template % 'mx'), + 'nt': compile_find(_template % 'nt'), + 'nts': compile_find(_template % 'nts'), + 'usn': compile_find(_template % 'usn'), + 'location': compile_find(_template % 'location'), + 'cache_control': compile_find(_template % 'cache[-|_]control'), + 'server': compile_find(_template % 'server'), +} + + +class SSDPDatagram: _M_SEARCH = "M-SEARCH" _NOTIFY = "NOTIFY" _OK = "OK" - _start_lines = { + _start_lines: Dict[str, str] = { _M_SEARCH: "M-SEARCH * HTTP/1.1", _NOTIFY: "NOTIFY * HTTP/1.1", _OK: "HTTP/1.1 200 OK" } - _friendly_names = { + _friendly_names: Dict[str, str] = { _M_SEARCH: "m-search", _NOTIFY: "notify", _OK: "m-search response" @@ -47,9 +71,7 @@ class SSDPDatagram(object): _vendor_field_pattern = vendor_pattern - _patterns = ssdp_datagram_patterns - - _required_fields = { + _required_fields: Dict[str, List[str]] = { _M_SEARCH: [ 'host', 'man', @@ -75,137 +97,137 @@ class SSDPDatagram(object): ] } - def __init__(self, packet_type, kwargs: OrderedDict = None) -> None: + def __init__(self, packet_type: str, kwargs: Optional[Dict[str, Union[str, int]]] = None) -> None: if packet_type not in [self._M_SEARCH, self._NOTIFY, self._OK]: raise UPnPError("unknown packet type: {}".format(packet_type)) self._packet_type = packet_type - kwargs = kwargs or OrderedDict() - self._field_order: list = [ - k.lower().replace("-", "_") for k in kwargs.keys() + kw: Dict[str, Union[str, int]] = kwargs or OrderedDict() + self._field_order: List[str] = [ + k.lower().replace("-", "_") for k in kw.keys() ] - self.host = None - self.man = None - self.mx = None - self.st = None - self.nt = None - self.nts = None - self.usn = None - self.location = None - self.cache_control = None - self.server = None - self.date = None - self.ext = None - for k, v in kwargs.items(): + self.host: Optional[str] = None + self.man: Optional[str] = None + self.mx: Optional[Union[str, int]] = None + self.st: Optional[str] = None + self.nt: Optional[str] = None + self.nts: Optional[str] = None + self.usn: Optional[str] = None + self.location: Optional[str] = None + self.cache_control: Optional[str] = None + self.server: Optional[str] = None + self.date: Optional[str] = None + self.ext: Optional[str] = None + for k, v in kw.items(): normalized = k.lower().replace("-", "_") - if not normalized.startswith("_") and hasattr(self, normalized) and getattr(self, normalized) is None: - setattr(self, normalized, v) - self._case_mappings: dict = {k.lower(): k for k in kwargs.keys()} + if not normalized.startswith("_") and hasattr(self, normalized): + if getattr(self, normalized, None) is None: + setattr(self, normalized, v) + self._case_mappings: Dict[str, str] = {k.lower(): k for k in kw.keys()} for k in self._required_fields[self._packet_type]: - if getattr(self, k) is None: + if getattr(self, k, None) is None: raise UPnPError("missing required field %s" % k) def get_cli_igd_kwargs(self) -> str: fields = [] for field in self._field_order: - v = getattr(self, field) + v = getattr(self, field, None) + if v is None: + raise UPnPError("missing required field %s" % field) fields.append("--%s=%s" % (self._case_mappings.get(field, field), v)) return " ".join(fields) def __repr__(self) -> str: return self.as_json() - def __getitem__(self, item): + def __getitem__(self, item: str) -> Union[str, int]: for i in self._required_fields[self._packet_type]: if i.lower() == item.lower(): return getattr(self, i) raise KeyError(item) - def get_friendly_name(self) -> str: - return self._friendly_names[self._packet_type] - def encode(self, trailing_newlines: int = 2) -> str: lines = [self._start_lines[self._packet_type]] - for attr_name in self._field_order: - if attr_name not in self._required_fields[self._packet_type]: - continue - attr = getattr(self, attr_name) - if attr is None: - raise UPnPError("required field for {} is missing: {}".format(self._packet_type, attr_name)) - if attr_name == 'mx': - value = str(attr) - else: - value = attr - lines.append("{}: {}".format(self._case_mappings.get(attr_name.lower(), attr_name.upper()), value)) + lines.extend( + f"{self._case_mappings.get(attr_name.lower(), attr_name.upper())}: {str(getattr(self, attr_name))}" + for attr_name in self._field_order if attr_name in self._required_fields[self._packet_type] + ) serialized = line_separator.join(lines) for _ in range(trailing_newlines): serialized += line_separator return serialized - def as_dict(self) -> OrderedDict: + def as_dict(self) -> Dict[str, Union[str, int]]: return self._lines_to_content_dict(self.encode().split(line_separator)) def as_json(self) -> str: return json.dumps(self.as_dict(), indent=2) @classmethod - def decode(cls, datagram: bytes): + def decode(cls, datagram: bytes) -> 'SSDPDatagram': packet = cls._from_string(datagram.decode()) if packet is None: raise UPnPError( "failed to decode datagram: {}".format(binascii.hexlify(datagram)) ) for attr_name in packet._required_fields[packet._packet_type]: - attr = getattr(packet, attr_name) - if attr is None: + if getattr(packet, attr_name, None) is None: raise UPnPError( "required field for {} is missing from m-search response: {}".format(packet._packet_type, attr_name) ) return packet @classmethod - def _lines_to_content_dict(cls, lines: list) -> OrderedDict: - result: OrderedDict = OrderedDict() + def _lines_to_content_dict(cls, lines: List[str]) -> Dict[str, Union[str, int]]: + result: Dict[str, Union[str, int]] = OrderedDict() + matched_keys: List[str] = [] for line in lines: if not line: continue matched = False - for name, (pattern, field_type) in cls._patterns.items(): - if name not in result and pattern.findall(line): - match = pattern.findall(line)[-1][-1] - result[line[:len(name)]] = field_type(match.lstrip(" ").rstrip(" ")) - matched = True - break - + for name, pattern in ssdp_datagram_patterns.items(): + if name not in matched_keys: + if name.lower() == 'mx': + _matched_int = pattern(line) + if _matched_int is not None: + match_int = int(_matched_int) + result[line[:len(name)]] = match_int + matched = True + matched_keys.append(name) + break + else: + match = pattern(line) + if match is not None: + result[line[:len(name)]] = match + matched = True + matched_keys.append(name) + break if not matched: - if cls._vendor_field_pattern.findall(line): - match = cls._vendor_field_pattern.findall(line)[-1] - vendor_key = match[0].lstrip(" ").rstrip(" ") - # vendor_domain = match[1].lstrip(" ").rstrip(" ") - value = match[2].lstrip(" ").rstrip(" ") - if vendor_key not in result: - result[vendor_key] = value + matched_vendor = match_vendor(line) + if matched_vendor and matched_vendor[0] not in result: + result[matched_vendor[0]] = matched_vendor[1] return result @classmethod - def _from_string(cls, datagram: str): + def _from_string(cls, datagram: str) -> Optional['SSDPDatagram']: lines = [l for l in datagram.split(line_separator) if l] if not lines: - return + return None if lines[0] == cls._start_lines[cls._M_SEARCH]: return cls._from_request(lines[1:]) if lines[0] in [cls._start_lines[cls._NOTIFY], cls._start_lines[cls._NOTIFY] + " "]: return cls._from_notify(lines[1:]) if lines[0] == cls._start_lines[cls._OK]: return cls._from_response(lines[1:]) + return None @classmethod - def _from_response(cls, lines: List): + def _from_response(cls, lines: List) -> 'SSDPDatagram': return cls(cls._OK, cls._lines_to_content_dict(lines)) @classmethod - def _from_notify(cls, lines: List): + def _from_notify(cls, lines: List) -> 'SSDPDatagram': return cls(cls._NOTIFY, cls._lines_to_content_dict(lines)) @classmethod - def _from_request(cls, lines: List): + def _from_request(cls, lines: List) -> 'SSDPDatagram': return cls(cls._M_SEARCH, cls._lines_to_content_dict(lines)) diff --git a/aioupnp/serialization/xml.py b/aioupnp/serialization/xml.py new file mode 100644 index 0000000..98e2d01 --- /dev/null +++ b/aioupnp/serialization/xml.py @@ -0,0 +1,81 @@ +import typing +from collections import OrderedDict +from defusedxml import ElementTree + +str_any_dict = typing.Dict[str, typing.Any] + + +def parse_xml(xml_str: str) -> ElementTree: + element: ElementTree = ElementTree.fromstring(xml_str) + return element + + +def _element_text(element_tree: ElementTree) -> typing.Optional[str]: + # if element_tree.attrib: + # element: typing.Dict[str, str] = OrderedDict() + # for k, v in element_tree.attrib.items(): + # element['@' + k] = v + # if not element_tree.text: + # return element + # element['#text'] = element_tree.text.strip() + # return element + if element_tree.text: + return element_tree.text.strip() + return None + + +def _get_element_children(element_tree: ElementTree) -> typing.Dict[str, typing.Union[ + str, typing.List[typing.Union[typing.Dict[str, str], str]]]]: + element_children = _get_child_dicts(element_tree) + element: typing.Dict[str, typing.Any] = OrderedDict() + keys: typing.List[str] = list(element_children.keys()) + for k in keys: + v: typing.Union[str, typing.List[typing.Any], typing.Dict[str, typing.Any]] = element_children[k] + if len(v) == 1 and isinstance(v, list): + l: typing.List[typing.Union[typing.Dict[str, str], str]] = v + element[k] = l[0] + else: + element[k] = v + return element + + +def _get_child_dicts(element: ElementTree) -> typing.Dict[str, typing.List[typing.Union[typing.Dict[str, str], str]]]: + children_dicts: typing.Dict[str, typing.List[typing.Union[typing.Dict[str, str], str]]] = OrderedDict() + children: typing.List[ElementTree] = list(element) + for child in children: + child_dict = _recursive_element_to_dict(child) + child_keys: typing.List[str] = list(child_dict.keys()) + for k in child_keys: + assert k in child_dict + v: typing.Union[typing.Dict[str, str], str] = child_dict[k] + if k not in children_dicts.keys(): + new_item = [v] + children_dicts[k] = new_item + else: + sublist = children_dicts[k] + assert isinstance(sublist, list) + sublist.append(v) + return children_dicts + + +def _recursive_element_to_dict(element_tree: ElementTree) -> typing.Dict[str, typing.Any]: + if len(element_tree): + element_result: typing.Dict[str, typing.Dict[str, typing.Union[ + str, typing.List[typing.Union[str, typing.Dict[str, typing.Any]]]]]] = OrderedDict() + children_element = _get_element_children(element_tree) + if element_tree.tag is not None: + element_result[element_tree.tag] = children_element + return element_result + else: + element_text = _element_text(element_tree) + if element_text is not None: + base_element_result: typing.Dict[str, typing.Any] = OrderedDict() + if element_tree.tag is not None: + base_element_result[element_tree.tag] = element_text + return base_element_result + null_result: typing.Dict[str, str] = OrderedDict() + return null_result + + +def xml_to_dict(xml_str: str) -> typing.Dict[str, typing.Any]: + return _recursive_element_to_dict(parse_xml(xml_str)) diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py index 9eab4e9..e16bb0f 100644 --- a/aioupnp/upnp.py +++ b/aioupnp/upnp.py @@ -5,12 +5,11 @@ import asyncio import zlib import base64 from collections import OrderedDict -from typing import Tuple, Dict, List, Union +from typing import Tuple, Dict, List, Union, Optional, Callable from aioupnp.fault import UPnPError from aioupnp.gateway import Gateway -from aioupnp.util import get_gateway_and_lan_addresses +from aioupnp.interfaces import get_gateway_and_lan_addresses from aioupnp.protocols.ssdp import m_search, fuzzy_m_search -from aioupnp.commands import SOAPCommand from aioupnp.serialization.ssdp import SSDPDatagram log = logging.getLogger(__name__) @@ -35,6 +34,10 @@ class UPnP: self.gateway_address = gateway_address self.gateway = gateway + @classmethod + def get_annotations(cls, command: str) -> Dict[str, type]: + return getattr(Gateway.commands, command).__annotations__ + @classmethod def get_lan_and_gateway(cls, lan_address: str = '', gateway_address: str = '', interface_name: str = 'default') -> Tuple[str, str]: @@ -59,8 +62,9 @@ class UPnP: @classmethod @cli async def m_search(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1, - igd_args: OrderedDict = None, unicast: bool = True, interface_name: str = 'default', - loop=None) -> Dict: + igd_args: Optional[Dict[str, Union[int, str]]] = None, + unicast: bool = True, interface_name: str = 'default', + loop=None) -> Dict[str, Union[str, Dict[str, Union[int, str]]]]: if not lan_address or not gateway_address: try: lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) @@ -97,13 +101,13 @@ class UPnP: async def get_port_mapping_by_index(self, index: int) -> Dict: result = await self._get_port_mapping_by_index(index) if result: - if isinstance(self.gateway.commands.GetGenericPortMappingEntry, SOAPCommand): + if self.gateway.commands.is_registered('GetGenericPortMappingEntry'): return { k: v for k, v in zip(self.gateway.commands.GetGenericPortMappingEntry.return_order, result) } return {} - async def _get_port_mapping_by_index(self, index: int) -> Union[None, Tuple[Union[None, str], int, str, + async def _get_port_mapping_by_index(self, index: int) -> Union[None, Tuple[Optional[str], int, str, int, str, bool, str, int]]: try: redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) @@ -134,7 +138,7 @@ class UPnP: result = await self.gateway.commands.GetSpecificPortMappingEntry( NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol ) - if result and isinstance(self.gateway.commands.GetSpecificPortMappingEntry, SOAPCommand): + if result and self.gateway.commands.is_registered('GetSpecificPortMappingEntry'): return {k: v for k, v in zip(self.gateway.commands.GetSpecificPortMappingEntry.return_order, result)} except UPnPError: pass @@ -152,7 +156,8 @@ class UPnP: ) @cli - async def get_next_mapping(self, port: int, protocol: str, description: str, internal_port: int=None) -> int: + async def get_next_mapping(self, port: int, protocol: str, description: str, + internal_port: Optional[int] = None) -> int: if protocol not in ["UDP", "TCP"]: raise UPnPError("unsupported protocol: {}".format(protocol)) internal_port = int(internal_port or port) @@ -340,8 +345,9 @@ class UPnP: return await self.gateway.commands.GetActiveConnections() @classmethod - def run_cli(cls, method, igd_args: OrderedDict, lan_address: str = '', gateway_address: str = '', timeout: int = 30, - interface_name: str = 'default', unicast: bool = True, kwargs: dict = None, loop=None) -> None: + def run_cli(cls, method, igd_args: Dict[str, Union[bool, str, int]], lan_address: str = '', + gateway_address: str = '', timeout: int = 30, interface_name: str = 'default', + unicast: bool = True, kwargs: Optional[Dict] = None, loop=None) -> None: """ :param method: the command name :param igd_args: ordered case sensitive M-SEARCH headers, if provided all headers to be used must be provided @@ -356,7 +362,7 @@ class UPnP: igd_args = igd_args timeout = int(timeout) loop = loop or asyncio.get_event_loop_policy().get_event_loop() - fut: asyncio.Future = asyncio.Future() + fut: asyncio.Future = loop.create_future() async def wrapper(): # wrap the upnp setup and call of the command in a coroutine diff --git a/aioupnp/util.py b/aioupnp/util.py index 71be110..3b19504 100644 --- a/aioupnp/util.py +++ b/aioupnp/util.py @@ -1,91 +1,48 @@ -import re -import socket -from collections import defaultdict -from typing import Tuple, Dict -from xml.etree import ElementTree -import netifaces +import typing +from collections import OrderedDict -BASE_ADDRESS_REGEX = re.compile("^(http:\/\/\d*\.\d*\.\d*\.\d*:\d*)\/.*$".encode()) -BASE_PORT_REGEX = re.compile("^http:\/\/\d*\.\d*\.\d*\.\d*:(\d*)\/.*$".encode()) +str_any_dict = typing.Dict[str, typing.Any] -def etree_to_dict(t: ElementTree.Element) -> Dict: - d: dict = {} - if t.attrib: - d[t.tag] = {} - children = list(t) - if children: - dd: dict = defaultdict(list) - for dc in map(etree_to_dict, children): - for k, v in dc.items(): - dd[k].append(v) - d[t.tag] = {k: v[0] if len(v) == 1 else v for k, v in dd.items()} - if t.attrib: - d[t.tag].update(('@' + k, v) for k, v in t.attrib.items()) - if t.text: - text = t.text.strip() - if children or t.attrib: - if text: - d[t.tag]['#text'] = text - else: - d[t.tag] = text - return d - - -def flatten_keys(d, strip): - if not isinstance(d, (list, dict)): - return d - if isinstance(d, list): - return [flatten_keys(i, strip) for i in d] - t = {} - for k, v in d.items(): +def _recursive_flatten(to_flatten: typing.Any, strip: str) -> typing.Any: + if not isinstance(to_flatten, (list, dict)): + return to_flatten + if isinstance(to_flatten, list): + assert isinstance(to_flatten, list) + return [_recursive_flatten(i, strip) for i in to_flatten] + assert isinstance(to_flatten, dict) + keys: typing.List[str] = list(to_flatten.keys()) + copy: str_any_dict = OrderedDict() + for k in keys: + item: typing.Any = to_flatten[k] if strip in k and strip != k: - t[k.split(strip)[1]] = flatten_keys(v, strip) + copy[k.split(strip)[1]] = _recursive_flatten(item, strip) else: - t[k] = flatten_keys(v, strip) - return t + copy[k] = _recursive_flatten(item, strip) + return copy -def get_dict_val_case_insensitive(d, k): - match = list(filter(lambda x: x.lower() == k.lower(), d.keys())) - if not match: - return +def flatten_keys(to_flatten: str_any_dict, strip: str) -> str_any_dict: + keys: typing.List[str] = list(to_flatten.keys()) + copy: str_any_dict = OrderedDict() + for k in keys: + item = to_flatten[k] + if strip in k and strip != k: + new_key: str = k.split(strip)[1] + copy[new_key] = _recursive_flatten(item, strip) + else: + copy[k] = _recursive_flatten(item, strip) + return copy + + +def get_dict_val_case_insensitive(source: typing.Dict[typing.AnyStr, typing.AnyStr], key: typing.AnyStr) -> typing.Optional[typing.AnyStr]: + match: typing.List[typing.AnyStr] = list(filter(lambda x: x.lower() == key.lower(), source.keys())) + if not len(match): + return None if len(match) > 1: raise KeyError("overlapping keys") - return d[match[0]] - -# import struct -# import fcntl -# def get_ip_address(ifname): -# SIOCGIFADDR = 0x8915 -# s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) -# return socket.inet_ntoa(fcntl.ioctl( -# s.fileno(), -# SIOCGIFADDR, -# struct.pack(b'256s', ifname[:15].encode()) -# )[20:24]) - - -def get_interfaces(): - r = { - interface_name: (router_address, netifaces.ifaddresses(interface_name)[netifaces.AF_INET][0]['addr']) - for router_address, interface_name, _ in netifaces.gateways()[socket.AF_INET] - } - for interface_name in netifaces.interfaces(): - if interface_name in ['lo', 'localhost'] or interface_name in r: - continue - addresses = netifaces.ifaddresses(interface_name) - if netifaces.AF_INET in addresses: - address = addresses[netifaces.AF_INET][0]['addr'] - gateway_guess = ".".join(address.split(".")[:-1] + ["1"]) - r[interface_name] = (gateway_guess, address) - r['default'] = r[netifaces.gateways()['default'][netifaces.AF_INET][1]] - return r - - -def get_gateway_and_lan_addresses(interface_name: str) -> Tuple[str, str]: - for iface_name, (gateway, lan) in get_interfaces().items(): - if interface_name == iface_name: - return gateway, lan - return '', '' + if len(match) == 1: + matched_key: typing.AnyStr = match[0] + return source[matched_key] + raise KeyError("overlapping keys") diff --git a/setup.py b/setup.py index caa9ce6..39bfa9b 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ setup( packages=find_packages(exclude=('tests',)), entry_points={'console_scripts': console_scripts}, install_requires=[ - 'netifaces', + 'netifaces', 'defusedxml' ], extras_require={ 'test': ( -- 2.45.2 From 836271c6e0b3175545e7b6970d929c546f8e968a Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Tue, 21 May 2019 18:17:17 -0400 Subject: [PATCH 2/9] tests --- aioupnp/__init__.py | 1 + stubs/defusedxml/__init__.py | 23 ++++ stubs/netifaces.py | 9 +- tests/__init__.py | 145 +++++++++++++++++------ tests/mocks.py | 64 ---------- tests/protocols/test_multicast.py | 23 ++++ tests/protocols/test_scpd.py | 7 +- tests/protocols/test_ssdp.py | 5 +- tests/serialization/test_scpd.py | 188 ++++++++++++++++++++++++++++++ tests/serialization/test_soap.py | 37 ++++++ tests/test_cli.py | 11 +- tests/test_gateway.py | 139 +++++++++++++++++++++- tests/test_interfaces.py | 55 +++++++++ 13 files changed, 582 insertions(+), 125 deletions(-) create mode 100644 stubs/defusedxml/__init__.py delete mode 100644 tests/mocks.py create mode 100644 tests/protocols/test_multicast.py create mode 100644 tests/test_interfaces.py diff --git a/aioupnp/__init__.py b/aioupnp/__init__.py index 7fab9dc..523847a 100644 --- a/aioupnp/__init__.py +++ b/aioupnp/__init__.py @@ -4,3 +4,4 @@ __author__ = "Jack Robison" __maintainer__ = "Jack Robison" __license__ = "MIT" __email__ = "jackrobison@lbry.io" + diff --git a/stubs/defusedxml/__init__.py b/stubs/defusedxml/__init__.py new file mode 100644 index 0000000..db9fd1c --- /dev/null +++ b/stubs/defusedxml/__init__.py @@ -0,0 +1,23 @@ +import typing + + +class ElementTree: + tag: typing.Optional[str] = None + """The element's name.""" + + attrib: typing.Optional[typing.Dict[str, str]] = None + """Dictionary of the element's attributes.""" + + text: typing.Optional[str] = None + + tail: typing.Optional[str] = None + + def __len__(self) -> int: + raise NotImplementedError() + + def __iter__(self) -> typing.Iterator['ElementTree']: + raise NotImplementedError() + + @classmethod + def fromstring(cls, xml_str: str) -> 'ElementTree': + raise NotImplementedError() \ No newline at end of file diff --git a/stubs/netifaces.py b/stubs/netifaces.py index 36b7d07..075b09c 100644 --- a/stubs/netifaces.py +++ b/stubs/netifaces.py @@ -36,8 +36,9 @@ version = '0.10.7' # functions - -def gateways(*args, **kwargs) -> typing.List: # real signature unknown +def gateways(*args, **kwargs) -> typing.Dict[typing.Union[str, int], + typing.Union[typing.Dict[int, typing.Tuple[str, str]], + typing.List[typing.Tuple[str, str, bool]]]]: """ Obtain a list of the gateways on this machine. @@ -56,7 +57,7 @@ def gateways(*args, **kwargs) -> typing.List: # real signature unknown pass -def ifaddresses(*args, **kwargs) -> typing.Dict: # real signature unknown +def ifaddresses(*args, **kwargs) -> typing.Dict[int, typing.List[typing.Dict[str, str]]]: """ Obtain information about the specified network interface. @@ -67,7 +68,7 @@ def ifaddresses(*args, **kwargs) -> typing.Dict: # real signature unknown pass -def interfaces(*args, **kwargs) -> typing.List: # real signature unknown +def interfaces(*args, **kwargs) -> typing.List[str]: """ Obtain a list of the interfaces available on this machine. """ pass diff --git a/tests/__init__.py b/tests/__init__.py index dd299fc..51ce45a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,11 @@ import asyncio import unittest +import contextlib +import socket +from unittest import mock from unittest.case import _Outcome + try: from asyncio.runners import _cancel_all_tasks except ImportError: @@ -10,30 +14,89 @@ except ImportError: pass -class TestBase(unittest.TestCase): +@contextlib.contextmanager +def mock_tcp_and_udp(loop, udp_expected_addr=None, udp_replies=None, udp_delay_reply=0.0, sent_udp_packets=None, + tcp_replies=None, tcp_delay_reply=0.0, sent_tcp_packets=None): + sent_udp_packets = sent_udp_packets if sent_udp_packets is not None else [] + udp_replies = udp_replies or {} + + sent_tcp_packets = sent_tcp_packets if sent_tcp_packets is not None else [] + tcp_replies = tcp_replies or {} + + async def create_connection(protocol_factory, host=None, port=None): + def write(p: asyncio.Protocol): + def _write(data): + sent_tcp_packets.append(data) + if data in tcp_replies: + loop.call_later(tcp_delay_reply, p.data_received, tcp_replies[data]) + + return _write + + protocol = protocol_factory() + transport = asyncio.Transport(extra={'socket': mock.Mock(spec=socket.socket)}) + transport.close = lambda: None + transport.write = write(protocol) + protocol.connection_made(transport) + return transport, protocol + + async def create_datagram_endpoint(proto_lam, sock=None): + def sendto(p: asyncio.DatagramProtocol): + def _sendto(data, addr): + sent_udp_packets.append(data) + if (data, addr) in udp_replies: + loop.call_later(udp_delay_reply, p.datagram_received, udp_replies[(data, addr)], + (udp_expected_addr, 1900)) + + return _sendto + + protocol = proto_lam() + transport = asyncio.DatagramTransport(extra={'socket': mock_sock}) + transport.close = lambda: mock_sock.close() + mock_sock.sendto = sendto(protocol) + transport.sendto = mock_sock.sendto + protocol.connection_made(transport) + return transport, protocol + + with mock.patch('socket.socket') as mock_socket: + mock_sock = mock.Mock(spec=socket.socket) + mock_sock.setsockopt = lambda *_: None + mock_sock.bind = lambda *_: None + mock_sock.setblocking = lambda *_: None + mock_sock.getsockname = lambda: "0.0.0.0" + mock_sock.getpeername = lambda: "" + mock_sock.close = lambda: None + mock_sock.type = socket.SOCK_DGRAM + mock_sock.fileno = lambda: 7 + + mock_socket.return_value = mock_sock + loop.create_datagram_endpoint = create_datagram_endpoint + loop.create_connection = create_connection + yield + + +class AsyncioTestCase(unittest.TestCase): # Implementation inspired by discussion: # https://bugs.python.org/issue32972 - async def asyncSetUp(self): + maxDiff = None + + async def asyncSetUp(self): # pylint: disable=C0103 pass - async def asyncTearDown(self): + async def asyncTearDown(self): # pylint: disable=C0103 pass - async def doAsyncCleanups(self): - pass - - def run(self, result=None): + def run(self, result=None): # pylint: disable=R0915 orig_result = result if result is None: result = self.defaultTestResult() - startTestRun = getattr(result, 'startTestRun', None) + startTestRun = getattr(result, 'startTestRun', None) # pylint: disable=C0103 if startTestRun is not None: startTestRun() result.startTest(self) - testMethod = getattr(self, self._testMethodName) + testMethod = getattr(self, self._testMethodName) # pylint: disable=C0103 if (getattr(self.__class__, "__unittest_skip__", False) or getattr(testMethod, "__unittest_skip__", False)): # If the class or method was skipped. @@ -50,36 +113,36 @@ class TestBase(unittest.TestCase): "__unittest_expecting_failure__", False) expecting_failure = expecting_failure_class or expecting_failure_method outcome = _Outcome(result) + + self.loop = asyncio.new_event_loop() # pylint: disable=W0201 + asyncio.set_event_loop(self.loop) + self.loop.set_debug(True) + try: self._outcome = outcome - loop = asyncio.new_event_loop() - try: - asyncio.set_event_loop(loop) - loop.set_debug(True) - + with outcome.testPartExecutor(self): + self.setUp() + self.loop.run_until_complete(self.asyncSetUp()) + if outcome.success: + outcome.expecting_failure = expecting_failure + with outcome.testPartExecutor(self, isTest=True): + maybe_coroutine = testMethod() + if asyncio.iscoroutine(maybe_coroutine): + self.loop.run_until_complete(maybe_coroutine) + outcome.expecting_failure = False with outcome.testPartExecutor(self): - self.setUp() - loop.run_until_complete(self.asyncSetUp()) - if outcome.success: - outcome.expecting_failure = expecting_failure - with outcome.testPartExecutor(self, isTest=True): - possible_coroutine = testMethod() - if asyncio.iscoroutine(possible_coroutine): - loop.run_until_complete(possible_coroutine) - outcome.expecting_failure = False - with outcome.testPartExecutor(self): - loop.run_until_complete(self.asyncTearDown()) - self.tearDown() - finally: - try: - _cancel_all_tasks(loop) - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - asyncio.set_event_loop(None) - loop.close() + self.loop.run_until_complete(self.asyncTearDown()) + self.tearDown() - self.doCleanups() + self.doAsyncCleanups() + + try: + _cancel_all_tasks(self.loop) + self.loop.run_until_complete(self.loop.shutdown_asyncgens()) + finally: + asyncio.set_event_loop(None) + self.loop.close() for test, reason in outcome.skipped: self._addSkip(result, test, reason) @@ -96,9 +159,9 @@ class TestBase(unittest.TestCase): finally: result.stopTest(self) if orig_result is None: - stopTestRun = getattr(result, 'stopTestRun', None) + stopTestRun = getattr(result, 'stopTestRun', None) # pylint: disable=C0103 if stopTestRun is not None: - stopTestRun() + stopTestRun() # pylint: disable=E1102 # explicitly break reference cycles: # outcome.errors -> frame -> outcome -> outcome.errors @@ -109,5 +172,11 @@ class TestBase(unittest.TestCase): # clear the outcome, no more needed self._outcome = None - def setUp(self): - self.loop = asyncio.get_event_loop_policy().get_event_loop() + def doAsyncCleanups(self): # pylint: disable=C0103 + outcome = self._outcome or _Outcome() + while self._cleanups: + function, args, kwargs = self._cleanups.pop() + with outcome.testPartExecutor(self): + maybe_coroutine = function(*args, **kwargs) + if asyncio.iscoroutine(maybe_coroutine): + self.loop.run_until_complete(maybe_coroutine) diff --git a/tests/mocks.py b/tests/mocks.py deleted file mode 100644 index 1351e78..0000000 --- a/tests/mocks.py +++ /dev/null @@ -1,64 +0,0 @@ -import asyncio -import contextlib -import socket -import mock - - -@contextlib.contextmanager -def mock_tcp_and_udp(loop, udp_expected_addr=None, udp_replies=None, udp_delay_reply=0.0, sent_udp_packets=None, - tcp_replies=None, tcp_delay_reply=0.0, sent_tcp_packets=None): - sent_udp_packets = sent_udp_packets if sent_udp_packets is not None else [] - udp_replies = udp_replies or {} - - sent_tcp_packets = sent_tcp_packets if sent_tcp_packets is not None else [] - tcp_replies = tcp_replies or {} - - async def create_connection(protocol_factory, host=None, port=None): - def write(p: asyncio.Protocol): - def _write(data): - sent_tcp_packets.append(data) - if data in tcp_replies: - loop.call_later(tcp_delay_reply, p.data_received, tcp_replies[data]) - - return _write - - protocol = protocol_factory() - transport = asyncio.Transport(extra={'socket': mock.Mock(spec=socket.socket)}) - transport.close = lambda: None - transport.write = write(protocol) - protocol.connection_made(transport) - return transport, protocol - - async def create_datagram_endpoint(proto_lam, sock=None): - def sendto(p: asyncio.DatagramProtocol): - def _sendto(data, addr): - sent_udp_packets.append(data) - if (data, addr) in udp_replies: - loop.call_later(udp_delay_reply, p.datagram_received, udp_replies[(data, addr)], - (udp_expected_addr, 1900)) - - return _sendto - - protocol = proto_lam() - transport = asyncio.DatagramTransport(extra={'socket': mock_sock}) - transport.close = lambda: mock_sock.close() - mock_sock.sendto = sendto(protocol) - transport.sendto = mock_sock.sendto - protocol.connection_made(transport) - return transport, protocol - - with mock.patch('socket.socket') as mock_socket: - mock_sock = mock.Mock(spec=socket.socket) - mock_sock.setsockopt = lambda *_: None - mock_sock.bind = lambda *_: None - mock_sock.setblocking = lambda *_: None - mock_sock.getsockname = lambda: "0.0.0.0" - mock_sock.getpeername = lambda: "" - mock_sock.close = lambda: None - mock_sock.type = socket.SOCK_DGRAM - mock_sock.fileno = lambda: 7 - - mock_socket.return_value = mock_sock - loop.create_datagram_endpoint = create_datagram_endpoint - loop.create_connection = create_connection - yield diff --git a/tests/protocols/test_multicast.py b/tests/protocols/test_multicast.py new file mode 100644 index 0000000..065f12e --- /dev/null +++ b/tests/protocols/test_multicast.py @@ -0,0 +1,23 @@ +import unittest + +from asyncio import DatagramTransport +from aioupnp.protocols.multicast import MulticastProtocol + + +class TestMulticast(unittest.TestCase): + def test_it(self): + class none_socket: + sock = None + + def get(self, name, default=None): + return default + + protocol = MulticastProtocol('1.2.3.4', '1.2.3.4') + transport = DatagramTransport(none_socket()) + protocol.set_ttl(1) + with self.assertRaises(ValueError): + _ = protocol.get_ttl() + protocol.connection_made(transport) + protocol.set_ttl(1) + with self.assertRaises(ValueError): + _ = protocol.get_ttl() diff --git a/tests/protocols/test_scpd.py b/tests/protocols/test_scpd.py index 7c80dbb..6ff5442 100644 --- a/tests/protocols/test_scpd.py +++ b/tests/protocols/test_scpd.py @@ -1,10 +1,9 @@ from aioupnp.fault import UPnPError from aioupnp.protocols.scpd import scpd_post, scpd_get -from tests import TestBase -from tests.mocks import mock_tcp_and_udp +from tests import AsyncioTestCase, mock_tcp_and_udp -class TestSCPDGet(TestBase): +class TestSCPDGet(AsyncioTestCase): path, lan_address, port = '/IGDdevicedesc_brlan0.xml', '10.1.10.1', 49152 get_request = b'GET /IGDdevicedesc_brlan0.xml HTTP/1.1\r\n' \ b'Accept-Encoding: gzip\r\nHost: 10.1.10.1\r\nConnection: Close\r\n\r\n' @@ -142,7 +141,7 @@ class TestSCPDGet(TestBase): self.assertTrue(str(err).startswith('too many bytes written')) -class TestSCPDPost(TestBase): +class TestSCPDPost(AsyncioTestCase): param_names: list = [] kwargs: dict = {} method, gateway_address, port = "GetExternalIPAddress", '10.0.0.1', 49152 diff --git a/tests/protocols/test_ssdp.py b/tests/protocols/test_ssdp.py index 8e780e0..a36f1a0 100644 --- a/tests/protocols/test_ssdp.py +++ b/tests/protocols/test_ssdp.py @@ -4,11 +4,10 @@ from aioupnp.protocols.m_search_patterns import packet_generator from aioupnp.serialization.ssdp import SSDPDatagram from aioupnp.constants import SSDP_IP_ADDRESS from aioupnp.protocols.ssdp import fuzzy_m_search, m_search -from tests import TestBase -from tests.mocks import mock_tcp_and_udp +from tests import AsyncioTestCase, mock_tcp_and_udp -class TestSSDP(TestBase): +class TestSSDP(AsyncioTestCase): packet_args = list(packet_generator()) byte_packets = [SSDPDatagram("M-SEARCH", p).encode().encode() for p in packet_args] diff --git a/tests/serialization/test_scpd.py b/tests/serialization/test_scpd.py index 5617fec..5e82918 100644 --- a/tests/serialization/test_scpd.py +++ b/tests/serialization/test_scpd.py @@ -1,5 +1,7 @@ import unittest +from aioupnp.fault import UPnPError from aioupnp.serialization.scpd import serialize_scpd_get, deserialize_scpd_get_response +from aioupnp.serialization.xml import xml_to_dict from aioupnp.device import Device from aioupnp.util import get_dict_val_case_insensitive @@ -20,6 +22,28 @@ class TestSCPDSerialization(unittest.TestCase): b"\r\n" \ b"\n\n\n1\n0\n\n\nurn:schemas-upnp-org:device:InternetGatewayDevice:1\nCGA4131COM\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:Layer3Forwarding:1\nurn:upnp-org:serviceId:L3Forwarding1\n/Layer3ForwardingSCPD.xml\n/upnp/control/Layer3Forwarding\n/upnp/event/Layer3Forwarding\n\n\n\n\nurn:schemas-upnp-org:device:WANDevice:1\nWANDevice:1\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\nurn:upnp-org:serviceId:WANCommonIFC1\n/WANCommonInterfaceConfigSCPD.xml\n/upnp/control/WANCommonInterfaceConfig0\n/upnp/event/WANCommonInterfaceConfig0\n\n\n\n \n urn:schemas-upnp-org:device:WANConnectionDevice:1\n WANConnectionDevice:1\n Cisco\n http://www.cisco.com/\n CGA4131COM\n CGA4131COM\n CGA4131COM\n http://www.cisco.com\n \n uuid:11111111-2222-3333-4444-555555555555\n CGA4131COM\n \n \n urn:schemas-upnp-org:service:WANIPConnection:1\n urn:upnp-org:serviceId:WANIPConn1\n /WANIPConnectionServiceSCPD.xml\n /upnp/control/WANIPConnection0\n /upnp/event/WANIPConnection0\n \n \n \n\n\n\nhttp://10.1.10.1/\n\n" + response_bad_root_device_name = b"HTTP/1.1 200 OK\r\n" \ + b"CONTENT-LENGTH: 2972\r\n" \ + b"CONTENT-TYPE: text/xml\r\n" \ + b"DATE: Thu, 18 Oct 2018 01:20:23 GMT\r\n" \ + b"LAST-MODIFIED: Fri, 28 Sep 2018 18:35:48 GMT\r\n" \ + b"SERVER: Linux/3.14.28-Prod_17.2, UPnP/1.0, Portable SDK for UPnP devices/1.6.22\r\n" \ + b"X-User-Agent: redsonic\r\n" \ + b"CONNECTION: close\r\n" \ + b"\r\n" \ + b"\n\n\n1\n0\n\n\nurn:schemas-upnp-org:device:InternetGatewayDevic3:1\nCGA4131COM\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:Layer3Forwarding:1\nurn:upnp-org:serviceId:L3Forwarding1\n/Layer3ForwardingSCPD.xml\n/upnp/control/Layer3Forwarding\n/upnp/event/Layer3Forwarding\n\n\n\n\nurn:schemas-upnp-org:device:WANDevice:1\nWANDevice:1\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\nurn:upnp-org:serviceId:WANCommonIFC1\n/WANCommonInterfaceConfigSCPD.xml\n/upnp/control/WANCommonInterfaceConfig0\n/upnp/event/WANCommonInterfaceConfig0\n\n\n\n \n urn:schemas-upnp-org:device:WANConnectionDevice:1\n WANConnectionDevice:1\n Cisco\n http://www.cisco.com/\n CGA4131COM\n CGA4131COM\n CGA4131COM\n http://www.cisco.com\n \n uuid:11111111-2222-3333-4444-555555555555\n CGA4131COM\n \n \n urn:schemas-upnp-org:service:WANIPConnection:1\n urn:upnp-org:serviceId:WANIPConn1\n /WANIPConnectionServiceSCPD.xml\n /upnp/control/WANIPConnection0\n /upnp/event/WANIPConnection0\n \n \n \n\n\n\nhttp://10.1.10.1/\n\n" + + response_bad_root_xmls = b"HTTP/1.1 200 OK\r\n" \ + b"CONTENT-LENGTH: 2972\r\n" \ + b"CONTENT-TYPE: text/xml\r\n" \ + b"DATE: Thu, 18 Oct 2018 01:20:23 GMT\r\n" \ + b"LAST-MODIFIED: Fri, 28 Sep 2018 18:35:48 GMT\r\n" \ + b"SERVER: Linux/3.14.28-Prod_17.2, UPnP/1.0, Portable SDK for UPnP devices/1.6.22\r\n" \ + b"X-User-Agent: redsonic\r\n" \ + b"CONNECTION: close\r\n" \ + b"\r\n" \ + b"\n\n\n1\n0\n\n\nurn:schemas-upnp-org:device:InternetGatewayDevic3:1\nCGA4131COM\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:Layer3Forwarding:1\nurn:upnp-org:serviceId:L3Forwarding1\n/Layer3ForwardingSCPD.xml\n/upnp/control/Layer3Forwarding\n/upnp/event/Layer3Forwarding\n\n\n\n\nurn:schemas-upnp-org:device:WANDevice:1\nWANDevice:1\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\nurn:upnp-org:serviceId:WANCommonIFC1\n/WANCommonInterfaceConfigSCPD.xml\n/upnp/control/WANCommonInterfaceConfig0\n/upnp/event/WANCommonInterfaceConfig0\n\n\n\n \n urn:schemas-upnp-org:device:WANConnectionDevice:1\n WANConnectionDevice:1\n Cisco\n http://www.cisco.com/\n CGA4131COM\n CGA4131COM\n CGA4131COM\n http://www.cisco.com\n \n uuid:11111111-2222-3333-4444-555555555555\n CGA4131COM\n \n \n urn:schemas-upnp-org:service:WANIPConnection:1\n urn:upnp-org:serviceId:WANIPConn1\n /WANIPConnectionServiceSCPD.xml\n /upnp/control/WANIPConnection0\n /upnp/event/WANIPConnection0\n \n \n \n\n\n\nhttp://10.1.10.1/\n\n" + expected_parsed = { 'specVersion': {'major': '1', 'minor': '0'}, 'device': { @@ -94,6 +118,87 @@ class TestSCPDSerialization(unittest.TestCase): def test_serialize_get(self): self.assertEqual(serialize_scpd_get(self.path, self.lan_address), self.get_request) + self.assertEqual(serialize_scpd_get(self.path, 'http://' + self.lan_address), self.get_request) + self.assertEqual(serialize_scpd_get(self.path, 'http://' + self.lan_address + ':1337'), self.get_request) + self.assertEqual(serialize_scpd_get(self.path, self.lan_address + ':1337'), self.get_request) + + def test_parse_device_response_xml(self): + self.assertDictEqual( + xml_to_dict('\n\n\t\n\t\t1\n\t\t0\n\t\n\thttp://10.0.0.1:49152\n\t\n\t\turn:schemas-upnp-org:device:InternetGatewayDevice:1\n\t\tWireless Broadband Router\n\t\tD-Link Corporation\n\t\thttp://www.dlink.com\n\t\tD-Link Router\n\t\tD-Link Router\n\t\tDIR-890L\n\t\thttp://www.dlink.com\n\t\t120\n\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\n\t\t\t\n\t\t\t\timage/gif\n\t\t\t\t118\n\t\t\t\t119\n\t\t\t\t8\n\t\t\t\t/ligd.gif\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-microsoft-com:service:OSInfo:1\n\t\t\t\turn:microsoft-com:serviceId:OSInfo1\n\t\t\t\t/soap.cgi?service=OSInfo1\n\t\t\t\t/gena.cgi?service=OSInfo1\n\t\t\t\t/OSInfo.xml\n\t\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:service:Layer3Forwarding:1\n\t\t\t\turn:upnp-org:serviceId:L3Forwarding1\n\t\t\t\t/soap.cgi?service=L3Forwarding1\n\t\t\t\t/gena.cgi?service=L3Forwarding1\n\t\t\t\t/Layer3Forwarding.xml\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:device:WANDevice:1\n\t\t\t\tWANDevice\n\t\t\t\tD-Link\n\t\t\t\thttp://www.dlink.com\n\t\t\t\tWANDevice\n\t\t\t\tDIR-890L\n\t\t\t\t1\n\t\t\t\thttp://www.dlink.com\n\t\t\t\t120\n\t\t\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\n\t\t\t\t\t\turn:upnp-org:serviceId:WANCommonIFC1\n\t\t\t\t\t\t/soap.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/gena.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/WANCommonInterfaceConfig.xml\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:device:WANConnectionDevice:1\n\t\t\t\t\t\tWANConnectionDevice\n\t\t\t\t\t\tD-Link\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\tWanConnectionDevice\n\t\t\t\t\t\tDIR-890L\n\t\t\t\t\t\t1\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\t120\n\t\t\t\t\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANEthernetLinkConfig:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANEthLinkC1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/WANEthernetLinkConfig.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANIPConnection:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANIPConn1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/WANIPConnection.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\thttp://10.0.0.1\n\t\n\n'), + {'{urn:schemas-upnp-org:device-1-0}root': { + '{urn:schemas-upnp-org:device-1-0}specVersion': {'{urn:schemas-upnp-org:device-1-0}major': '1', + '{urn:schemas-upnp-org:device-1-0}minor': '0'}, + '{urn:schemas-upnp-org:device-1-0}URLBase': 'http://10.0.0.1:49152', + '{urn:schemas-upnp-org:device-1-0}device': { + '{urn:schemas-upnp-org:device-1-0}deviceType': 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', + '{urn:schemas-upnp-org:device-1-0}friendlyName': 'Wireless Broadband Router', + '{urn:schemas-upnp-org:device-1-0}manufacturer': 'D-Link Corporation', + '{urn:schemas-upnp-org:device-1-0}manufacturerURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}modelDescription': 'D-Link Router', + '{urn:schemas-upnp-org:device-1-0}modelName': 'D-Link Router', + '{urn:schemas-upnp-org:device-1-0}modelNumber': 'DIR-890L', + '{urn:schemas-upnp-org:device-1-0}modelURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}serialNumber': '120', + '{urn:schemas-upnp-org:device-1-0}UDN': 'uuid:11111111-2222-3333-4444-555555555555', + '{urn:schemas-upnp-org:device-1-0}iconList': {'{urn:schemas-upnp-org:device-1-0}icon': { + '{urn:schemas-upnp-org:device-1-0}mimetype': 'image/gif', + '{urn:schemas-upnp-org:device-1-0}width': '118', + '{urn:schemas-upnp-org:device-1-0}height': '119', '{urn:schemas-upnp-org:device-1-0}depth': '8', + '{urn:schemas-upnp-org:device-1-0}url': '/ligd.gif'}}, + '{urn:schemas-upnp-org:device-1-0}serviceList': {'{urn:schemas-upnp-org:device-1-0}service': [ + {'{urn:schemas-upnp-org:device-1-0}serviceType': 'urn:schemas-microsoft-com:service:OSInfo:1', + '{urn:schemas-upnp-org:device-1-0}serviceId': 'urn:microsoft-com:serviceId:OSInfo1', + '{urn:schemas-upnp-org:device-1-0}controlURL': '/soap.cgi?service=OSInfo1', + '{urn:schemas-upnp-org:device-1-0}eventSubURL': '/gena.cgi?service=OSInfo1', + '{urn:schemas-upnp-org:device-1-0}SCPDURL': '/OSInfo.xml'}, { + '{urn:schemas-upnp-org:device-1-0}serviceType': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', + '{urn:schemas-upnp-org:device-1-0}serviceId': 'urn:upnp-org:serviceId:L3Forwarding1', + '{urn:schemas-upnp-org:device-1-0}controlURL': '/soap.cgi?service=L3Forwarding1', + '{urn:schemas-upnp-org:device-1-0}eventSubURL': '/gena.cgi?service=L3Forwarding1', + '{urn:schemas-upnp-org:device-1-0}SCPDURL': '/Layer3Forwarding.xml'}]}, + '{urn:schemas-upnp-org:device-1-0}deviceList': {'{urn:schemas-upnp-org:device-1-0}device': { + '{urn:schemas-upnp-org:device-1-0}deviceType': 'urn:schemas-upnp-org:device:WANDevice:1', + '{urn:schemas-upnp-org:device-1-0}friendlyName': 'WANDevice', + '{urn:schemas-upnp-org:device-1-0}manufacturer': 'D-Link', + '{urn:schemas-upnp-org:device-1-0}manufacturerURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}modelDescription': 'WANDevice', + '{urn:schemas-upnp-org:device-1-0}modelName': 'DIR-890L', + '{urn:schemas-upnp-org:device-1-0}modelNumber': '1', + '{urn:schemas-upnp-org:device-1-0}modelURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}serialNumber': '120', + '{urn:schemas-upnp-org:device-1-0}UDN': 'uuid:11111111-2222-3333-4444-555555555555', + '{urn:schemas-upnp-org:device-1-0}serviceList': {'{urn:schemas-upnp-org:device-1-0}service': { + '{urn:schemas-upnp-org:device-1-0}serviceType': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + '{urn:schemas-upnp-org:device-1-0}serviceId': 'urn:upnp-org:serviceId:WANCommonIFC1', + '{urn:schemas-upnp-org:device-1-0}controlURL': '/soap.cgi?service=WANCommonIFC1', + '{urn:schemas-upnp-org:device-1-0}eventSubURL': '/gena.cgi?service=WANCommonIFC1', + '{urn:schemas-upnp-org:device-1-0}SCPDURL': '/WANCommonInterfaceConfig.xml'}}, + '{urn:schemas-upnp-org:device-1-0}deviceList': {'{urn:schemas-upnp-org:device-1-0}device': { + '{urn:schemas-upnp-org:device-1-0}deviceType': 'urn:schemas-upnp-org:device:WANConnectionDevice:1', + '{urn:schemas-upnp-org:device-1-0}friendlyName': 'WANConnectionDevice', + '{urn:schemas-upnp-org:device-1-0}manufacturer': 'D-Link', + '{urn:schemas-upnp-org:device-1-0}manufacturerURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}modelDescription': 'WanConnectionDevice', + '{urn:schemas-upnp-org:device-1-0}modelName': 'DIR-890L', + '{urn:schemas-upnp-org:device-1-0}modelNumber': '1', + '{urn:schemas-upnp-org:device-1-0}modelURL': 'http://www.dlink.com', + '{urn:schemas-upnp-org:device-1-0}serialNumber': '120', + '{urn:schemas-upnp-org:device-1-0}UDN': 'uuid:11111111-2222-3333-4444-555555555555', + '{urn:schemas-upnp-org:device-1-0}serviceList': { + '{urn:schemas-upnp-org:device-1-0}service': [{ + '{urn:schemas-upnp-org:device-1-0}serviceType': 'urn:schemas-upnp-org:service:WANEthernetLinkConfig:1', + '{urn:schemas-upnp-org:device-1-0}serviceId': 'urn:upnp-org:serviceId:WANEthLinkC1', + '{urn:schemas-upnp-org:device-1-0}controlURL': '/soap.cgi?service=WANEthLinkC1', + '{urn:schemas-upnp-org:device-1-0}eventSubURL': '/gena.cgi?service=WANEthLinkC1', + '{urn:schemas-upnp-org:device-1-0}SCPDURL': '/WANEthernetLinkConfig.xml'}, + { + '{urn:schemas-upnp-org:device-1-0}serviceType': 'urn:schemas-upnp-org:service:WANIPConnection:1', + '{urn:schemas-upnp-org:device-1-0}serviceId': 'urn:upnp-org:serviceId:WANIPConn1', + '{urn:schemas-upnp-org:device-1-0}controlURL': '/soap.cgi?service=WANIPConn1', + '{urn:schemas-upnp-org:device-1-0}eventSubURL': '/gena.cgi?service=WANIPConn1', + '{urn:schemas-upnp-org:device-1-0}SCPDURL': '/WANIPConnection.xml'}]}}}}}, + '{urn:schemas-upnp-org:device-1-0}presentationURL': 'http://10.0.0.1'}}} + ) def test_deserialize_get_response(self): self.assertDictEqual(deserialize_scpd_get_response(self.response), self.expected_parsed) @@ -101,6 +206,14 @@ class TestSCPDSerialization(unittest.TestCase): def test_deserialize_blank(self): self.assertDictEqual(deserialize_scpd_get_response(b''), {}) + def test_fail_to_deserialize_invalid_root_device(self): + with self.assertRaises(UPnPError): + deserialize_scpd_get_response(self.response_bad_root_device_name) + + def test_fail_to_deserialize_invalid_root_xmls(self): + with self.assertRaises(UPnPError): + deserialize_scpd_get_response(self.response_bad_root_xmls) + def test_deserialize_to_device_object(self): devices = [] services = [] @@ -173,3 +286,78 @@ class TestSCPDSerialization(unittest.TestCase): }, 'presentationURL': 'http://10.1.10.1/' } self.assertDictEqual(expected_result, device.as_dict()) + + def test_deserialize_another_device(self): + xml_bytes = b"\n\n\n1\n0\n\n\nurn:schemas-upnp-org:device:InternetGatewayDevice:1\nCGA4131COM\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:11111111-2222-3333-4444-555555555556\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:Layer3Forwarding:1\nurn:upnp-org:serviceId:L3Forwarding1\n/Layer3ForwardingSCPD.xml\n/upnp/control/Layer3Forwarding\n/upnp/event/Layer3Forwarding\n\n\n\n\nurn:schemas-upnp-org:device:WANDevice:1\nWANDevice:1\nCisco\nhttp://www.cisco.com/\nCGA4131COM\nCGA4131COM\nCGA4131COM\nhttp://www.cisco.com\n\nuuid:ebf5a0a0-1dd1-11b2-a92f-603d266f9915\nCGA4131COM\n\n\nurn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\nurn:upnp-org:serviceId:WANCommonIFC1\n/WANCommonInterfaceConfigSCPD.xml\n/upnp/control/WANCommonInterfaceConfig0\n/upnp/event/WANCommonInterfaceConfig0\n\n\n\n \n urn:schemas-upnp-org:device:WANConnectionDevice:1\n WANConnectionDevice:1\n Cisco\n http://www.cisco.com/\n CGA4131COM\n CGA4131COM\n CGA4131COM\n http://www.cisco.com\n \n uuid:11111111-2222-3333-4444-555555555555\n CGA4131COM\n \n \n urn:schemas-upnp-org:service:WANIPConnection:1\n urn:upnp-org:serviceId:WANIPConn1\n /WANIPConnectionServiceSCPD.xml\n /upnp/control/WANIPConnection0\n /upnp/event/WANIPConnection0\n \n \n \n\n\n\nhttp://10.1.10.1/\n\n" + expected_parsed = { + 'specVersion': {'major': '1', 'minor': '0'}, + 'device': { + 'deviceType': 'urn:schemas-upnp-org:device:InternetGatewayDevice:1', + 'friendlyName': 'CGA4131COM', + 'manufacturer': 'Cisco', + 'manufacturerURL': 'http://www.cisco.com/', + 'modelDescription': 'CGA4131COM', + 'modelName': 'CGA4131COM', + 'modelNumber': 'CGA4131COM', + 'modelURL': 'http://www.cisco.com', + 'UDN': 'uuid:11111111-2222-3333-4444-555555555556', + 'UPC': 'CGA4131COM', + 'serviceList': { + 'service': { + 'serviceType': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', + 'serviceId': 'urn:upnp-org:serviceId:L3Forwarding1', + 'SCPDURL': '/Layer3ForwardingSCPD.xml', + 'controlURL': '/upnp/control/Layer3Forwarding', + 'eventSubURL': '/upnp/event/Layer3Forwarding' + } + }, + 'deviceList': { + 'device': { + 'deviceType': 'urn:schemas-upnp-org:device:WANDevice:1', + 'friendlyName': 'WANDevice:1', + 'manufacturer': 'Cisco', + 'manufacturerURL': 'http://www.cisco.com/', + 'modelDescription': 'CGA4131COM', + 'modelName': 'CGA4131COM', + 'modelNumber': 'CGA4131COM', + 'modelURL': 'http://www.cisco.com', + 'UDN': 'uuid:ebf5a0a0-1dd1-11b2-a92f-603d266f9915', + 'UPC': 'CGA4131COM', + 'serviceList': { + 'service': { + 'serviceType': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + 'serviceId': 'urn:upnp-org:serviceId:WANCommonIFC1', + 'SCPDURL': '/WANCommonInterfaceConfigSCPD.xml', + 'controlURL': '/upnp/control/WANCommonInterfaceConfig0', + 'eventSubURL': '/upnp/event/WANCommonInterfaceConfig0' + } + }, + 'deviceList': { + 'device': { + 'deviceType': 'urn:schemas-upnp-org:device:WANConnectionDevice:1', + 'friendlyName': 'WANConnectionDevice:1', + 'manufacturer': 'Cisco', + 'manufacturerURL': 'http://www.cisco.com/', + 'modelDescription': 'CGA4131COM', + 'modelName': 'CGA4131COM', + 'modelNumber': 'CGA4131COM', + 'modelURL': 'http://www.cisco.com', + 'UDN': 'uuid:11111111-2222-3333-4444-555555555555', + 'UPC': 'CGA4131COM', + 'serviceList': { + 'service': { + 'serviceType': 'urn:schemas-upnp-org:service:WANIPConnection:1', + 'serviceId': 'urn:upnp-org:serviceId:WANIPConn1', + 'SCPDURL': '/WANIPConnectionServiceSCPD.xml', + 'controlURL': '/upnp/control/WANIPConnection0', + 'eventSubURL': '/upnp/event/WANIPConnection0' + } + } + } + } + } + }, + 'presentationURL': 'http://10.1.10.1/' + } + } + self.assertDictEqual(expected_parsed, deserialize_scpd_get_response(xml_bytes)) diff --git a/tests/serialization/test_soap.py b/tests/serialization/test_soap.py index a238ef5..ea3cf04 100644 --- a/tests/serialization/test_soap.py +++ b/tests/serialization/test_soap.py @@ -28,6 +28,26 @@ class TestSOAPSerialization(unittest.TestCase): b"\r\n" \ b"\n\r\n11.22.33.44\r\n\r\n " + blank_response = b"HTTP/1.1 200 OK\r\n" \ + b"CONTENT-LENGTH: 148\r\n" \ + b"CONTENT-TYPE: text/xml; charset=\"utf-8\"\r\n" \ + b"DATE: Thu, 18 Oct 2018 01:20:23 GMT\r\n" \ + b"EXT:\r\n" \ + b"SERVER: Linux/3.14.28-Prod_17.2, UPnP/1.0, Portable SDK for UPnP devices/1.6.22\r\n" \ + b"X-User-Agent: redsonic\r\n" \ + b"\r\n" \ + b"\n " + + blank_response_body = b"HTTP/1.1 200 OK\r\n" \ + b"CONTENT-LENGTH: 280\r\n" \ + b"CONTENT-TYPE: text/xml; charset=\"utf-8\"\r\n" \ + b"DATE: Thu, 18 Oct 2018 01:20:23 GMT\r\n" \ + b"EXT:\r\n" \ + b"SERVER: Linux/3.14.28-Prod_17.2, UPnP/1.0, Portable SDK for UPnP devices/1.6.22\r\n" \ + b"X-User-Agent: redsonic\r\n" \ + b"\r\n" \ + b"\n\r\n " + error_response = b"HTTP/1.1 500 Internal Server Error\r\n" \ b"Server: WebServer\r\n" \ b"Date: Thu, 11 Oct 2018 22:16:17 GMT\r\n" \ @@ -43,12 +63,29 @@ class TestSOAPSerialization(unittest.TestCase): self.method, self.param_names, self.st, self.gateway_address, self.path, **self.kwargs ), self.post_bytes) + def test_serialize_post_http_host(self): + self.assertEqual(serialize_soap_post( + self.method, self.param_names, self.st, b'http://' + self.gateway_address, self.path, **self.kwargs + ), self.post_bytes) + def test_deserialize_post_response(self): self.assertDictEqual( deserialize_soap_post_response(self.post_response, self.method, service_id=self.st.decode()), {'NewExternalIPAddress': '11.22.33.44'} ) + def test_deserialize_error_response_field_not_found(self): + with self.assertRaises(UPnPError) as e: + deserialize_soap_post_response(self.post_response, self.method + 'derp', service_id=self.st.decode()) + self.assertTrue(str(e.exception).startswith('unknown response fields for GetExternalIPAddressderp')) + + def test_deserialize_blank_response(self): + # TODO: these seem like they should error... this test will break and have to be updated + self.assertDictEqual({}, deserialize_soap_post_response(self.blank_response, self.method, + service_id=self.st.decode())) + self.assertDictEqual({}, deserialize_soap_post_response(self.blank_response_body, self.method, + service_id=self.st.decode())) + def test_raise_from_error_response(self): raised = False try: diff --git a/tests/test_cli.py b/tests/test_cli.py index e1c4da5..e72ea59 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,6 @@ import contextlib from io import StringIO -from tests import TestBase -from tests.mocks import mock_tcp_and_udp +from tests import AsyncioTestCase, mock_tcp_and_udp from collections import OrderedDict from aioupnp.serialization.ssdp import SSDPDatagram from aioupnp.protocols.m_search_patterns import packet_generator @@ -22,7 +21,7 @@ m_search_cli_result = """{ }\n""" -class TestCLI(TestBase): +class TestCLI(AsyncioTestCase): gateway_address = "10.0.0.1" soap_port = 49152 m_search_args = OrderedDict([ @@ -78,7 +77,7 @@ class TestCLI(TestBase): with contextlib.redirect_stdout(actual_output): with mock_tcp_and_udp(self.loop, '10.0.0.1', tcp_replies=self.scpd_replies, udp_replies=self.udp_replies): main( - (None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', 'get-external-ip'), + [None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', 'get-external-ip'], self.loop ) self.assertEqual("11.22.33.44\n", actual_output.getvalue()) @@ -89,7 +88,7 @@ class TestCLI(TestBase): with contextlib.redirect_stdout(actual_output): with mock_tcp_and_udp(self.loop, '10.0.0.1', tcp_replies=self.scpd_replies, udp_replies=self.udp_replies): main( - (None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', 'm-search'), + [None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', 'm-search'], self.loop ) self.assertEqual(timeout_msg, actual_output.getvalue()) @@ -98,7 +97,7 @@ class TestCLI(TestBase): with contextlib.redirect_stdout(actual_output): with mock_tcp_and_udp(self.loop, '10.0.0.1', tcp_replies=self.scpd_replies, udp_replies=self.udp_replies): main( - (None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', '--unicast', 'm-search'), + [None, '--timeout=1', '--gateway_address=10.0.0.1', '--lan_address=10.0.0.2', '--unicast', 'm-search'], self.loop ) self.assertEqual(m_search_cli_result, actual_output.getvalue()) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index c105f11..6366e27 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,10 +1,7 @@ -import asyncio - from aioupnp.fault import UPnPError -from tests import TestBase -from tests.mocks import mock_tcp_and_udp +from tests import AsyncioTestCase, mock_tcp_and_udp from collections import OrderedDict -from aioupnp.gateway import Gateway +from aioupnp.gateway import Gateway, get_action_list from aioupnp.serialization.ssdp import SSDPDatagram @@ -14,7 +11,137 @@ def gen_get_bytes(location: str, host: str) -> bytes: ).encode() -class TestDiscoverDLinkDIR890L(TestBase): +class TestParseActionList(AsyncioTestCase): + test_action_list = {'actionList': { + 'action': [OrderedDict([('name', 'SetConnectionType'), ('argumentList', OrderedDict([('argument', OrderedDict( + [('name', 'NewConnectionType'), ('direction', 'in'), ('relatedStateVariable', 'ConnectionType')]))]))]), + OrderedDict([('name', 'GetConnectionTypeInfo'), ('argumentList', OrderedDict([('argument', [ + OrderedDict([('name', 'NewConnectionType'), ('direction', 'out'), + ('relatedStateVariable', 'ConnectionType')]), OrderedDict( + [('name', 'NewPossibleConnectionTypes'), ('direction', 'out'), + ('relatedStateVariable', 'PossibleConnectionTypes')])])]))]), + OrderedDict([('name', 'RequestConnection')]), OrderedDict([('name', 'ForceTermination')]), + OrderedDict([('name', 'GetStatusInfo'), ('argumentList', OrderedDict([('argument', [OrderedDict( + [('name', 'NewConnectionStatus'), ('direction', 'out'), + ('relatedStateVariable', 'ConnectionStatus')]), OrderedDict( + [('name', 'NewLastConnectionError'), ('direction', 'out'), + ('relatedStateVariable', 'LastConnectionError')]), OrderedDict( + [('name', 'NewUptime'), ('direction', 'out'), ('relatedStateVariable', 'Uptime')])])]))]), + OrderedDict([('name', 'GetNATRSIPStatus'), ('argumentList', OrderedDict([('argument', [OrderedDict( + [('name', 'NewRSIPAvailable'), ('direction', 'out'), + ('relatedStateVariable', 'RSIPAvailable')]), OrderedDict( + [('name', 'NewNATEnabled'), ('direction', 'out'), + ('relatedStateVariable', 'NATEnabled')])])]))]), OrderedDict( + [('name', 'GetGenericPortMappingEntry'), ('argumentList', OrderedDict([('argument', [OrderedDict( + [('name', 'NewPortMappingIndex'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingNumberOfEntries')]), OrderedDict( + [('name', 'NewRemoteHost'), ('direction', 'out'), ('relatedStateVariable', 'RemoteHost')]), + OrderedDict( + [('name', 'NewExternalPort'), ('direction', 'out'), ('relatedStateVariable', 'ExternalPort')]), + OrderedDict( + [('name', 'NewProtocol'), ('direction', 'out'), + ('relatedStateVariable', 'PortMappingProtocol')]), + OrderedDict([('name', + 'NewInternalPort'), + ('direction', + 'out'), ( + 'relatedStateVariable', + 'InternalPort')]), + OrderedDict([('name', + 'NewInternalClient'), + ('direction', + 'out'), ( + 'relatedStateVariable', + 'InternalClient')]), + OrderedDict([('name', + 'NewEnabled'), + ('direction', + 'out'), ( + 'relatedStateVariable', + 'PortMappingEnabled')]), + OrderedDict([('name', + 'NewPortMappingDescription'), + ('direction', + 'out'), ( + 'relatedStateVariable', + 'PortMappingDescription')]), + OrderedDict([('name', + 'NewLeaseDuration'), + ('direction', + 'out'), ( + 'relatedStateVariable', + 'PortMappingLeaseDuration')])])]))]), + OrderedDict([('name', 'GetSpecificPortMappingEntry'), ('argumentList', OrderedDict([('argument', [ + OrderedDict( + [('name', 'NewRemoteHost'), ('direction', 'in'), ('relatedStateVariable', 'RemoteHost')]), + OrderedDict([('name', 'NewExternalPort'), ('direction', 'in'), + ('relatedStateVariable', 'ExternalPort')]), OrderedDict( + [('name', 'NewProtocol'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingProtocol')]), OrderedDict( + [('name', 'NewInternalPort'), ('direction', 'out'), + ('relatedStateVariable', 'InternalPort')]), OrderedDict( + [('name', 'NewInternalClient'), ('direction', 'out'), + ('relatedStateVariable', 'InternalClient')]), OrderedDict( + [('name', 'NewEnabled'), ('direction', 'out'), + ('relatedStateVariable', 'PortMappingEnabled')]), OrderedDict( + [('name', 'NewPortMappingDescription'), ('direction', 'out'), + ('relatedStateVariable', 'PortMappingDescription')]), OrderedDict( + [('name', 'NewLeaseDuration'), ('direction', 'out'), + ('relatedStateVariable', 'PortMappingLeaseDuration')])])]))]), OrderedDict( + [('name', 'AddPortMapping'), ('argumentList', OrderedDict([('argument', [ + OrderedDict( + [('name', 'NewRemoteHost'), ('direction', 'in'), ('relatedStateVariable', 'RemoteHost')]), + OrderedDict( + [('name', 'NewExternalPort'), ('direction', 'in'), ('relatedStateVariable', 'ExternalPort')]), + OrderedDict( + [('name', 'NewProtocol'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingProtocol')]), + OrderedDict( + [('name', 'NewInternalPort'), ('direction', 'in'), ('relatedStateVariable', 'InternalPort')]), + OrderedDict( + [('name', 'NewInternalClient'), ('direction', 'in'), + ('relatedStateVariable', 'InternalClient')]), + OrderedDict( + [('name', 'NewEnabled'), ('direction', 'in'), ('relatedStateVariable', 'PortMappingEnabled')]), + OrderedDict([('name', 'NewPortMappingDescription'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingDescription')]), OrderedDict( + [('name', 'NewLeaseDuration'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingLeaseDuration')])])]))]), OrderedDict( + [('name', 'DeletePortMapping'), ('argumentList', OrderedDict([('argument', [ + OrderedDict( + [('name', 'NewRemoteHost'), ('direction', 'in'), ('relatedStateVariable', 'RemoteHost')]), + OrderedDict( + [('name', 'NewExternalPort'), ('direction', 'in'), ('relatedStateVariable', 'ExternalPort')]), + OrderedDict( + [('name', 'NewProtocol'), ('direction', 'in'), + ('relatedStateVariable', 'PortMappingProtocol')])])]))]), + OrderedDict([('name', 'GetExternalIPAddress'), + ('argumentList', OrderedDict( + [('argument', OrderedDict([('name', 'NewExternalIPAddress'), + ('direction', 'out'), + ('relatedStateVariable', 'ExternalIPAddress')]))]))])]}} + + def test_parse_expected_action_list(self): + expected = [('SetConnectionType', ['NewConnectionType'], []), + ('GetConnectionTypeInfo', [], ['NewConnectionType', 'NewPossibleConnectionTypes']), + ('RequestConnection', [], []), ('ForceTermination', [], []), + ('GetStatusInfo', [], ['NewConnectionStatus', 'NewLastConnectionError', 'NewUptime']), + ('GetNATRSIPStatus', [], ['NewRSIPAvailable', 'NewNATEnabled']), ( + 'GetGenericPortMappingEntry', ['NewPortMappingIndex'], + ['NewRemoteHost', 'NewExternalPort', 'NewProtocol', 'NewInternalPort', 'NewInternalClient', + 'NewEnabled', 'NewPortMappingDescription', 'NewLeaseDuration']), ( + 'GetSpecificPortMappingEntry', ['NewRemoteHost', 'NewExternalPort', 'NewProtocol'], + ['NewInternalPort', 'NewInternalClient', 'NewEnabled', 'NewPortMappingDescription', + 'NewLeaseDuration']), ('AddPortMapping', + ['NewRemoteHost', 'NewExternalPort', 'NewProtocol', 'NewInternalPort', + 'NewInternalClient', 'NewEnabled', 'NewPortMappingDescription', + 'NewLeaseDuration'], []), + ('DeletePortMapping', ['NewRemoteHost', 'NewExternalPort', 'NewProtocol'], []), + ('GetExternalIPAddress', [], ['NewExternalIPAddress'])] + self.assertEqual(expected, get_action_list(self.test_action_list)) + + +class TestDiscoverDLinkDIR890L(AsyncioTestCase): gateway_address = "10.0.0.1" client_address = "10.0.0.2" soap_port = 49152 diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py new file mode 100644 index 0000000..b2a71c2 --- /dev/null +++ b/tests/test_interfaces.py @@ -0,0 +1,55 @@ +import unittest +from unittest import mock + + +class mock_netifaces: + @staticmethod + def gateways(): + return { + "default": { + 2: [ + "192.168.1.1", + "test0" + ] + }, + 2: [ + [ + "192.168.1.1", + "test0", + True + ] + ] + } + @staticmethod + def interfaces(): + return ['test0'] + + @staticmethod + def ifaddresses(interface): + return { + "test0": { + 17: [ + { + "addr": "01:02:03:04:05:06", + "broadcast": "ff:ff:ff:ff:ff:ff" + } + ], + 2: [ + { + "addr": "192.168.1.2", + "netmask": "255.255.255.0", + "broadcast": "192.168.1.255" + } + ], + }, + }[interface] + + +class TestParseInterfaces(unittest.TestCase): + def test_parse_interfaces(self): + with mock.patch('aioupnp.interfaces.get_netifaces') as patch: + patch.return_value = mock_netifaces + import aioupnp.interfaces + gateway, lan = aioupnp.interfaces.get_gateway_and_lan_addresses('test0') + self.assertEqual(gateway, '192.168.1.1') + self.assertEqual(lan, '192.168.1.2') -- 2.45.2 From 65dab4ce9f8dcceed6c192072686423f9e49db8e Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Tue, 21 May 2019 23:42:03 -0400 Subject: [PATCH 3/9] pylint, more mypy refactoring, improve tests --- .pylintrc | 6 +- aioupnp/__init__.py | 3 +- aioupnp/commands.py | 415 +++++++++++++++++++++------------ aioupnp/device.py | 3 +- aioupnp/gateway.py | 8 +- aioupnp/interfaces.py | 12 +- aioupnp/protocols/multicast.py | 2 +- aioupnp/protocols/scpd.py | 9 +- aioupnp/protocols/ssdp.py | 13 +- aioupnp/upnp.py | 303 ++++++++++-------------- aioupnp/util.py | 3 +- tests/__init__.py | 3 + tests/generate_test.py | 0 tests/test_gateway.py | 54 ++--- tests/test_upnp.py | 90 +++++++ 15 files changed, 540 insertions(+), 384 deletions(-) create mode 100644 tests/generate_test.py create mode 100644 tests/test_upnp.py diff --git a/.pylintrc b/.pylintrc index c97b3d2..8fe95e7 100644 --- a/.pylintrc +++ b/.pylintrc @@ -33,7 +33,7 @@ unsafe-load-any-extension=no # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code -# extension-pkg-whitelist= +extension-pkg-whitelist=netifaces, # Allow optimization of some AST trees. This will activate a peephole AST # optimizer, which will apply various small optimizations. For instance, it can @@ -123,7 +123,9 @@ disable= assignment-from-no-return, useless-return, assignment-from-none, - stop-iteration-return + stop-iteration-return, + unsubscriptable-object, + unsupported-membership-test [REPORTS] diff --git a/aioupnp/__init__.py b/aioupnp/__init__.py index 523847a..4f107a6 100644 --- a/aioupnp/__init__.py +++ b/aioupnp/__init__.py @@ -1,7 +1,6 @@ -__version__ = "0.0.12" +__version__ = "0.0.13a" __name__ = "aioupnp" __author__ = "Jack Robison" __maintainer__ = "Jack Robison" __license__ = "MIT" __email__ = "jackrobison@lbry.io" - diff --git a/aioupnp/commands.py b/aioupnp/commands.py index 4e514bf..3200a62 100644 --- a/aioupnp/commands.py +++ b/aioupnp/commands.py @@ -1,7 +1,6 @@ import asyncio import time import typing -import functools import logging from typing import Tuple from aioupnp.protocols.scpd import scpd_post @@ -18,7 +17,7 @@ def soap_bool(x: typing.Optional[str]) -> bool: return False if not x or str(x).lower() in ['false', 'False'] else True -def recast_single_result(t, result): +def recast_single_result(t: type, result: typing.Any) -> typing.Optional[typing.Union[str, int, float, bool]]: if t is bool: return soap_bool(result) if t is str: @@ -26,44 +25,22 @@ def recast_single_result(t, result): return t(result) -def recast_return(return_annotation, result, result_keys: typing.List[str]): - if return_annotation is None: - return None +def recast_return(return_annotation, result: typing.Dict[str, typing.Union[int, str]], + result_keys: typing.List[str]) -> typing.Tuple: + if return_annotation is None or len(result_keys) == 0: + return () if len(result_keys) == 1: assert len(result_keys) == 1 single_result = result[result_keys[0]] - return recast_single_result(return_annotation, single_result) - + return (recast_single_result(return_annotation, single_result), ) annotated_args: typing.List[type] = list(return_annotation.__args__) assert len(annotated_args) == len(result_keys) - recast_results: typing.List[typing.Optional[typing.Union[str, int, bool, bytes]]] = [] + recast_results: typing.List[typing.Optional[typing.Union[str, int, float, bool]]] = [] for type_annotation, result_key in zip(annotated_args, result_keys): - recast_results.append(recast_single_result(type_annotation, result[result_key])) + recast_results.append(recast_single_result(type_annotation, result.get(result_key, None))) return tuple(recast_results) -def soap_command(fn): - @functools.wraps(fn) - async def wrapper(self: 'SOAPCommands', **kwargs): - if not self.is_registered(fn.__name__): - return fn(self, **kwargs) - service = self.get_service(fn.__name__) - assert service.controlURL is not None - assert service.serviceType is not None - response, xml_bytes, err = await scpd_post( - service.controlURL, self._base_address.decode(), self._port, fn.__name__, self._registered[service][fn.__name__][0], - service.serviceType.encode(), self._loop, **kwargs - ) - if err is not None: - - self._requests.append((fn.__name__, kwargs, xml_bytes, None, err, time.time())) - raise err - result = recast_return(fn.__annotations__.get('return'), response, self._registered[service][fn.__name__][1]) - self._requests.append((fn.__name__, kwargs, xml_bytes, result, None, time.time())) - return result - return wrapper - - class SOAPCommands: """ Type annotated wrappers for common UPnP SOAP functions @@ -77,39 +54,41 @@ class SOAPCommands: SOAP_COMMANDS: typing.List[str] = [ 'AddPortMapping', - 'GetNATRSIPStatus', 'GetGenericPortMappingEntry', 'GetSpecificPortMappingEntry', - 'SetConnectionType', - 'GetExternalIPAddress', - 'GetConnectionTypeInfo', - 'GetStatusInfo', - 'ForceTermination', 'DeletePortMapping', - 'RequestConnection', - 'GetCommonLinkProperties', - 'GetTotalBytesSent', - 'GetTotalBytesReceived', - 'GetTotalPacketsSent', - 'GetTotalPacketsReceived', - 'X_GetICSStatistics', - 'GetDefaultConnectionService', - 'SetDefaultConnectionService', - 'SetEnabledForInternet', - 'GetEnabledForInternet', - 'GetMaximumActiveConnections', - 'GetActiveConnections' + 'GetExternalIPAddress', + # 'SetConnectionType', + # 'GetNATRSIPStatus', + # 'GetConnectionTypeInfo', + # 'GetStatusInfo', + # 'ForceTermination', + # 'RequestConnection', + # 'GetCommonLinkProperties', + # 'GetTotalBytesSent', + # 'GetTotalBytesReceived', + # 'GetTotalPacketsSent', + # 'GetTotalPacketsReceived', + # 'X_GetICSStatistics', + # 'GetDefaultConnectionService', + # 'SetDefaultConnectionService', + # 'SetEnabledForInternet', + # 'GetEnabledForInternet', + # 'GetMaximumActiveConnections', + # 'GetActiveConnections' ] def __init__(self, loop: asyncio.AbstractEventLoop, base_address: bytes, port: int) -> None: self._loop = loop self._registered: typing.Dict[Service, typing.Dict[str, typing.Tuple[typing.List[str], typing.List[str]]]] = {} + self._wrappers_no_args: typing.Dict[str, typing.Callable[[], typing.Awaitable[typing.Any]]] = {} + self._wrappers_kwargs: typing.Dict[str, typing.Callable[..., typing.Awaitable[typing.Any]]] = {} + self._base_address = base_address self._port = port self._requests: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, - typing.Optional[typing.Dict[str, typing.Any]], - typing.Optional[Exception], float]] = [] + typing.Tuple, typing.Optional[Exception], float]] = [] def is_registered(self, name: str) -> bool: if name not in self.SOAP_COMMANDS: @@ -127,8 +106,37 @@ class SOAPCommands: return service raise ValueError(name) + def _register_soap_wrapper(self, name: str) -> None: + annotations: typing.Dict[str, typing.Any] = typing.get_type_hints(getattr(self, name)) + service = self.get_service(name) + input_names: typing.List[str] = self._registered[service][name][0] + output_names: typing.List[str] = self._registered[service][name][1] + + async def wrapper(**kwargs: typing.Any) -> typing.Tuple: + + assert service.controlURL is not None + assert service.serviceType is not None + response, xml_bytes, err = await scpd_post( + service.controlURL, self._base_address.decode(), self._port, name, input_names, + service.serviceType.encode(), self._loop, **kwargs + ) + if err is not None: + assert isinstance(xml_bytes, bytes) + self._requests.append((name, kwargs, xml_bytes, (), err, time.time())) + raise err + assert 'return' in annotations + result = recast_return(annotations['return'], response, output_names) + + self._requests.append((name, kwargs, xml_bytes, result, None, time.time())) + return result + + if not len(list(k for k in annotations if k != 'return')): + self._wrappers_no_args[name] = wrapper + else: + self._wrappers_kwargs[name] = wrapper + return None + def register(self, name: str, service: Service, inputs: typing.List[str], outputs: typing.List[str]) -> None: - # control_url: str, service_type: bytes, if name not in self.SOAP_COMMANDS: raise AttributeError(name) if self.is_registered(name): @@ -136,122 +144,227 @@ class SOAPCommands: if service not in self._registered: self._registered[service] = {} self._registered[service][name] = inputs, outputs + self._register_soap_wrapper(name) - @soap_command async def AddPortMapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int, NewInternalClient: str, NewEnabled: int, NewPortMappingDescription: str, NewLeaseDuration: str) -> None: """Returns None""" - raise NotImplementedError() + name = "AddPortMapping" + if not self.is_registered(name): + raise NotImplementedError() + assert name in self._wrappers_kwargs + await self._wrappers_kwargs[name]( + NewRemoteHost=NewRemoteHost, NewExternalPort=NewExternalPort, NewProtocol=NewProtocol, + NewInternalPort=NewInternalPort, NewInternalClient=NewInternalClient, NewEnabled=NewEnabled, + NewPortMappingDescription=NewPortMappingDescription, NewLeaseDuration=NewLeaseDuration + ) + return None - @soap_command - async def GetNATRSIPStatus(self) -> Tuple[bool, bool]: - """Returns (NewRSIPAvailable, NewNATEnabled)""" - raise NotImplementedError() - - @soap_command async def GetGenericPortMappingEntry(self, NewPortMappingIndex: int) -> Tuple[str, int, str, int, str, bool, str, int]: """ Returns (NewRemoteHost, NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration) """ - raise NotImplementedError() + name = "GetGenericPortMappingEntry" + if not self.is_registered(name): + raise NotImplementedError() + assert name in self._wrappers_kwargs + result: Tuple[str, int, str, int, str, bool, str, int] = await self._wrappers_kwargs[name]( + NewPortMappingIndex=NewPortMappingIndex + ) + return result - @soap_command async def GetSpecificPortMappingEntry(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> Tuple[int, str, bool, str, int]: """Returns (NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration)""" - raise NotImplementedError() + name = "GetSpecificPortMappingEntry" + if not self.is_registered(name): + raise NotImplementedError() + assert name in self._wrappers_kwargs + result: Tuple[int, str, bool, str, int] = await self._wrappers_kwargs[name]( + NewRemoteHost=NewRemoteHost, NewExternalPort=NewExternalPort, NewProtocol=NewProtocol + ) + return result - @soap_command - async def SetConnectionType(self, NewConnectionType: str) -> None: - """Returns None""" - raise NotImplementedError() - - @soap_command - async def GetExternalIPAddress(self) -> str: - """Returns (NewExternalIPAddress)""" - raise NotImplementedError() - - @soap_command - async def GetConnectionTypeInfo(self) -> Tuple[str, str]: - """Returns (NewConnectionType, NewPossibleConnectionTypes)""" - raise NotImplementedError() - - @soap_command - async def GetStatusInfo(self) -> Tuple[str, str, int]: - """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" - raise NotImplementedError() - - @soap_command - async def ForceTermination(self) -> None: - """Returns None""" - raise NotImplementedError() - - @soap_command async def DeletePortMapping(self, NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None: """Returns None""" - raise NotImplementedError() + name = "DeletePortMapping" + if not self.is_registered(name): + raise NotImplementedError() + assert name in self._wrappers_kwargs + await self._wrappers_kwargs[name]( + NewRemoteHost=NewRemoteHost, NewExternalPort=NewExternalPort, NewProtocol=NewProtocol + ) + return None - @soap_command - async def RequestConnection(self) -> None: - """Returns None""" - raise NotImplementedError() + async def GetExternalIPAddress(self) -> str: + """Returns (NewExternalIPAddress)""" + name = "GetExternalIPAddress" + if not self.is_registered(name): + raise NotImplementedError() + assert name in self._wrappers_no_args + result: Tuple[str] = await self._wrappers_no_args[name]() + return result[0] - @soap_command - async def GetCommonLinkProperties(self) -> Tuple[str, int, int, str]: - """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)""" - raise NotImplementedError() - - @soap_command - async def GetTotalBytesSent(self) -> int: - """Returns (NewTotalBytesSent)""" - raise NotImplementedError() - - @soap_command - async def GetTotalBytesReceived(self) -> int: - """Returns (NewTotalBytesReceived)""" - raise NotImplementedError() - - @soap_command - async def GetTotalPacketsSent(self) -> int: - """Returns (NewTotalPacketsSent)""" - raise NotImplementedError() - - @soap_command - async def GetTotalPacketsReceived(self) -> int: - """Returns (NewTotalPacketsReceived)""" - raise NotImplementedError() - - @soap_command - async def X_GetICSStatistics(self) -> Tuple[int, int, int, int, str, str]: - """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)""" - raise NotImplementedError() - - @soap_command - async def GetDefaultConnectionService(self) -> str: - """Returns (NewDefaultConnectionService)""" - raise NotImplementedError() - - @soap_command - async def SetDefaultConnectionService(self, NewDefaultConnectionService: str) -> None: - """Returns (None)""" - raise NotImplementedError() - - @soap_command - async def SetEnabledForInternet(self, NewEnabledForInternet: bool) -> None: - raise NotImplementedError() - - @soap_command - async def GetEnabledForInternet(self) -> bool: - raise NotImplementedError() - - @soap_command - async def GetMaximumActiveConnections(self, NewActiveConnectionIndex: int): - raise NotImplementedError() - - @soap_command - async def GetActiveConnections(self) -> Tuple[str, str]: - """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" - raise NotImplementedError() + # async def GetNATRSIPStatus(self) -> Tuple[bool, bool]: + # """Returns (NewRSIPAvailable, NewNATEnabled)""" + # name = "GetNATRSIPStatus" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[bool, bool] = await self._wrappers_no_args[name]() + # return result[0], result[1] + # + # async def SetConnectionType(self, NewConnectionType: str) -> None: + # """Returns None""" + # name = "SetConnectionType" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_kwargs + # await self._wrappers_kwargs[name](NewConnectionType=NewConnectionType) + # return None + # + # async def GetConnectionTypeInfo(self) -> Tuple[str, str]: + # """Returns (NewConnectionType, NewPossibleConnectionTypes)""" + # name = "GetConnectionTypeInfo" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[str, str] = await self._wrappers_no_args[name]() + # return result + # + # async def GetStatusInfo(self) -> Tuple[str, str, int]: + # """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" + # name = "GetStatusInfo" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[str, str, int] = await self._wrappers_no_args[name]() + # return result + # + # async def ForceTermination(self) -> None: + # """Returns None""" + # name = "ForceTermination" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # await self._wrappers_no_args[name]() + # return None + # + # async def RequestConnection(self) -> None: + # """Returns None""" + # name = "RequestConnection" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # await self._wrappers_no_args[name]() + # return None + # + # async def GetCommonLinkProperties(self) -> Tuple[str, int, int, str]: + # """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, + # NewPhysicalLinkStatus)""" + # name = "GetCommonLinkProperties" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[str, int, int, str] = await self._wrappers_no_args[name]() + # return result + # + # async def GetTotalBytesSent(self) -> int: + # """Returns (NewTotalBytesSent)""" + # name = "GetTotalBytesSent" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[int] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def GetTotalBytesReceived(self) -> int: + # """Returns (NewTotalBytesReceived)""" + # name = "GetTotalBytesReceived" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[int] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def GetTotalPacketsSent(self) -> int: + # """Returns (NewTotalPacketsSent)""" + # name = "GetTotalPacketsSent" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[int] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def GetTotalPacketsReceived(self) -> int: + # """Returns (NewTotalPacketsReceived)""" + # name = "GetTotalPacketsReceived" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[int] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def X_GetICSStatistics(self) -> Tuple[int, int, int, int, str, str]: + # """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, + # Layer1DownstreamMaxBitRate, Uptime)""" + # name = "X_GetICSStatistics" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[int, int, int, int, str, str] = await self._wrappers_no_args[name]() + # return result + # + # async def GetDefaultConnectionService(self) -> str: + # """Returns (NewDefaultConnectionService)""" + # name = "GetDefaultConnectionService" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[str] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def SetDefaultConnectionService(self, NewDefaultConnectionService: str) -> None: + # """Returns (None)""" + # name = "SetDefaultConnectionService" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_kwargs + # await self._wrappers_kwargs[name](NewDefaultConnectionService=NewDefaultConnectionService) + # return None + # + # async def SetEnabledForInternet(self, NewEnabledForInternet: bool) -> None: + # name = "SetEnabledForInternet" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_kwargs + # await self._wrappers_kwargs[name](NewEnabledForInternet=NewEnabledForInternet) + # return None + # + # async def GetEnabledForInternet(self) -> bool: + # name = "GetEnabledForInternet" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[bool] = await self._wrappers_no_args[name]() + # return result[0] + # + # async def GetMaximumActiveConnections(self, NewActiveConnectionIndex: int) -> None: + # name = "GetMaximumActiveConnections" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_kwargs + # await self._wrappers_kwargs[name](NewActiveConnectionIndex=NewActiveConnectionIndex) + # return None + # + # async def GetActiveConnections(self) -> Tuple[str, str]: + # """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" + # name = "GetActiveConnections" + # if not self.is_registered(name): + # raise NotImplementedError() + # assert name in self._wrappers_no_args + # result: Tuple[str, str] = await self._wrappers_no_args[name]() + # return result diff --git a/aioupnp/device.py b/aioupnp/device.py index d6d84d4..e3892c4 100644 --- a/aioupnp/device.py +++ b/aioupnp/device.py @@ -6,7 +6,8 @@ log = logging.getLogger(__name__) class CaseInsensitive: - def __init__(self, **kwargs: typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], typing.List[typing.Any]]]) -> None: + def __init__(self, **kwargs: typing.Dict[str, typing.Union[str, typing.Dict[str, typing.Any], + typing.List[typing.Any]]]) -> None: keys: typing.List[str] = list(kwargs.keys()) for k in keys: if not k.startswith("_"): diff --git a/aioupnp/gateway.py b/aioupnp/gateway.py index 1ed4b1e..b40d8cb 100644 --- a/aioupnp/gateway.py +++ b/aioupnp/gateway.py @@ -69,8 +69,6 @@ def parse_location(location: bytes) -> typing.Tuple[bytes, int]: class Gateway: - commands: SOAPCommands - def __init__(self, ok_packet: SSDPDatagram, m_search_args: typing.Dict[str, typing.Union[int, str]], lan_address: str, gateway_address: str, loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: @@ -144,10 +142,10 @@ class Gateway: @property def soap_requests(self) -> typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, - typing.Optional[typing.Dict[str, typing.Any]], + typing.Optional[typing.Tuple], typing.Optional[Exception], float]]: soap_call_infos: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes, - typing.Optional[typing.Dict[str, typing.Any]], + typing.Optional[typing.Tuple], typing.Optional[Exception], float]] = [] soap_call_infos.extend([ (name, request_args, raw_response, decoded_response, soap_error, ts) @@ -241,7 +239,7 @@ class Gateway: task.exception() except asyncio.CancelledError: pass - results: typing.List[asyncio.Future['Gateway']] = list(done) + results: typing.List['asyncio.Future[Gateway]'] = list(done) return results[0].result() async def discover_commands(self, loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> None: diff --git a/aioupnp/interfaces.py b/aioupnp/interfaces.py index 78f11ad..6a03e3d 100644 --- a/aioupnp/interfaces.py +++ b/aioupnp/interfaces.py @@ -8,15 +8,17 @@ def get_netifaces(): return netifaces -def ifaddresses(iface: str): +def ifaddresses(iface: str) -> typing.Dict[int, typing.List[typing.Dict[str, str]]]: return get_netifaces().ifaddresses(iface) -def _get_interfaces(): +def _get_interfaces() -> typing.List[str]: return get_netifaces().interfaces() -def _get_gateways(): +def _get_gateways() -> typing.Dict[typing.Union[str, int], + typing.Union[typing.Dict[int, typing.Tuple[str, str]], + typing.List[typing.Tuple[str, str, bool]]]]: return get_netifaces().gateways() @@ -26,7 +28,7 @@ def get_interfaces() -> typing.Dict[str, typing.Tuple[str, str]]: assert isinstance(infos, list), TypeError(f"expected list from netifaces, got a dict") interface_infos: typing.List[typing.Tuple[str, str, bool]] = infos result: typing.Dict[str, typing.Tuple[str, str]] = OrderedDict( - (interface_name, (router_address, ifaddresses(interface_name)[netifaces.AF_INET][0]['addr'])) + (interface_name, (router_address, ifaddresses(interface_name)[socket.AF_INET][0]['addr'])) for router_address, interface_name, _ in interface_infos ) for interface_name in _get_interfaces(): @@ -40,7 +42,7 @@ def get_interfaces() -> typing.Dict[str, typing.Tuple[str, str]]: _default = gateways['default'] assert isinstance(_default, dict), TypeError(f"expected dict from netifaces, got a list") default: typing.Dict[int, typing.Tuple[str, str]] = _default - result['default'] = result[default[netifaces.AF_INET][1]] + result['default'] = result[default[socket.AF_INET][1]] return result diff --git a/aioupnp/protocols/multicast.py b/aioupnp/protocols/multicast.py index 452b304..4aadb2e 100644 --- a/aioupnp/protocols/multicast.py +++ b/aioupnp/protocols/multicast.py @@ -10,7 +10,7 @@ def _get_sock(transport: typing.Optional[BaseTransport]) -> typing.Optional[sock if transport is None or not hasattr(transport, "_extra"): return None sock: typing.Optional[socket.socket] = transport.get_extra_info('socket', None) - assert sock is None or isinstance(sock, socket.SocketType) or isinstance(sock, mock.MagicMock) + assert sock is None or isinstance(sock, (socket.SocketType, mock.MagicMock)) return sock diff --git a/aioupnp/protocols/scpd.py b/aioupnp/protocols/scpd.py index 3543ba8..9412946 100644 --- a/aioupnp/protocols/scpd.py +++ b/aioupnp/protocols/scpd.py @@ -101,11 +101,11 @@ class SCPDHTTPClientProtocol(Protocol): async def scpd_get(control_url: str, address: str, port: int, - loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> typing.Tuple[typing.Dict[str, typing.Any], bytes, - typing.Optional[Exception]]: + loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> typing.Tuple[ + typing.Dict[str, typing.Any], bytes, typing.Optional[Exception]]: loop = loop or asyncio.get_event_loop() packet = serialize_scpd_get(control_url, address) - finished: asyncio.Future[typing.Tuple[bytes, int, bytes]] = asyncio.Future(loop=loop) + finished: 'asyncio.Future[typing.Tuple[bytes, int, bytes]]' = asyncio.Future(loop=loop) proto_factory: typing.Callable[[], SCPDHTTPClientProtocol] = lambda: SCPDHTTPClientProtocol(packet, finished) connect_tup: typing.Tuple[asyncio.BaseTransport, asyncio.BaseProtocol] = await loop.create_connection( proto_factory, address, port @@ -140,7 +140,7 @@ async def scpd_post(control_url: str, address: str, port: int, method: str, para **kwargs: typing.Dict[str, typing.Any] ) -> typing.Tuple[typing.Dict, bytes, typing.Optional[Exception]]: loop = loop or asyncio.get_event_loop() - finished: asyncio.Future[typing.Tuple[bytes, int, bytes]] = asyncio.Future(loop=loop) + finished: 'asyncio.Future[typing.Tuple[bytes, int, bytes]]' = asyncio.Future(loop=loop) packet = serialize_soap_post(method, param_names, service_id, address.encode(), control_url.encode(), **kwargs) proto_factory: typing.Callable[[], SCPDHTTPClientProtocol] = lambda:\ SCPDHTTPClientProtocol(packet, finished, soap_method=method, soap_service_id=service_id.decode()) @@ -159,6 +159,7 @@ async def scpd_post(control_url: str, address: str, port: int, method: str, para except UPnPError as err: return {}, protocol.response_buff, err finally: + # raw_response = protocol.response_buff transport.close() try: return ( diff --git a/aioupnp/protocols/ssdp.py b/aioupnp/protocols/ssdp.py index a43e117..846f1ce 100644 --- a/aioupnp/protocols/ssdp.py +++ b/aioupnp/protocols/ssdp.py @@ -4,7 +4,6 @@ import asyncio import logging import typing import socket -from collections import OrderedDict from asyncio.transports import DatagramTransport from aioupnp.fault import UPnPError from aioupnp.serialization.ssdp import SSDPDatagram @@ -25,7 +24,7 @@ class SSDPProtocol(MulticastProtocol): self.transport: typing.Optional[DatagramTransport] = None self._unicast = unicast self._ignored: typing.Set[str] = ignored or set() # ignored locations - self._pending_searches: typing.List[typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle]] = [] + self._pending_searches: typing.List[typing.Tuple[str, str, 'asyncio.Future[SSDPDatagram]', asyncio.Handle]] = [] self.notifications: typing.List[SSDPDatagram] = [] self.connected = asyncio.Event(loop=self.loop) @@ -54,12 +53,12 @@ class SSDPProtocol(MulticastProtocol): if packet.location in self._ignored: return None # TODO: fix this - tmp: typing.List[typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle]] = [] - set_futures: typing.List[asyncio.Future[SSDPDatagram]] = [] + tmp: typing.List[typing.Tuple[str, str, 'asyncio.Future[SSDPDatagram]', asyncio.Handle]] = [] + set_futures: typing.List['asyncio.Future[SSDPDatagram]'] = [] while len(self._pending_searches): - t: typing.Tuple[str, str, asyncio.Future[SSDPDatagram], asyncio.Handle] = self._pending_searches.pop() + t: typing.Tuple[str, str, 'asyncio.Future[SSDPDatagram]', asyncio.Handle] = self._pending_searches.pop() if (address == t[0]) and (t[1] in [packet.st, "upnp:rootdevice"]): - f: asyncio.Future[SSDPDatagram] = t[2] + f = t[2] if f not in set_futures: set_futures.append(f) if not f.done(): @@ -80,7 +79,7 @@ class SSDPProtocol(MulticastProtocol): async def m_search(self, address: str, timeout: float, datagrams: typing.List[typing.Dict[str, typing.Union[str, int]]]) -> SSDPDatagram: - fut: asyncio.Future[SSDPDatagram] = asyncio.Future(loop=self.loop) + fut: 'asyncio.Future[SSDPDatagram]' = asyncio.Future(loop=self.loop) for datagram in datagrams: packet = SSDPDatagram("M-SEARCH", datagram) assert packet.st is not None diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py index e16bb0f..7e8a27f 100644 --- a/aioupnp/upnp.py +++ b/aioupnp/upnp.py @@ -1,11 +1,11 @@ -import os +# import os +# import zlib +# import base64 import logging import json import asyncio -import zlib -import base64 from collections import OrderedDict -from typing import Tuple, Dict, List, Union, Optional, Callable +from typing import Tuple, Dict, List, Union, Optional from aioupnp.fault import UPnPError from aioupnp.gateway import Gateway from aioupnp.interfaces import get_gateway_and_lan_addresses @@ -49,7 +49,8 @@ class UPnP: @classmethod async def discover(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 30, - igd_args: OrderedDict = None, interface_name: str = 'default', loop=None): + igd_args: Optional[Dict[str, Union[str, int]]] = None, interface_name: str = 'default', + loop: Optional[asyncio.AbstractEventLoop] = None) -> 'UPnP': try: lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) except Exception as err: @@ -64,7 +65,8 @@ class UPnP: async def m_search(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1, igd_args: Optional[Dict[str, Union[int, str]]] = None, unicast: bool = True, interface_name: str = 'default', - loop=None) -> Dict[str, Union[str, Dict[str, Union[int, str]]]]: + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> Dict[str, Union[str, Dict[str, Union[int, str]]]]: if not lan_address or not gateway_address: try: lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) @@ -98,16 +100,16 @@ class UPnP: ) @cli - async def get_port_mapping_by_index(self, index: int) -> Dict: - result = await self._get_port_mapping_by_index(index) - if result: - if self.gateway.commands.is_registered('GetGenericPortMappingEntry'): - return { - k: v for k, v in zip(self.gateway.commands.GetGenericPortMappingEntry.return_order, result) - } - return {} + async def get_port_mapping_by_index(self, index: int): + return await self._get_port_mapping_by_index(index) + # if result: + # if self.gateway.commands.is_registered('GetGenericPortMappingEntry'): + # return { + # k: v for k, v in zip(self.gateway.commands.GetGenericPortMappingEntry.return_order, result) + # } + # return {} - async def _get_port_mapping_by_index(self, index: int) -> Union[None, Tuple[Optional[str], int, str, + async def _get_port_mapping_by_index(self, index: int) -> Optional[Tuple[Optional[str], int, str, int, str, bool, str, int]]: try: redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) @@ -127,22 +129,20 @@ class UPnP: return redirects @cli - async def get_specific_port_mapping(self, external_port: int, protocol: str) -> Dict: + async def get_specific_port_mapping(self, external_port: int, protocol: str): """ :param external_port: (int) external port to listen on :param protocol: (str) 'UDP' | 'TCP' :return: (int) , (str) , (bool) , (str) , (int) """ - try: - result = await self.gateway.commands.GetSpecificPortMappingEntry( - NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol - ) - if result and self.gateway.commands.is_registered('GetSpecificPortMappingEntry'): - return {k: v for k, v in zip(self.gateway.commands.GetSpecificPortMappingEntry.return_order, result)} - except UPnPError: - pass - return {} + # try: + return await self.gateway.commands.GetSpecificPortMappingEntry( + NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol + ) + # except UPnPError: + # pass + # return {} @cli async def delete_port_mapping(self, external_port: int, protocol: str) -> None: @@ -193,156 +193,103 @@ class UPnP: "client_address": self.lan_address, }, default=_encode, indent=2) - @property - def zipped_debugging_info(self) -> str: - return base64.b64encode(zlib.compress( - json.dumps({ - "gateway": self.gateway.debug_gateway(), - "client_address": self.lan_address, - }, default=_encode, indent=2).encode() - )).decode() - - @cli - async def generate_test_data(self): - print("found gateway via M-SEARCH") - try: - external_ip = await self.get_external_ip() - print("got external ip: %s" % external_ip) - except (UPnPError, NotImplementedError): - print("failed to get the external ip") - try: - await self.get_redirects() - print("got redirects") - except (UPnPError, NotImplementedError): - print("failed to get redirects") - try: - await self.get_specific_port_mapping(4567, "UDP") - print("got specific mapping") - except (UPnPError, NotImplementedError): - print("failed to get specific mapping") - try: - ext_port = await self.get_next_mapping(4567, "UDP", "aioupnp test mapping") - print("set up external mapping to port %i" % ext_port) - try: - await self.get_specific_port_mapping(4567, "UDP") - print("got specific mapping") - except (UPnPError, NotImplementedError): - print("failed to get specific mapping") - try: - await self.get_redirects() - print("got redirects") - except (UPnPError, NotImplementedError): - print("failed to get redirects") - await self.delete_port_mapping(ext_port, "UDP") - print("deleted mapping") - except (UPnPError, NotImplementedError): - print("failed to add and remove a mapping") - try: - await self.get_redirects() - print("got redirects") - except (UPnPError, NotImplementedError): - print("failed to get redirects") - try: - await self.get_specific_port_mapping(4567, "UDP") - print("got specific mapping") - except (UPnPError, NotImplementedError): - print("failed to get specific mapping") - if self.gateway.devices: - device = list(self.gateway.devices.values())[0] - assert device.manufacturer and device.modelName - device_path = os.path.join(os.getcwd(), self.gateway.manufacturer_string) - else: - device_path = os.path.join(os.getcwd(), "UNKNOWN GATEWAY") - with open(device_path, "w") as f: - f.write(await self.debug_gateway()) - return "Generated test data! -> %s" % device_path - - @cli - async def get_natrsip_status(self) -> Tuple[bool, bool]: - """Returns (NewRSIPAvailable, NewNATEnabled)""" - return await self.gateway.commands.GetNATRSIPStatus() - - @cli - async def set_connection_type(self, NewConnectionType: str) -> None: - """Returns None""" - return await self.gateway.commands.SetConnectionType(NewConnectionType) - - @cli - async def get_connection_type_info(self) -> Tuple[str, str]: - """Returns (NewConnectionType, NewPossibleConnectionTypes)""" - return await self.gateway.commands.GetConnectionTypeInfo() - - @cli - async def get_status_info(self) -> Tuple[str, str, int]: - """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" - return await self.gateway.commands.GetStatusInfo() - - @cli - async def force_termination(self) -> None: - """Returns None""" - return await self.gateway.commands.ForceTermination() - - @cli - async def request_connection(self) -> None: - """Returns None""" - return await self.gateway.commands.RequestConnection() - - @cli - async def get_common_link_properties(self): - """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)""" - return await self.gateway.commands.GetCommonLinkProperties() - - @cli - async def get_total_bytes_sent(self): - """Returns (NewTotalBytesSent)""" - return await self.gateway.commands.GetTotalBytesSent() - - @cli - async def get_total_bytes_received(self): - """Returns (NewTotalBytesReceived)""" - return await self.gateway.commands.GetTotalBytesReceived() - - @cli - async def get_total_packets_sent(self): - """Returns (NewTotalPacketsSent)""" - return await self.gateway.commands.GetTotalPacketsSent() - - @cli - async def get_total_packets_received(self): - """Returns (NewTotalPacketsReceived)""" - return await self.gateway.commands.GetTotalPacketsReceived() - - @cli - async def x_get_ics_statistics(self) -> Tuple[int, int, int, int, str, str]: - """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)""" - return await self.gateway.commands.X_GetICSStatistics() - - @cli - async def get_default_connection_service(self): - """Returns (NewDefaultConnectionService)""" - return await self.gateway.commands.GetDefaultConnectionService() - - @cli - async def set_default_connection_service(self, NewDefaultConnectionService: str) -> None: - """Returns (None)""" - return await self.gateway.commands.SetDefaultConnectionService(NewDefaultConnectionService) - - @cli - async def set_enabled_for_internet(self, NewEnabledForInternet: bool) -> None: - return await self.gateway.commands.SetEnabledForInternet(NewEnabledForInternet) - - @cli - async def get_enabled_for_internet(self) -> bool: - return await self.gateway.commands.GetEnabledForInternet() - - @cli - async def get_maximum_active_connections(self, NewActiveConnectionIndex: int): - return await self.gateway.commands.GetMaximumActiveConnections(NewActiveConnectionIndex) - - @cli - async def get_active_connections(self) -> Tuple[str, str]: - """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" - return await self.gateway.commands.GetActiveConnections() + # @property + # def zipped_debugging_info(self) -> str: + # return base64.b64encode(zlib.compress( + # json.dumps({ + # "gateway": self.gateway.debug_gateway(), + # "client_address": self.lan_address, + # }, default=_encode, indent=2).encode() + # )).decode() + # + # @cli + # async def get_natrsip_status(self) -> Tuple[bool, bool]: + # """Returns (NewRSIPAvailable, NewNATEnabled)""" + # return await self.gateway.commands.GetNATRSIPStatus() + # + # @cli + # async def set_connection_type(self, NewConnectionType: str) -> None: + # """Returns None""" + # return await self.gateway.commands.SetConnectionType(NewConnectionType) + # + # @cli + # async def get_connection_type_info(self) -> Tuple[str, str]: + # """Returns (NewConnectionType, NewPossibleConnectionTypes)""" + # return await self.gateway.commands.GetConnectionTypeInfo() + # + # @cli + # async def get_status_info(self) -> Tuple[str, str, int]: + # """Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)""" + # return await self.gateway.commands.GetStatusInfo() + # + # @cli + # async def force_termination(self) -> None: + # """Returns None""" + # return await self.gateway.commands.ForceTermination() + # + # @cli + # async def request_connection(self) -> None: + # """Returns None""" + # return await self.gateway.commands.RequestConnection() + # + # @cli + # async def get_common_link_properties(self): + # """Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, + # NewPhysicalLinkStatus)""" + # return await self.gateway.commands.GetCommonLinkProperties() + # + # @cli + # async def get_total_bytes_sent(self) -> int: + # """Returns (NewTotalBytesSent)""" + # return await self.gateway.commands.GetTotalBytesSent() + # + # @cli + # async def get_total_bytes_received(self): + # """Returns (NewTotalBytesReceived)""" + # return await self.gateway.commands.GetTotalBytesReceived() + # + # @cli + # async def get_total_packets_sent(self): + # """Returns (NewTotalPacketsSent)""" + # return await self.gateway.commands.GetTotalPacketsSent() + # + # @cli + # async def get_total_packets_received(self): + # """Returns (NewTotalPacketsReceived)""" + # return await self.gateway.commands.GetTotalPacketsReceived() + # + # @cli + # async def x_get_ics_statistics(self) -> Tuple[int, int, int, int, str, str]: + # """Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, + # Layer1DownstreamMaxBitRate, Uptime)""" + # return await self.gateway.commands.X_GetICSStatistics() + # + # @cli + # async def get_default_connection_service(self): + # """Returns (NewDefaultConnectionService)""" + # return await self.gateway.commands.GetDefaultConnectionService() + # + # @cli + # async def set_default_connection_service(self, NewDefaultConnectionService: str) -> None: + # """Returns (None)""" + # return await self.gateway.commands.SetDefaultConnectionService(NewDefaultConnectionService) + # + # @cli + # async def set_enabled_for_internet(self, NewEnabledForInternet: bool) -> None: + # return await self.gateway.commands.SetEnabledForInternet(NewEnabledForInternet) + # + # @cli + # async def get_enabled_for_internet(self) -> bool: + # return await self.gateway.commands.GetEnabledForInternet() + # + # @cli + # async def get_maximum_active_connections(self, NewActiveConnectionIndex: int): + # return await self.gateway.commands.GetMaximumActiveConnections(NewActiveConnectionIndex) + # + # @cli + # async def get_active_connections(self) -> Tuple[str, str]: + # """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" + # return await self.gateway.commands.GetActiveConnections() @classmethod def run_cli(cls, method, igd_args: Dict[str, Union[bool, str, int]], lan_address: str = '', @@ -361,8 +308,8 @@ class UPnP: kwargs = kwargs or {} igd_args = igd_args timeout = int(timeout) - loop = loop or asyncio.get_event_loop_policy().get_event_loop() - fut: asyncio.Future = loop.create_future() + loop = loop or asyncio.get_event_loop() + fut: 'asyncio.Future' = asyncio.Future(loop=loop) async def wrapper(): # wrap the upnp setup and call of the command in a coroutine @@ -404,6 +351,6 @@ class UPnP: return if isinstance(result, (list, tuple, dict)): - print(json.dumps(result, indent=2, default=_encode)) + print(json.dumps(result, indent=2)) else: print(result) diff --git a/aioupnp/util.py b/aioupnp/util.py index 3b19504..7553639 100644 --- a/aioupnp/util.py +++ b/aioupnp/util.py @@ -36,7 +36,8 @@ def flatten_keys(to_flatten: str_any_dict, strip: str) -> str_any_dict: return copy -def get_dict_val_case_insensitive(source: typing.Dict[typing.AnyStr, typing.AnyStr], key: typing.AnyStr) -> typing.Optional[typing.AnyStr]: +def get_dict_val_case_insensitive(source: typing.Dict[typing.AnyStr, typing.AnyStr], + key: typing.AnyStr) -> typing.Optional[typing.AnyStr]: match: typing.List[typing.AnyStr] = list(filter(lambda x: x.lower() == key.lower(), source.keys())) if not len(match): return None diff --git a/tests/__init__.py b/tests/__init__.py index 51ce45a..9d87eeb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -29,6 +29,9 @@ def mock_tcp_and_udp(loop, udp_expected_addr=None, udp_replies=None, udp_delay_r sent_tcp_packets.append(data) if data in tcp_replies: loop.call_later(tcp_delay_reply, p.data_received, tcp_replies[data]) + return + else: + pass return _write diff --git a/tests/generate_test.py b/tests/generate_test.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 6366e27..1680ed4 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -171,20 +171,20 @@ class TestDiscoverDLinkDIR890L(AsyncioTestCase): } expected_commands = { - 'GetDefaultConnectionService': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', - 'SetDefaultConnectionService': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', - 'GetCommonLinkProperties': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'GetTotalBytesSent': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'GetTotalBytesReceived': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'GetTotalPacketsSent': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'GetTotalPacketsReceived': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'X_GetICSStatistics': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', - 'SetConnectionType': 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'GetConnectionTypeInfo': 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'RequestConnection': 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'ForceTermination': 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'GetStatusInfo': 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'GetNATRSIPStatus': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'GetDefaultConnectionService': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', + # 'SetDefaultConnectionService': 'urn:schemas-upnp-org:service:Layer3Forwarding:1', + # 'GetCommonLinkProperties': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'GetTotalBytesSent': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'GetTotalBytesReceived': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'GetTotalPacketsSent': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'GetTotalPacketsReceived': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'X_GetICSStatistics': 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1', + # 'SetConnectionType': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'GetConnectionTypeInfo': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'RequestConnection': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'ForceTermination': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'GetStatusInfo': 'urn:schemas-upnp-org:service:WANIPConnection:1', + # 'GetNATRSIPStatus': 'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetGenericPortMappingEntry': 'urn:schemas-upnp-org:service:WANIPConnection:1', 'GetSpecificPortMappingEntry': 'urn:schemas-upnp-org:service:WANIPConnection:1', 'AddPortMapping': 'urn:schemas-upnp-org:service:WANIPConnection:1', @@ -238,22 +238,22 @@ class TestDiscoverNetgearNighthawkAC2350(TestDiscoverDLinkDIR890L): } expected_commands = { - "SetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", - "GetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", - "GetCommonLinkProperties": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", - "GetTotalBytesSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", - "GetTotalBytesReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", - "GetTotalPacketsSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", - "GetTotalPacketsReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + # "SetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", + # "GetDefaultConnectionService": "urn:schemas-upnp-org:service:Layer3Forwarding:1", + # "GetCommonLinkProperties": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + # "GetTotalBytesSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + # "GetTotalBytesReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + # "GetTotalPacketsSent": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", + # "GetTotalPacketsReceived": "urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1", "AddPortMapping": "urn:schemas-upnp-org:service:WANIPConnection:1", "GetExternalIPAddress": "urn:schemas-upnp-org:service:WANIPConnection:1", "DeletePortMapping": "urn:schemas-upnp-org:service:WANIPConnection:1", - "SetConnectionType": "urn:schemas-upnp-org:service:WANIPConnection:1", - "GetConnectionTypeInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", - "RequestConnection": "urn:schemas-upnp-org:service:WANIPConnection:1", - "ForceTermination": "urn:schemas-upnp-org:service:WANIPConnection:1", - "GetStatusInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", - "GetNATRSIPStatus": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "SetConnectionType": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "GetConnectionTypeInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "RequestConnection": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "ForceTermination": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "GetStatusInfo": "urn:schemas-upnp-org:service:WANIPConnection:1", + # "GetNATRSIPStatus": "urn:schemas-upnp-org:service:WANIPConnection:1", "GetGenericPortMappingEntry": "urn:schemas-upnp-org:service:WANIPConnection:1", "GetSpecificPortMappingEntry": "urn:schemas-upnp-org:service:WANIPConnection:1" } diff --git a/tests/test_upnp.py b/tests/test_upnp.py new file mode 100644 index 0000000..b6a2537 --- /dev/null +++ b/tests/test_upnp.py @@ -0,0 +1,90 @@ +from tests import AsyncioTestCase, mock_tcp_and_udp +from collections import OrderedDict +from aioupnp.upnp import UPnP +from aioupnp.gateway import Gateway +from aioupnp.serialization.ssdp import SSDPDatagram + + +class UPnPCommandTestCase(AsyncioTestCase): + gateway_address = "11.2.3.4" + client_address = "11.2.3.5" + soap_port = 49152 + + m_search_args = OrderedDict([ + ("HOST", "239.255.255.250:1900"), + ("MAN", "ssdp:discover"), + ("MX", 1), + ("ST", "urn:schemas-upnp-org:device:WANDevice:1") + ]) + + reply = SSDPDatagram("OK", OrderedDict([ + ("CACHE_CONTROL", "max-age=1800"), + ("LOCATION", "http://11.2.3.4:49152/InternetGatewayDevice.xml"), + ("SERVER", "Linux, UPnP/1.0, DIR-890L Ver 1.20"), + ("ST", "urn:schemas-upnp-org:device:WANDevice:1"), + ("USN", "uuid:11111111-2222-3333-4444-555555555555::urn:schemas-upnp-org:device:WANDevice:1") + ])) + + replies = { + b'GET /InternetGatewayDevice.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 3921\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\n\n\t\n\t\t1\n\t\t0\n\t\n\thttp://11.2.3.4:49152\n\t\n\t\turn:schemas-upnp-org:device:InternetGatewayDevice:1\n\t\tWireless Broadband Router\n\t\tD-Link Corporation\n\t\thttp://www.dlink.com\n\t\tD-Link Router\n\t\tD-Link Router\n\t\tDIR-890L\n\t\thttp://www.dlink.com\n\t\t120\n\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\n\t\t\t\n\t\t\t\timage/gif\n\t\t\t\t118\n\t\t\t\t119\n\t\t\t\t8\n\t\t\t\t/ligd.gif\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-microsoft-com:service:OSInfo:1\n\t\t\t\turn:microsoft-com:serviceId:OSInfo1\n\t\t\t\t/soap.cgi?service=OSInfo1\n\t\t\t\t/gena.cgi?service=OSInfo1\n\t\t\t\t/OSInfo.xml\n\t\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:service:Layer3Forwarding:1\n\t\t\t\turn:upnp-org:serviceId:L3Forwarding1\n\t\t\t\t/soap.cgi?service=L3Forwarding1\n\t\t\t\t/gena.cgi?service=L3Forwarding1\n\t\t\t\t/Layer3Forwarding.xml\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\turn:schemas-upnp-org:device:WANDevice:1\n\t\t\t\tWANDevice\n\t\t\t\tD-Link\n\t\t\t\thttp://www.dlink.com\n\t\t\t\tWANDevice\n\t\t\t\tDIR-890L\n\t\t\t\t1\n\t\t\t\thttp://www.dlink.com\n\t\t\t\t120\n\t\t\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:service:WANCommonInterfaceConfig:1\n\t\t\t\t\t\turn:upnp-org:serviceId:WANCommonIFC1\n\t\t\t\t\t\t/soap.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/gena.cgi?service=WANCommonIFC1\n\t\t\t\t\t\t/WANCommonInterfaceConfig.xml\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\turn:schemas-upnp-org:device:WANConnectionDevice:1\n\t\t\t\t\t\tWANConnectionDevice\n\t\t\t\t\t\tD-Link\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\tWanConnectionDevice\n\t\t\t\t\t\tDIR-890L\n\t\t\t\t\t\t1\n\t\t\t\t\t\thttp://www.dlink.com\n\t\t\t\t\t\t120\n\t\t\t\t\t\tuuid:11111111-2222-3333-4444-555555555555\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANEthernetLinkConfig:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANEthLinkC1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANEthLinkC1\n\t\t\t\t\t\t\t\t/WANEthernetLinkConfig.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\turn:schemas-upnp-org:service:WANIPConnection:1\n\t\t\t\t\t\t\t\turn:upnp-org:serviceId:WANIPConn1\n\t\t\t\t\t\t\t\t/soap.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/gena.cgi?service=WANIPConn1\n\t\t\t\t\t\t\t\t/WANIPConnection.xml\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\thttp://11.2.3.4\n\t\n\n", + b'GET /OSInfo.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 219\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\n\t\n\t\n\n", + b'GET /Layer3Forwarding.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 920\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\t\n\t\t\tGetDefaultConnectionService\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewDefaultConnectionService\n\t\t\t\t\tout\n\t\t\t\t\tDefaultConnectionService\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tSetDefaultConnectionService\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewDefaultConnectionService\n\t\t\t\t\tin\n\t\t\t\t\tDefaultConnectionService\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tDefaultConnectionService\n\t\t\tstring\n\t\t\n\t\n\n", + b'GET /WANCommonInterfaceConfig.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 5343\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\r\n\r\n\t\r\n\t\t1\r\n\t\t0\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tGetCommonLinkProperties\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewWANAccessType\r\n\t\t\t\t\tout\r\n\t\t\t\t\tWANAccessType\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLayer1UpstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1UpstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLayer1DownstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPhysicalLinkStatus\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPhysicalLinkStatus\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalBytesSent\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalBytesSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalBytesReceived\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalBytesReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalPacketsSent\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalPacketsSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetTotalPacketsReceived\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewTotalPacketsReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tX_GetICSStatistics\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesSent\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalBytesReceived\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsSent\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\tout\r\n\t\t\t\t\tTotalPacketsReceived\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tUptime\r\n\t\t\t\t\tout\r\n\t\t\t\t\tX_Uptime\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tWANAccessType\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tDSL\r\n\t\t\t\tPOTS\r\n\t\t\t\tCable\r\n\t\t\t\tEthernet\r\n\t\t\t\tOther\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tLayer1UpstreamMaxBitRate\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tLayer1DownstreamMaxBitRate\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tPhysicalLinkStatus\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tUp\r\n\t\t\t\tDown\r\n\t\t\t\tInitializing\r\n\t\t\t\tUnavailable\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tWANAccessProvider\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tMaximumActiveConnections\r\n\t\t\tui2\r\n\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tTotalBytesSent\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalBytesReceived\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalPacketsSent\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tTotalPacketsReceived\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tX_PersonalFirewallEnabled\r\n\t\t\tboolean\r\n\t\t\r\n\t\t\r\n\t\t\tX_Uptime\r\n\t\t\tui4\r\n\t\t\r\n\t\r\n\r\n", + b'GET /WANEthernetLinkConfig.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 773\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\n\n\t\n\t\t1\n\t\t0\n\t\n\t\n\t\t\n\t\t\tGetEthernetLinkStatus\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tNewEthernetLinkStatus\n\t\t\t\t\tout\n\t\t\t\t\tEthernetLinkStatus\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tEthernetLinkStatus\n\t\t\tstring\n\t\t\t\n\t\t\t\tUp\n\t\t\t\tDown\n\t\t\t\tUnavailable\n\t\t\t\n\t\t\n\t\n\n", + b'GET /WANIPConnection.xml HTTP/1.1\r\nAccept-Encoding: gzip\r\nHost: 11.2.3.4\r\nConnection: Close\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Thu, 11 Oct 2018 22:16:16 GMT\r\nContent-Type: text/xml\r\nContent-Length: 12078\r\nLast-Modified: Thu, 09 Aug 2018 12:41:07 GMT\r\nConnection: close\r\n\r\n\r\n\r\n\t\r\n\t\t1\r\n\t\t0\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tSetConnectionType\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionType\r\n\t\t\t\t\tin\r\n\t\t\t\t\tConnectionType\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t \r\n\t\t\r\n\t\t\tGetConnectionTypeInfo\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionType\r\n\t\t\t\t\tout\r\n\t\t\t\t\tConnectionType\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPossibleConnectionTypes\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPossibleConnectionTypes\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tRequestConnection\r\n\t\t\r\n\t\t\r\n\t\t\tForceTermination\r\n\t\t\r\n\t\t\r\n\t\t\tGetStatusInfo\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewConnectionStatus\r\n\t\t\t\t\tout\r\n\t\t\t\t\tConnectionStatus\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLastConnectionError\r\n\t\t\t\t\tout\r\n\t\t\t\t\tLastConnectionError\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewUptime\r\n\t\t\t\t\tout\r\n\t\t\t\t\tUptime\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetNATRSIPStatus\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRSIPAvailable\r\n\t\t\t\t\tout\r\n\t\t\t\t\tRSIPAvailable\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewNATEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tNATEnabled\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetGenericPortMappingEntry\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingIndex\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingNumberOfEntries\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tout\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetSpecificPortMappingEntry\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tout\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tout\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tAddPortMapping\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tInternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewInternalClient\r\n\t\t\t\t\tin\r\n\t\t\t\t\tInternalClient\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewEnabled\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingEnabled\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewPortMappingDescription\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingDescription\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewLeaseDuration\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingLeaseDuration\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tDeletePortMapping\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewRemoteHost\r\n\t\t\t\t\tin\r\n\t\t\t\t\tRemoteHost\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalPort\r\n\t\t\t\t\tin\r\n\t\t\t\t\tExternalPort\r\n\t\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewProtocol\r\n\t\t\t\t\tin\r\n\t\t\t\t\tPortMappingProtocol\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tGetExternalIPAddress\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\t\tNewExternalIPAddress\r\n\t\t\t\t\tout\r\n\t\t\t\t\tExternalIPAddress\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\r\n\t\r\n\t\t\r\n\t\t\tConnectionType\r\n\t\t\tstring\r\n\t\t\tUnconfigured\r\n\t\t\r\n\t\t\r\n\t\t\tPossibleConnectionTypes\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tUnconfigured\r\n\t\t\t\tIP_Routed\r\n\t\t\t\tIP_Bridged\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tConnectionStatus\r\n\t\t\tstring\r\n\t\t\tUnconfigured\r\n\t\t\t\r\n\t\t\t\tUnconfigured\r\n\t\t\t\tConnecting\r\n\t\t\t\tAuthenticating\r\n\t\t\t\tPendingDisconnect\r\n\t\t\t\tDisconnecting\r\n\t\t\t\tDisconnected\r\n\t\t\t\tConnected\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tUptime\r\n\t\t\tui4\r\n\t\t\t0\r\n\t\t\t\r\n\t\t\t\t0\r\n\t\t\t\t\r\n\t\t\t\t1\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tRSIPAvailable\r\n\t\tboolean\r\n\t\t\t0\r\n\t\t\r\n\t\t\r\n\t\t\tNATEnabled\r\n\t\t\tboolean\r\n\t\t\t1\r\n\t\t \r\n\t\t\r\n\t\t\tX_Name\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tLastConnectionError\r\n\t\t\tstring\r\n\t\t\tERROR_NONE\r\n\t\t\t\r\n\t\t\t\tERROR_NONE\r\n\t\t\t\tERROR_ISP_TIME_OUT\r\n\t\t\t\tERROR_COMMAND_ABORTED\r\n\t\t\t\tERROR_NOT_ENABLED_FOR_INTERNET\r\n\t\t\t\tERROR_BAD_PHONE_NUMBER\r\n\t\t\t\tERROR_USER_DISCONNECT\r\n\t\t\t\tERROR_ISP_DISCONNECT\r\n\t\t\t\tERROR_IDLE_DISCONNECT\r\n\t\t\t\tERROR_FORCED_DISCONNECT\r\n\t\t\t\tERROR_SERVER_OUT_OF_RESOURCES\r\n\t\t\t\tERROR_RESTRICTED_LOGON_HOURS\r\n\t\t\t\tERROR_ACCOUNT_DISABLED\r\n\t\t\t\tERROR_ACCOUNT_EXPIRED\r\n\t\t\t\tERROR_PASSWORD_EXPIRED\r\n\t\t\t\tERROR_AUTHENTICATION_FAILURE\r\n\t\t\t\tERROR_NO_DIALTONE\r\n\t\t\t\tERROR_NO_CARRIER\r\n\t\t\t\tERROR_NO_ANSWER\r\n\t\t\t\tERROR_LINE_BUSY\r\n\t\t\t\tERROR_UNSUPPORTED_BITSPERSECOND\r\n\t\t\t\tERROR_TOO_MANY_LINE_ERRORS\r\n\t\t\t\tERROR_IP_CONFIGURATION\r\n\t\t\t\tERROR_UNKNOWN\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tExternalIPAddress\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tRemoteHost\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tExternalPort\r\n\t\t\tui2\r\n\t\t\r\n\t\t\r\n\t\t\tInternalPort\r\n\t\t\tui2\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingProtocol\r\n\t\t\tstring\r\n\t\t\t\r\n\t\t\t\tTCP\r\n\t\t\t\tUDP\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\tInternalClient\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingDescription\r\n\t\t\tstring\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingEnabled\r\n\t\t\tboolean\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingLeaseDuration\r\n\t\t\tui4\r\n\t\t\r\n\t\t\r\n\t\t\tPortMappingNumberOfEntries\r\n\t\t\tui2\r\n\t\t\r\n\t\r\n\r\n", + } + + +class TestGetExternalIPAddress(UPnPCommandTestCase): + client_address = '11.2.3.222' + def setUp(self) -> None: + self.replies.update({b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 285\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n\r\n': b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:25:57 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 365 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n11.222.3.44\n\n\t\n\n"}) + + async def test_get_external_ip(self): + with mock_tcp_and_udp(self.loop, tcp_replies=self.replies): + gateway = Gateway(self.reply, self.m_search_args, self.client_address, self.gateway_address) + await gateway.discover_commands(self.loop) + upnp = UPnP(self.client_address, self.gateway_address, gateway) + external_ip = await upnp.get_external_ip() + self.assertEqual("11.222.3.44", external_ip) + + +class TestGetGenericPortMappingEntry(UPnPCommandTestCase): + def setUp(self) -> None: + query = b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n0\r\n' + response = b"HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:25:57 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 663 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\n\n9308\nUDP\n9308\n11.2.3.44\n1\n11.2.3.44:9308 to 9308 (UDP)\n0\n\n\t\n\n" + + self.replies.update({ + query: response + }) + + async def test_get_port_mapping_by_index(self): + with mock_tcp_and_udp(self.loop, tcp_replies=self.replies): + gateway = Gateway(self.reply, self.m_search_args, self.client_address, self.gateway_address) + await gateway.discover_commands(self.loop) + upnp = UPnP(self.client_address, self.gateway_address, gateway) + result = await upnp.get_port_mapping_by_index(0) + self.assertEqual((None, 9308, 'UDP', 9308, "11.2.3.44", True, "11.2.3.44:9308 to 9308 (UDP)", 0), result) + + +class TestGetNextPortMapping(UPnPCommandTestCase): + client_address = '11.2.3.4' + + def setUp(self) -> None: + self.replies.update({ + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n0\r\n': b'HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:55:24 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 663 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\n\n9308\nUDP\n9308\n11.2.3.55\n1\n11.2.3.55:9308 to 9308 (UDP)\n0\n\n\t\n\n', + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n1\r\n': b'HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:55:24 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 663 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\n\n9305\nUDP\n9305\n11.2.3.55\n1\n11.2.3.55:9305 to 9305 (UDP)\n0\n\n\t\n\n', + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 341\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetGenericPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n2\r\n': b'HTTP/1.1 500 Internal Server Error\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:55:24 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 482 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\t\t\ts:Client\n\t\t\tUPnPError\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t713\n\t\t\t\t\tSpecifiedArrayIndexInvalid\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n', + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 598\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4567UDP456711.2.3.41aioupnp test mapping0\r\n': b'HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:55:24 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 295 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\t\n\n', + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 379\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n4567UDP\r\n': b'HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 03:55:24 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset=\"utf-8\"\r\nCONTENT-LENGTH: 301 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\t\n\n' + }) + + async def test_get_next_mapping(self): + with mock_tcp_and_udp(self.loop, tcp_replies=self.replies): + gateway = Gateway(self.reply, self.m_search_args, self.client_address, self.gateway_address) + await gateway.discover_commands(self.loop) + upnp = UPnP(self.client_address, self.gateway_address, gateway) + ext_port = await upnp.get_next_mapping(4567, "UDP", "aioupnp test mapping") + self.assertEqual(4567, ext_port) + result = await upnp.delete_port_mapping(ext_port, "UDP") + self.assertEqual(None, result) -- 2.45.2 From 3ddec6ccf09d105c3ab1e95f7509a30c528edf05 Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 00:32:25 -0400 Subject: [PATCH 4/9] travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 092b175..db357b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ jobs: - pip install -e .[test] script: - - mypy . --txt-report . --scripts-are-modules; cat index.txt; rm index.txt + - mypy aioupnp --txt-report . --scripts-are-modules; cat index.txt; rm index.txt - &tests stage: test -- 2.45.2 From f7c8f7a6058900f6aeb5c061002de06467440d8f Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 00:37:21 -0400 Subject: [PATCH 5/9] coveragerc --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index d3ae8b7..7d7f8fb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,4 @@ omit = tests/* stubs/* + stubs/defusedxml/* -- 2.45.2 From 7066b55c304485fcc65d4cfc297f4ebcf2787005 Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 00:45:33 -0400 Subject: [PATCH 6/9] stubs --- .coveragerc | 1 - stubs/{defusedxml/__init__.py => defusedxml.py} | 0 2 files changed, 1 deletion(-) rename stubs/{defusedxml/__init__.py => defusedxml.py} (100%) diff --git a/.coveragerc b/.coveragerc index 7d7f8fb..d3ae8b7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,3 @@ omit = tests/* stubs/* - stubs/defusedxml/* diff --git a/stubs/defusedxml/__init__.py b/stubs/defusedxml.py similarity index 100% rename from stubs/defusedxml/__init__.py rename to stubs/defusedxml.py -- 2.45.2 From 8457ef54e27f97e47b3574e1af5dc56740530ca3 Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 01:56:16 -0400 Subject: [PATCH 7/9] mypy --- aioupnp/__main__.py | 4 +- aioupnp/upnp.py | 237 +++++++++++++++++++++++--------------------- stubs/defusedxml.py | 2 +- 3 files changed, 125 insertions(+), 118 deletions(-) diff --git a/aioupnp/__main__.py b/aioupnp/__main__.py index abef621..7ee15ea 100644 --- a/aioupnp/__main__.py +++ b/aioupnp/__main__.py @@ -4,7 +4,7 @@ import logging import textwrap import typing from collections import OrderedDict -from aioupnp.upnp import UPnP +from aioupnp.upnp import run_cli, UPnP from aioupnp.commands import SOAPCommands log = logging.getLogger("aioupnp") @@ -100,7 +100,7 @@ def main(argv: typing.Optional[typing.List[typing.Optional[str]]] = None, interface: str = str(options.pop('interface')) unicast: bool = bool(options.pop('unicast')) - UPnP.run_cli( + run_cli( command.replace('-', '_'), options, lan_address, gateway_address, timeout, interface, unicast, kwargs, loop ) return 0 diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py index 7e8a27f..98dcb40 100644 --- a/aioupnp/upnp.py +++ b/aioupnp/upnp.py @@ -11,21 +11,17 @@ from aioupnp.gateway import Gateway from aioupnp.interfaces import get_gateway_and_lan_addresses from aioupnp.protocols.ssdp import m_search, fuzzy_m_search from aioupnp.serialization.ssdp import SSDPDatagram +from aioupnp.commands import SOAPCommands log = logging.getLogger(__name__) -def cli(fn): - fn._cli = True - return fn - - -def _encode(x): - if isinstance(x, bytes): - return x.decode() - elif isinstance(x, Exception): - return str(x) - return x +# def _encode(x): +# if isinstance(x, bytes): +# return x.decode() +# elif isinstance(x, Exception): +# return str(x) +# return x class UPnP: @@ -36,7 +32,7 @@ class UPnP: @classmethod def get_annotations(cls, command: str) -> Dict[str, type]: - return getattr(Gateway.commands, command).__annotations__ + return getattr(SOAPCommands, command).__annotations__ @classmethod def get_lan_and_gateway(cls, lan_address: str = '', gateway_address: str = '', @@ -61,7 +57,6 @@ class UPnP: return cls(lan_address, gateway_address, gateway) @classmethod - @cli async def m_search(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1, igd_args: Optional[Dict[str, Union[int, str]]] = None, unicast: bool = True, interface_name: str = 'default', @@ -86,92 +81,87 @@ class UPnP: 'discover_reply': datagram.as_dict() } - @cli async def get_external_ip(self) -> str: return await self.gateway.commands.GetExternalIPAddress() - @cli async def add_port_mapping(self, external_port: int, protocol: str, internal_port: int, lan_address: str, description: str) -> None: - return await self.gateway.commands.AddPortMapping( + await self.gateway.commands.AddPortMapping( NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol, NewInternalPort=internal_port, NewInternalClient=lan_address, NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration='0' ) + return None - @cli - async def get_port_mapping_by_index(self, index: int): - return await self._get_port_mapping_by_index(index) - # if result: - # if self.gateway.commands.is_registered('GetGenericPortMappingEntry'): - # return { - # k: v for k, v in zip(self.gateway.commands.GetGenericPortMappingEntry.return_order, result) - # } - # return {} - - async def _get_port_mapping_by_index(self, index: int) -> Optional[Tuple[Optional[str], int, str, - int, str, bool, str, int]]: + async def get_port_mapping_by_index(self, index: int) -> Optional[Tuple[str, int, str, int, str, bool, str, int]]: try: redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) return redirect except UPnPError: return None - @cli - async def get_redirects(self) -> List[Dict]: - redirects = [] + async def get_redirects(self) -> List[Tuple[str, int, str, int, str, bool, str, int]]: + redirects: List[Tuple[str, int, str, int, str, bool, str, int]] = [] cnt = 0 - redirect = await self.get_port_mapping_by_index(cnt) - while redirect: + redirect: Optional[Tuple[str, int, str, int, str, bool, str, int]] = None + try: + redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + except UPnPError: + pass + while redirect is not None: redirects.append(redirect) cnt += 1 - redirect = await self.get_port_mapping_by_index(cnt) + try: + redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + except UPnPError: + pass return redirects - @cli - async def get_specific_port_mapping(self, external_port: int, protocol: str): + async def get_specific_port_mapping(self, external_port: int, protocol: str) -> Tuple[int, str, bool, str, int]: """ :param external_port: (int) external port to listen on :param protocol: (str) 'UDP' | 'TCP' :return: (int) , (str) , (bool) , (str) , (int) """ - - # try: return await self.gateway.commands.GetSpecificPortMappingEntry( NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol ) - # except UPnPError: - # pass - # return {} - @cli async def delete_port_mapping(self, external_port: int, protocol: str) -> None: """ :param external_port: (int) external port to listen on :param protocol: (str) 'UDP' | 'TCP' :return: None """ - return await self.gateway.commands.DeletePortMapping( + await self.gateway.commands.DeletePortMapping( NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol ) + return None - @cli async def get_next_mapping(self, port: int, protocol: str, description: str, internal_port: Optional[int] = None) -> int: if protocol not in ["UDP", "TCP"]: raise UPnPError("unsupported protocol: {}".format(protocol)) - internal_port = int(internal_port or port) - requested_port = int(internal_port) - redirect_tups = [] + _internal_port = int(internal_port or port) + requested_port = int(_internal_port) + redirect_tups: List[Tuple[str, int, str, int, str, bool, str, int]] = [] cnt = 0 port = int(port) - redirect = await self._get_port_mapping_by_index(cnt) - while redirect: + redirect: Optional[Tuple[str, int, str, int, str, bool, str, int]] = None + try: + redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + except UPnPError: + pass + while redirect is not None: redirect_tups.append(redirect) cnt += 1 - redirect = await self._get_port_mapping_by_index(cnt) + try: + redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + except UPnPError as err: + if "ArrayIndex" in str(err): + break - redirects = { + redirects: Dict[Tuple[int, str], Tuple[str, int, str]] = { (ext_port, proto): (int_host, int_port, desc) for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, _) in redirect_tups } @@ -181,18 +171,20 @@ class UPnP: if int_host == self.lan_address and int_port == requested_port and desc == description: return port port += 1 - await self.add_port_mapping( # set one up - port, protocol, internal_port, self.lan_address, description + await self.gateway.commands.AddPortMapping( + NewRemoteHost='', NewExternalPort=port, NewProtocol=protocol, + NewInternalPort=_internal_port, NewInternalClient=self.lan_address, + NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration='0' ) return port - @cli - async def debug_gateway(self) -> str: - return json.dumps({ - "gateway": self.gateway.debug_gateway(), - "client_address": self.lan_address, - }, default=_encode, indent=2) - + # @cli + # async def debug_gateway(self) -> str: + # return json.dumps({ + # "gateway": self.gateway.debug_gateway(), + # "client_address": self.lan_address, + # }, default=_encode, indent=2) + # # @property # def zipped_debugging_info(self) -> str: # return base64.b64encode(zlib.compress( @@ -291,66 +283,81 @@ class UPnP: # """Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID""" # return await self.gateway.commands.GetActiveConnections() - @classmethod - def run_cli(cls, method, igd_args: Dict[str, Union[bool, str, int]], lan_address: str = '', - gateway_address: str = '', timeout: int = 30, interface_name: str = 'default', - unicast: bool = True, kwargs: Optional[Dict] = None, loop=None) -> None: - """ - :param method: the command name - :param igd_args: ordered case sensitive M-SEARCH headers, if provided all headers to be used must be provided - :param lan_address: the ip address of the local interface - :param gateway_address: the ip address of the gateway - :param timeout: timeout, in seconds - :param interface_name: name of the network interface, the default is aliased to 'default' - :param kwargs: keyword arguments for the command - :param loop: EventLoop, used for testing - """ - kwargs = kwargs or {} - igd_args = igd_args - timeout = int(timeout) - loop = loop or asyncio.get_event_loop() - fut: 'asyncio.Future' = asyncio.Future(loop=loop) - async def wrapper(): # wrap the upnp setup and call of the command in a coroutine +def run_cli(method, igd_args: Dict[str, Union[bool, str, int]], lan_address: str = '', + gateway_address: str = '', timeout: int = 30, interface_name: str = 'default', + unicast: bool = True, kwargs: Optional[Dict] = None, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + """ + :param method: the command name + :param igd_args: ordered case sensitive M-SEARCH headers, if provided all headers to be used must be provided + :param lan_address: the ip address of the local interface + :param gateway_address: the ip address of the gateway + :param timeout: timeout, in seconds + :param interface_name: name of the network interface, the default is aliased to 'default' + :param kwargs: keyword arguments for the command + :param loop: EventLoop, used for testing + """ - if method == 'm_search': # if we're only m_searching don't do any device discovery - fn = lambda *_a, **_kw: cls.m_search( - lan_address, gateway_address, timeout, igd_args, unicast, interface_name, loop + + kwargs = kwargs or {} + igd_args = igd_args + timeout = int(timeout) + loop = loop or asyncio.get_event_loop() + fut: 'asyncio.Future' = asyncio.Future(loop=loop) + + async def wrapper(): # wrap the upnp setup and call of the command in a coroutine + cli_commands = [ + 'm_search', + 'get_external_ip', + 'add_port_mapping', + 'get_port_mapping_by_index', + 'get_redirects', + 'get_specific_port_mapping', + 'delete_port_mapping', + 'get_next_mapping' + ] + + if method == 'm_search': # if we're only m_searching don't do any device discovery + fn = lambda *_a, **_kw: UPnP.m_search( + lan_address, gateway_address, timeout, igd_args, unicast, interface_name, loop + ) + else: # automatically discover the gateway + try: + u = await UPnP.discover( + lan_address, gateway_address, timeout, igd_args, interface_name, loop=loop ) - else: # automatically discover the gateway - try: - u = await cls.discover( - lan_address, gateway_address, timeout, igd_args, interface_name, loop=loop - ) - except UPnPError as err: - fut.set_exception(err) - return - if hasattr(u, method) and hasattr(getattr(u, method), "_cli"): - fn = getattr(u, method) - else: - fut.set_exception(UPnPError("\"%s\" is not a recognized command" % method)) - return - try: # call the command - result = await fn(**{k: fn.__annotations__[k](v) for k, v in kwargs.items()}) - fut.set_result(result) except UPnPError as err: fut.set_exception(err) + return + if method not in cli_commands: + fut.set_exception(UPnPError("\"%s\" is not a recognized command" % method)) + return + else: + fn = getattr(u, method) - except Exception as err: - log.exception("uncaught error") - fut.set_exception(UPnPError("uncaught error: %s" % str(err))) - - if not hasattr(UPnP, method) or not hasattr(getattr(UPnP, method), "_cli"): - fut.set_exception(UPnPError("\"%s\" is not a recognized command" % method)) - else: - loop.run_until_complete(wrapper()) - try: - result = fut.result() + try: # call the command + result = await fn(**{k: fn.__annotations__[k](v) for k, v in kwargs.items()}) + fut.set_result(result) except UPnPError as err: - print("aioupnp encountered an error: %s" % str(err)) - return + fut.set_exception(err) - if isinstance(result, (list, tuple, dict)): - print(json.dumps(result, indent=2)) - else: - print(result) + except Exception as err: + log.exception("uncaught error") + fut.set_exception(UPnPError("uncaught error: %s" % str(err))) + + if not hasattr(UPnP, method): + fut.set_exception(UPnPError("\"%s\" is not a recognized command" % method)) + else: + loop.run_until_complete(wrapper()) + try: + result = fut.result() + except UPnPError as err: + print("aioupnp encountered an error: %s" % str(err)) + return + + if isinstance(result, (list, tuple, dict)): + print(json.dumps(result, indent=2)) + else: + print(result) + return diff --git a/stubs/defusedxml.py b/stubs/defusedxml.py index db9fd1c..bb94534 100644 --- a/stubs/defusedxml.py +++ b/stubs/defusedxml.py @@ -20,4 +20,4 @@ class ElementTree: @classmethod def fromstring(cls, xml_str: str) -> 'ElementTree': - raise NotImplementedError() \ No newline at end of file + raise NotImplementedError() -- 2.45.2 From f5f3cc5f957766faa296789d1a663dc1297f6423 Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 02:14:09 -0400 Subject: [PATCH 8/9] coverage --- aioupnp/upnp.py | 39 ++++++++------------------------------- tests/test_interfaces.py | 5 +++-- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py index 98dcb40..9cb872e 100644 --- a/aioupnp/upnp.py +++ b/aioupnp/upnp.py @@ -93,28 +93,23 @@ class UPnP: ) return None - async def get_port_mapping_by_index(self, index: int) -> Optional[Tuple[str, int, str, int, str, bool, str, int]]: - try: - redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) - return redirect - except UPnPError: - return None + async def get_port_mapping_by_index(self, index: int) -> Tuple[str, int, str, int, str, bool, str, int]: + return await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index) async def get_redirects(self) -> List[Tuple[str, int, str, int, str, bool, str, int]]: redirects: List[Tuple[str, int, str, int, str, bool, str, int]] = [] cnt = 0 - redirect: Optional[Tuple[str, int, str, int, str, bool, str, int]] = None try: - redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + redirect = await self.get_port_mapping_by_index(cnt) except UPnPError: - pass + return redirects while redirect is not None: redirects.append(redirect) cnt += 1 try: - redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) + redirect = await self.get_port_mapping_by_index(cnt) except UPnPError: - pass + break return redirects async def get_specific_port_mapping(self, external_port: int, protocol: str) -> Tuple[int, str, bool, str, int]: @@ -144,22 +139,8 @@ class UPnP: raise UPnPError("unsupported protocol: {}".format(protocol)) _internal_port = int(internal_port or port) requested_port = int(_internal_port) - redirect_tups: List[Tuple[str, int, str, int, str, bool, str, int]] = [] - cnt = 0 port = int(port) - redirect: Optional[Tuple[str, int, str, int, str, bool, str, int]] = None - try: - redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) - except UPnPError: - pass - while redirect is not None: - redirect_tups.append(redirect) - cnt += 1 - try: - redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=cnt) - except UPnPError as err: - if "ArrayIndex" in str(err): - break + redirect_tups = await self.get_redirects() redirects: Dict[Tuple[int, str], Tuple[str, int, str]] = { (ext_port, proto): (int_host, int_port, desc) @@ -171,11 +152,7 @@ class UPnP: if int_host == self.lan_address and int_port == requested_port and desc == description: return port port += 1 - await self.gateway.commands.AddPortMapping( - NewRemoteHost='', NewExternalPort=port, NewProtocol=protocol, - NewInternalPort=_internal_port, NewInternalClient=self.lan_address, - NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration='0' - ) + await self.add_port_mapping(port, protocol, _internal_port, self.lan_address, description) return port # @cli diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index b2a71c2..b86e971 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -1,5 +1,6 @@ import unittest from unittest import mock +from aioupnp.upnp import UPnP class mock_netifaces: @@ -49,7 +50,7 @@ class TestParseInterfaces(unittest.TestCase): def test_parse_interfaces(self): with mock.patch('aioupnp.interfaces.get_netifaces') as patch: patch.return_value = mock_netifaces - import aioupnp.interfaces - gateway, lan = aioupnp.interfaces.get_gateway_and_lan_addresses('test0') + + lan, gateway = UPnP.get_lan_and_gateway(interface_name='test0') self.assertEqual(gateway, '192.168.1.1') self.assertEqual(lan, '192.168.1.2') -- 2.45.2 From d87a1704ef6e02cf47067493898d6a93910161ea Mon Sep 17 00:00:00 2001 From: Jack Robison Date: Wed, 22 May 2019 02:56:59 -0400 Subject: [PATCH 9/9] tests --- aioupnp/interfaces.py | 3 ++- aioupnp/upnp.py | 15 +++++++++------ tests/test_interfaces.py | 18 ++++++++++++++++-- tests/test_upnp.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/aioupnp/interfaces.py b/aioupnp/interfaces.py index 6a03e3d..f4f12bc 100644 --- a/aioupnp/interfaces.py +++ b/aioupnp/interfaces.py @@ -2,6 +2,7 @@ import socket from collections import OrderedDict import typing import netifaces +from aioupnp.fault import UPnPError def get_netifaces(): @@ -50,4 +51,4 @@ def get_gateway_and_lan_addresses(interface_name: str) -> typing.Tuple[str, str] for iface_name, (gateway, lan) in get_interfaces().items(): if interface_name == iface_name: return gateway, lan - return '', '' + raise UPnPError(f'failed to get lan and gateway addresses for {interface_name}') diff --git a/aioupnp/upnp.py b/aioupnp/upnp.py index 9cb872e..a248c6c 100644 --- a/aioupnp/upnp.py +++ b/aioupnp/upnp.py @@ -47,10 +47,7 @@ class UPnP: async def discover(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 30, igd_args: Optional[Dict[str, Union[str, int]]] = None, interface_name: str = 'default', loop: Optional[asyncio.AbstractEventLoop] = None) -> 'UPnP': - try: - lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) - except Exception as err: - raise UPnPError("failed to get lan and gateway addresses: %s" % str(err)) + lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name) gateway = await Gateway.discover_gateway( lan_address, gateway_address, timeout, igd_args, loop ) @@ -135,8 +132,14 @@ class UPnP: async def get_next_mapping(self, port: int, protocol: str, description: str, internal_port: Optional[int] = None) -> int: - if protocol not in ["UDP", "TCP"]: - raise UPnPError("unsupported protocol: {}".format(protocol)) + """ + :param port: (int) external port to redirect from + :param protocol: (str) 'UDP' | 'TCP' + :param description: (str) mapping description + :param internal_port: (int) internal port to redirect to + + :return: (int) + """ _internal_port = int(internal_port or port) requested_port = int(_internal_port) port = int(port) diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index b86e971..f3c7357 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -1,6 +1,7 @@ -import unittest from unittest import mock +from aioupnp.fault import UPnPError from aioupnp.upnp import UPnP +from tests import AsyncioTestCase class mock_netifaces: @@ -46,7 +47,7 @@ class mock_netifaces: }[interface] -class TestParseInterfaces(unittest.TestCase): +class TestParseInterfaces(AsyncioTestCase): def test_parse_interfaces(self): with mock.patch('aioupnp.interfaces.get_netifaces') as patch: patch.return_value = mock_netifaces @@ -54,3 +55,16 @@ class TestParseInterfaces(unittest.TestCase): lan, gateway = UPnP.get_lan_and_gateway(interface_name='test0') self.assertEqual(gateway, '192.168.1.1') self.assertEqual(lan, '192.168.1.2') + + async def test_netifaces_fail(self): + checked = [] + with mock.patch('aioupnp.interfaces.get_netifaces') as patch: + patch.return_value = mock_netifaces + try: + await UPnP.discover(interface_name='test1') + except UPnPError as err: + self.assertEqual(str(err), 'failed to get lan and gateway addresses for test1') + checked.append(True) + else: + self.assertTrue(False) + self.assertTrue(len(checked) == 1) \ No newline at end of file diff --git a/tests/test_upnp.py b/tests/test_upnp.py index b6a2537..0a6d2a3 100644 --- a/tests/test_upnp.py +++ b/tests/test_upnp.py @@ -1,10 +1,22 @@ from tests import AsyncioTestCase, mock_tcp_and_udp from collections import OrderedDict from aioupnp.upnp import UPnP +from aioupnp.fault import UPnPError from aioupnp.gateway import Gateway from aioupnp.serialization.ssdp import SSDPDatagram +class TestGetAnnotations(AsyncioTestCase): + def test_get_annotations(self): + expected = { + 'NewRemoteHost': str, 'NewExternalPort': int, 'NewProtocol': str, 'NewInternalPort': int, + 'NewInternalClient': str, 'NewEnabled': int, 'NewPortMappingDescription': str, + 'NewLeaseDuration': str, 'return': None + } + + self.assertDictEqual(expected, UPnP.get_annotations('AddPortMapping')) + + class UPnPCommandTestCase(AsyncioTestCase): gateway_address = "11.2.3.4" client_address = "11.2.3.5" @@ -88,3 +100,26 @@ class TestGetNextPortMapping(UPnPCommandTestCase): self.assertEqual(4567, ext_port) result = await upnp.delete_port_mapping(ext_port, "UDP") self.assertEqual(None, result) + + +class TestGetSpecificPortMapping(UPnPCommandTestCase): + client_address = '11.2.3.4' + + def setUp(self) -> None: + self.replies.update({ + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 399\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetSpecificPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n1000UDP\r\n': b'HTTP/1.1 500 Internal Server Error\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 06:48:57 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset="utf-8"\r\nCONTENT-LENGTH: 474 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\t\t\ts:Client\n\t\t\tUPnPError\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t714\n\t\t\t\t\tNoSuchEntryInArray\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n', + b'POST /soap.cgi?service=WANIPConn1 HTTP/1.1\r\nHost: 11.2.3.4\r\nUser-Agent: python3/aioupnp, UPnP/1.0, MiniUPnPc/1.9\r\nContent-Length: 399\r\nContent-Type: text/xml\r\nSOAPAction: "urn:schemas-upnp-org:service:WANIPConnection:1#GetSpecificPortMappingEntry"\r\nConnection: Close\r\nCache-Control: no-cache\r\nPragma: no-cache\r\n\r\n\r\n9308UDP\r\n': b'HTTP/1.1 200 OK\r\nServer: WebServer\r\nDate: Wed, 22 May 2019 06:50:07 GMT\r\nConnection: close\r\nCONTENT-TYPE: text/xml; charset="utf-8"\r\nCONTENT-LENGTH: 562 \r\nEXT:\r\n\r\n\n\n\t\n\t\t\n\n9308\n11.2.3.55\n1\n11.2.3.55:9308 to 9308 (UDP)\n0\n\n\t\n\n' + }) + + async def test_get_specific_port_mapping(self): + with mock_tcp_and_udp(self.loop, tcp_replies=self.replies): + gateway = Gateway(self.reply, self.m_search_args, self.client_address, self.gateway_address) + await gateway.discover_commands(self.loop) + upnp = UPnP(self.client_address, self.gateway_address, gateway) + try: + await upnp.get_specific_port_mapping(1000, 'UDP') + except UPnPError: + result = await upnp.get_specific_port_mapping(9308, 'UDP') + self.assertEqual((9308, '11.2.3.55', True, '11.2.3.55:9308 to 9308 (UDP)', 0), result) + else: + self.assertTrue(False) -- 2.45.2