mypy refactor, improve coverage #12

Merged
jackrobison merged 9 commits from mypy-refactor into master 2019-05-22 09:05:10 +02:00
36 changed files with 2525 additions and 1160 deletions

4
.coveragerc Normal file
View file

@ -0,0 +1,4 @@
[run]
omit =
tests/*
stubs/*

3
.gitignore vendored
View file

@ -3,6 +3,9 @@
_trial_temp/
build/
dist/
html/
index.html
mypy-html.css
.coverage
.mypy_cache/
aioupnp.spec

442
.pylintrc Normal file
View file

@ -0,0 +1,442 @@
[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=netifaces,
# 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,
unsubscriptable-object,
unsupported-membership-test
[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*(# )?<?https?://\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

View file

@ -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

View file

@ -1,4 +1,4 @@
__version__ = "0.0.12"
__version__ = "0.0.13a"
__name__ = "aioupnp"
__author__ = "Jack Robison"
__maintainer__ = "Jack Robison"

View file

@ -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.upnp import run_cli, 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 <command>\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)
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
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'))
run_cli(
command.replace('-', '_'), options, lan_address, gateway_address, timeout, interface, unicast, kwargs, loop
)
return 0
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -1,64 +1,44 @@
import logging
import asyncio
import time
import typing
from typing import Tuple, Union, List
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()}
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
)
if err is not None:
self._requests.append((soap_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()))
return 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:
return soap_optional_str(result)
return t(result)
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), )
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, float, bool]]] = []
for type_annotation, result_key in zip(annotated_args, result_keys):
recast_results.append(recast_single_result(type_annotation, result.get(result_key, None)))
return tuple(recast_results)
class SOAPCommands:
@ -72,179 +52,319 @@ class SOAPCommands:
to their expected types.
"""
SOAP_COMMANDS = [
SOAP_COMMANDS: typing.List[str] = [
'AddPortMapping',
'GetNATRSIPStatus',
'GetGenericPortMappingEntry',
'GetSpecificPortMappingEntry',
'SetConnectionType',
'GetExternalIPAddress',
'GetConnectionTypeInfo',
'GetStatusInfo',
'ForceTermination',
'DeletePortMapping',
'RequestConnection',
'GetCommonLinkProperties',
'GetTotalBytesSent',
'GetTotalBytesReceived',
'GetTotalPacketsSent',
'GetTotalPacketsReceived',
'X_GetICSStatistics',
'GetDefaultConnectionService',
'NewDefaultConnectionService',
'NewEnabledForInternet',
'SetDefaultConnectionService',
'SetEnabledForInternet',
'GetEnabledForInternet',
'NewActiveConnectionIndex',
'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):
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._wrappers_no_args: typing.Dict[str, typing.Callable[[], typing.Awaitable[typing.Any]]] = {}
self._wrappers_kwargs: typing.Dict[str, typing.Callable[..., typing.Awaitable[typing.Any]]] = {}
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:
self._base_address = base_address
self._port = port
self._requests: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes,
typing.Tuple, typing.Optional[Exception], float]] = []
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_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:
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
self._register_soap_wrapper(name)
@staticmethod
async def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int,
NewInternalClient: str, NewEnabled: int, NewPortMappingDescription: str,
NewLeaseDuration: str) -> None:
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
@staticmethod
async def GetNATRSIPStatus() -> Tuple[bool, bool]:
"""Returns (NewRSIPAvailable, NewNATEnabled)"""
raise NotImplementedError()
@staticmethod
async def GetGenericPortMappingEntry(NewPortMappingIndex: int) -> Tuple[str, int, str, int, str,
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
@staticmethod
async def GetSpecificPortMappingEntry(NewRemoteHost: str, NewExternalPort: int,
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
@staticmethod
async def SetConnectionType(NewConnectionType: str) -> None:
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
@staticmethod
async def GetExternalIPAddress() -> str:
async def GetExternalIPAddress(self) -> str:
"""Returns (NewExternalIPAddress)"""
raise NotImplementedError()
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]
@staticmethod
async def GetConnectionTypeInfo() -> Tuple[str, str]:
"""Returns (NewConnectionType, NewPossibleConnectionTypes)"""
raise NotImplementedError()
@staticmethod
async def GetStatusInfo() -> Tuple[str, str, int]:
"""Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)"""
raise NotImplementedError()
@staticmethod
async def ForceTermination() -> None:
"""Returns None"""
raise NotImplementedError()
@staticmethod
async def DeletePortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None:
"""Returns None"""
raise NotImplementedError()
@staticmethod
async def RequestConnection() -> None:
"""Returns None"""
raise NotImplementedError()
@staticmethod
async def GetCommonLinkProperties():
"""Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)"""
raise NotImplementedError()
@staticmethod
async def GetTotalBytesSent():
"""Returns (NewTotalBytesSent)"""
raise NotImplementedError()
@staticmethod
async def GetTotalBytesReceived():
"""Returns (NewTotalBytesReceived)"""
raise NotImplementedError()
@staticmethod
async def GetTotalPacketsSent():
"""Returns (NewTotalPacketsSent)"""
raise NotImplementedError()
@staticmethod
def GetTotalPacketsReceived():
"""Returns (NewTotalPacketsReceived)"""
raise NotImplementedError()
@staticmethod
async def X_GetICSStatistics() -> Tuple[int, int, int, int, str, str]:
"""Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)"""
raise NotImplementedError()
@staticmethod
async def GetDefaultConnectionService():
"""Returns (NewDefaultConnectionService)"""
raise NotImplementedError()
@staticmethod
async def SetDefaultConnectionService(NewDefaultConnectionService: str) -> None:
"""Returns (None)"""
raise NotImplementedError()
@staticmethod
async def SetEnabledForInternet(NewEnabledForInternet: bool) -> None:
raise NotImplementedError()
@staticmethod
async def GetEnabledForInternet() -> bool:
raise NotImplementedError()
@staticmethod
async def GetMaximumActiveConnections(NewActiveConnectionIndex: int):
raise NotImplementedError()
@staticmethod
async def GetActiveConnections() -> 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

View file

@ -1,23 +1,34 @@
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 +37,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)

View file

@ -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

View file

@ -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,92 @@ 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: # [(<method>, [<input1>, ...], [<output1, ...]), ...]
def get_action_list(element_dict: typing.Dict[str, typing.Union[str, typing.Dict[str, str],
typing.List[typing.Dict[str, typing.Dict[str, str]]]]]
) -> 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:
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 +118,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 +134,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.Tuple],
typing.Optional[Exception], float]]:
soap_call_infos: typing.List[typing.Tuple[str, typing.Dict[str, typing.Any], bytes,
typing.Optional[typing.Tuple],
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 +174,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 +186,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 +209,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 +230,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 +239,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

54
aioupnp/interfaces.py Normal file
View file

@ -0,0 +1,54 @@
import socket
from collections import OrderedDict
import typing
import netifaces
from aioupnp.fault import UPnPError
def get_netifaces():
return netifaces
def ifaddresses(iface: str) -> typing.Dict[int, typing.List[typing.Dict[str, str]]]:
return get_netifaces().ifaddresses(iface)
def _get_interfaces() -> typing.List[str]:
return get_netifaces().interfaces()
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()
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)[socket.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[socket.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
raise UPnPError(f'failed to get lan and gateway addresses for {interface_name}')

View file

@ -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})

View file

@ -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, 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))

View file

@ -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,33 +129,41 @@ 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:
return {}, protocol.response_buff, err
finally:
# raw_response = protocol.response_buff
transport.close()
try:
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)

View file

@ -3,8 +3,7 @@ import binascii
import asyncio
import logging
import typing
from collections import OrderedDict
from asyncio.futures import Future
import socket
from asyncio.transports import DatagramTransport
from aioupnp.fault import UPnPError
from aioupnp.serialization.ssdp import SSDPDatagram
@ -18,32 +17,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 = t[2]
if f not in set_futures:
set_futures.append(f)
if not f.done():
@ -52,38 +67,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 +122,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 +144,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 +177,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 +186,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

View file

@ -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

View file

@ -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(
"(\<s\:Envelope xmlns\:s=\"http\:\/\/schemas\.xmlsoap\.org\/soap\/envelope\/\"(\s*.)*\>)".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</%s>" % (n, kwargs.get(n), n) for n in param_names)
soap_body = ('\r\n%s\r\n<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
'<u:%s xmlns:u="%s">%s</u:%s></s:Body></s:Envelope>' % (
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)}</{n}>" for n in param_names)
soap_body = (f'\r\n{XML_VERSION}\r\n<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
f's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
f'<u:{method} xmlns:u="{service_id.decode()}">{args}</u:{method}></s:Body></s:Envelope>')
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]

View file

@ -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))

View file

@ -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))

View file

@ -1,32 +1,27 @@
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
from typing import Tuple, Dict, List, Union, Optional
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
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:
@ -35,6 +30,10 @@ class UPnP:
self.gateway_address = gateway_address
self.gateway = gateway
@classmethod
def get_annotations(cls, command: str) -> Dict[str, type]:
return getattr(SOAPCommands, command).__annotations__
@classmethod
def get_lan_and_gateway(cls, lan_address: str = '', gateway_address: str = '',
interface_name: str = 'default') -> Tuple[str, str]:
@ -46,21 +45,20 @@ 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):
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))
igd_args: Optional[Dict[str, Union[str, int]]] = None, interface_name: str = 'default',
loop: Optional[asyncio.AbstractEventLoop] = None) -> 'UPnP':
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
)
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: 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: 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)
@ -80,93 +78,74 @@ 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) -> Dict:
result = await self._get_port_mapping_by_index(index)
if result:
if isinstance(self.gateway.commands.GetGenericPortMappingEntry, SOAPCommand):
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) -> Tuple[str, int, str, int, str, bool, str, int]:
return await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index)
async def _get_port_mapping_by_index(self, index: int) -> Union[None, Tuple[Union[None, 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:
try:
redirect = await self.get_port_mapping_by_index(cnt)
except UPnPError:
return redirects
while redirect is not None:
redirects.append(redirect)
cnt += 1
redirect = await self.get_port_mapping_by_index(cnt)
try:
redirect = await self.get_port_mapping_by_index(cnt)
except UPnPError:
break
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) -> Tuple[int, str, bool, str, int]:
"""
:param external_port: (int) external port to listen on
:param protocol: (str) 'UDP' | 'TCP'
:return: (int) <internal port>, (str) <lan ip>, (bool) <enabled>, (str) <description>, (int) <lease time>
"""
return await self.gateway.commands.GetSpecificPortMappingEntry(
NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol
)
try:
result = await self.gateway.commands.GetSpecificPortMappingEntry(
NewRemoteHost='', NewExternalPort=external_port, NewProtocol=protocol
)
if result and isinstance(self.gateway.commands.GetSpecificPortMappingEntry, SOAPCommand):
return {k: v for k, v in zip(self.gateway.commands.GetSpecificPortMappingEntry.return_order, result)}
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: 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 = []
cnt = 0
async def get_next_mapping(self, port: int, protocol: str, description: str,
internal_port: Optional[int] = None) -> int:
"""
: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) <mapped port>
"""
_internal_port = int(internal_port or port)
requested_port = int(_internal_port)
port = int(port)
redirect = await self._get_port_mapping_by_index(cnt)
while redirect:
redirect_tups.append(redirect)
cnt += 1
redirect = await self._get_port_mapping_by_index(cnt)
redirect_tups = await self.get_redirects()
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
}
@ -176,228 +155,189 @@ 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.add_port_mapping(port, protocol, _internal_port, self.lan_address, description)
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(
# 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()
@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)
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
"""
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:
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()
@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:
"""
: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_policy().get_event_loop()
fut: asyncio.Future = asyncio.Future()
async def wrapper(): # wrap the upnp setup and call of the command in a coroutine
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
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, default=_encode))
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

View file

@ -1,91 +1,49 @@
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")

View file

@ -37,7 +37,7 @@ setup(
packages=find_packages(exclude=('tests',)),
entry_points={'console_scripts': console_scripts},
install_requires=[
'netifaces',
'netifaces', 'defusedxml'
],
extras_require={
'test': (

23
stubs/defusedxml.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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,92 @@ 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
else:
pass
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 +116,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 +162,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 +175,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)

0
tests/generate_test.py Normal file
View file

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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]

View file

@ -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"<?xml version=\"1.0\"?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-0\">\n<specVersion>\n<major>1</major>\n<minor>0</minor>\n</specVersion>\n<device>\n<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>\n<friendlyName>CGA4131COM</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n<SCPDURL>/Layer3ForwardingSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/Layer3Forwarding</controlURL>\n<eventSubURL>/upnp/event/Layer3Forwarding</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n<device>\n<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n<friendlyName>WANDevice:1</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n<SCPDURL>/WANCommonInterfaceConfigSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/WANCommonInterfaceConfig0</controlURL>\n<eventSubURL>/upnp/event/WANCommonInterfaceConfig0</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n <device>\n <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n <friendlyName>WANConnectionDevice:1</friendlyName>\n <manufacturer>Cisco</manufacturer>\n <manufacturerURL>http://www.cisco.com/</manufacturerURL>\n <modelDescription>CGA4131COM</modelDescription>\n <modelName>CGA4131COM</modelName>\n <modelNumber>CGA4131COM</modelNumber>\n <modelURL>http://www.cisco.com</modelURL>\n <serialNumber></serialNumber>\n <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n <UPC>CGA4131COM</UPC>\n <serviceList>\n <service>\n <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n <SCPDURL>/WANIPConnectionServiceSCPD.xml</SCPDURL>\n <controlURL>/upnp/control/WANIPConnection0</controlURL>\n <eventSubURL>/upnp/event/WANIPConnection0</eventSubURL>\n </service>\n </serviceList>\n </device>\n</deviceList>\n</device>\n</deviceList>\n<presentationURL>http://10.1.10.1/</presentationURL></device>\n</root>\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"<?xml version=\"1.0\"?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-?\">\n<specVersion>\n<major>1</major>\n<minor>0</minor>\n</specVersion>\n<device>\n<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevic3:1</deviceType>\n<friendlyName>CGA4131COM</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n<SCPDURL>/Layer3ForwardingSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/Layer3Forwarding</controlURL>\n<eventSubURL>/upnp/event/Layer3Forwarding</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n<device>\n<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n<friendlyName>WANDevice:1</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n<SCPDURL>/WANCommonInterfaceConfigSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/WANCommonInterfaceConfig0</controlURL>\n<eventSubURL>/upnp/event/WANCommonInterfaceConfig0</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n <device>\n <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n <friendlyName>WANConnectionDevice:1</friendlyName>\n <manufacturer>Cisco</manufacturer>\n <manufacturerURL>http://www.cisco.com/</manufacturerURL>\n <modelDescription>CGA4131COM</modelDescription>\n <modelName>CGA4131COM</modelName>\n <modelNumber>CGA4131COM</modelNumber>\n <modelURL>http://www.cisco.com</modelURL>\n <serialNumber></serialNumber>\n <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n <UPC>CGA4131COM</UPC>\n <serviceList>\n <service>\n <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n <SCPDURL>/WANIPConnectionServiceSCPD.xml</SCPDURL>\n <controlURL>/upnp/control/WANIPConnection0</controlURL>\n <eventSubURL>/upnp/event/WANIPConnection0</eventSubURL>\n </service>\n </serviceList>\n </device>\n</deviceList>\n</device>\n</deviceList>\n<presentationURL>http://10.1.10.1/</presentationURL></device>\n</root>\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"<?xml version=\"1.0\"?>\n<root xmlns=\"urn:schemas-upnp--org:device-1-0\">\n<specVersion>\n<major>1</major>\n<minor>0</minor>\n</specVersion>\n<device>\n<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevic3:1</deviceType>\n<friendlyName>CGA4131COM</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n<SCPDURL>/Layer3ForwardingSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/Layer3Forwarding</controlURL>\n<eventSubURL>/upnp/event/Layer3Forwarding</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n<device>\n<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n<friendlyName>WANDevice:1</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n<SCPDURL>/WANCommonInterfaceConfigSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/WANCommonInterfaceConfig0</controlURL>\n<eventSubURL>/upnp/event/WANCommonInterfaceConfig0</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n <device>\n <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n <friendlyName>WANConnectionDevice:1</friendlyName>\n <manufacturer>Cisco</manufacturer>\n <manufacturerURL>http://www.cisco.com/</manufacturerURL>\n <modelDescription>CGA4131COM</modelDescription>\n <modelName>CGA4131COM</modelName>\n <modelNumber>CGA4131COM</modelNumber>\n <modelURL>http://www.cisco.com</modelURL>\n <serialNumber></serialNumber>\n <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n <UPC>CGA4131COM</UPC>\n <serviceList>\n <service>\n <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n <SCPDURL>/WANIPConnectionServiceSCPD.xml</SCPDURL>\n <controlURL>/upnp/control/WANIPConnection0</controlURL>\n <eventSubURL>/upnp/event/WANIPConnection0</eventSubURL>\n </service>\n </serviceList>\n </device>\n</deviceList>\n</device>\n</deviceList>\n<presentationURL>http://10.1.10.1/</presentationURL></device>\n</root>\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('<?xml version="1.0"?>\n<root xmlns="urn:schemas-upnp-org:device-1-0">\n\t<specVersion>\n\t\t<major>1</major>\n\t\t<minor>0</minor>\n\t</specVersion>\n\t<URLBase>http://10.0.0.1:49152</URLBase>\n\t<device>\n\t\t<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>\n\t\t<friendlyName>Wireless Broadband Router</friendlyName>\n\t\t<manufacturer>D-Link Corporation</manufacturer>\n\t\t<manufacturerURL>http://www.dlink.com</manufacturerURL>\n\t\t<modelDescription>D-Link Router</modelDescription>\n\t\t<modelName>D-Link Router</modelName>\n\t\t<modelNumber>DIR-890L</modelNumber>\n\t\t<modelURL>http://www.dlink.com</modelURL>\n\t\t<serialNumber>120</serialNumber>\n\t\t<UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n\t\t<iconList>\n\t\t\t<icon>\n\t\t\t\t<mimetype>image/gif</mimetype>\n\t\t\t\t<width>118</width>\n\t\t\t\t<height>119</height>\n\t\t\t\t<depth>8</depth>\n\t\t\t\t<url>/ligd.gif</url>\n\t\t\t</icon>\n\t\t</iconList>\n\t\t<serviceList>\n\t\t\t<service>\n\t\t\t\t<serviceType>urn:schemas-microsoft-com:service:OSInfo:1</serviceType>\n\t\t\t\t<serviceId>urn:microsoft-com:serviceId:OSInfo1</serviceId>\n\t\t\t\t<controlURL>/soap.cgi?service=OSInfo1</controlURL>\n\t\t\t\t<eventSubURL>/gena.cgi?service=OSInfo1</eventSubURL>\n\t\t\t\t<SCPDURL>/OSInfo.xml</SCPDURL>\n\t\t\t</service>\n\t\t\t<service>\n\t\t\t\t<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n\t\t\t\t<serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n\t\t\t\t<controlURL>/soap.cgi?service=L3Forwarding1</controlURL>\n\t\t\t\t<eventSubURL>/gena.cgi?service=L3Forwarding1</eventSubURL>\n\t\t\t\t<SCPDURL>/Layer3Forwarding.xml</SCPDURL>\n\t\t\t</service>\n\t\t</serviceList>\n\t\t<deviceList>\n\t\t\t<device>\n\t\t\t\t<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n\t\t\t\t<friendlyName>WANDevice</friendlyName>\n\t\t\t\t<manufacturer>D-Link</manufacturer>\n\t\t\t\t<manufacturerURL>http://www.dlink.com</manufacturerURL>\n\t\t\t\t<modelDescription>WANDevice</modelDescription>\n\t\t\t\t<modelName>DIR-890L</modelName>\n\t\t\t\t<modelNumber>1</modelNumber>\n\t\t\t\t<modelURL>http://www.dlink.com</modelURL>\n\t\t\t\t<serialNumber>120</serialNumber>\n\t\t\t\t<UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n\t\t\t\t<serviceList>\n\t\t\t\t\t<service>\n\t\t\t\t\t\t<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n\t\t\t\t\t\t<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n\t\t\t\t\t\t<controlURL>/soap.cgi?service=WANCommonIFC1</controlURL>\n\t\t\t\t\t\t<eventSubURL>/gena.cgi?service=WANCommonIFC1</eventSubURL>\n\t\t\t\t\t\t<SCPDURL>/WANCommonInterfaceConfig.xml</SCPDURL>\n\t\t\t\t\t</service>\n\t\t\t\t</serviceList>\n\t\t\t\t<deviceList>\n\t\t\t\t\t<device>\n\t\t\t\t\t\t<deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n\t\t\t\t\t\t<friendlyName>WANConnectionDevice</friendlyName>\n\t\t\t\t\t\t<manufacturer>D-Link</manufacturer>\n\t\t\t\t\t\t<manufacturerURL>http://www.dlink.com</manufacturerURL>\n\t\t\t\t\t\t<modelDescription>WanConnectionDevice</modelDescription>\n\t\t\t\t\t\t<modelName>DIR-890L</modelName>\n\t\t\t\t\t\t<modelNumber>1</modelNumber>\n\t\t\t\t\t\t<modelURL>http://www.dlink.com</modelURL>\n\t\t\t\t\t\t<serialNumber>120</serialNumber>\n\t\t\t\t\t\t<UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n\t\t\t\t\t\t<serviceList>\n\t\t\t\t\t\t\t<service>\n\t\t\t\t\t\t\t\t<serviceType>urn:schemas-upnp-org:service:WANEthernetLinkConfig:1</serviceType>\n\t\t\t\t\t\t\t\t<serviceId>urn:upnp-org:serviceId:WANEthLinkC1</serviceId>\n\t\t\t\t\t\t\t\t<controlURL>/soap.cgi?service=WANEthLinkC1</controlURL>\n\t\t\t\t\t\t\t\t<eventSubURL>/gena.cgi?service=WANEthLinkC1</eventSubURL>\n\t\t\t\t\t\t\t\t<SCPDURL>/WANEthernetLinkConfig.xml</SCPDURL>\n\t\t\t\t\t\t\t</service>\n\t\t\t\t\t\t\t<service>\n\t\t\t\t\t\t\t\t<serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n\t\t\t\t\t\t\t\t<serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n\t\t\t\t\t\t\t\t<controlURL>/soap.cgi?service=WANIPConn1</controlURL>\n\t\t\t\t\t\t\t\t<eventSubURL>/gena.cgi?service=WANIPConn1</eventSubURL>\n\t\t\t\t\t\t\t\t<SCPDURL>/WANIPConnection.xml</SCPDURL>\n\t\t\t\t\t\t\t</service>\n\t\t\t\t\t\t</serviceList>\n\t\t\t\t\t</device>\n\t\t\t\t</deviceList>\n\t\t\t</device>\n\t\t</deviceList>\n\t\t<presentationURL>http://10.0.0.1</presentationURL>\n\t</device>\n</root>\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"<?xml version=\"1.0\"?>\n<root xmlns=\"urn:schemas-upnp-org:device-1-0\">\n<specVersion>\n<major>1</major>\n<minor>0</minor>\n</specVersion>\n<device>\n<deviceType>urn:schemas-upnp-org:device:InternetGatewayDevice:1</deviceType>\n<friendlyName>CGA4131COM</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:11111111-2222-3333-4444-555555555556</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:Layer3Forwarding:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:L3Forwarding1</serviceId>\n<SCPDURL>/Layer3ForwardingSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/Layer3Forwarding</controlURL>\n<eventSubURL>/upnp/event/Layer3Forwarding</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n<device>\n<deviceType>urn:schemas-upnp-org:device:WANDevice:1</deviceType>\n<friendlyName>WANDevice:1</friendlyName>\n<manufacturer>Cisco</manufacturer>\n<manufacturerURL>http://www.cisco.com/</manufacturerURL>\n<modelDescription>CGA4131COM</modelDescription>\n<modelName>CGA4131COM</modelName>\n<modelNumber>CGA4131COM</modelNumber>\n<modelURL>http://www.cisco.com</modelURL>\n<serialNumber></serialNumber>\n<UDN>uuid:ebf5a0a0-1dd1-11b2-a92f-603d266f9915</UDN>\n<UPC>CGA4131COM</UPC>\n<serviceList>\n<service>\n<serviceType>urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1</serviceType>\n<serviceId>urn:upnp-org:serviceId:WANCommonIFC1</serviceId>\n<SCPDURL>/WANCommonInterfaceConfigSCPD.xml</SCPDURL>\n<controlURL>/upnp/control/WANCommonInterfaceConfig0</controlURL>\n<eventSubURL>/upnp/event/WANCommonInterfaceConfig0</eventSubURL>\n</service>\n</serviceList>\n<deviceList>\n <device>\n <deviceType>urn:schemas-upnp-org:device:WANConnectionDevice:1</deviceType>\n <friendlyName>WANConnectionDevice:1</friendlyName>\n <manufacturer>Cisco</manufacturer>\n <manufacturerURL>http://www.cisco.com/</manufacturerURL>\n <modelDescription>CGA4131COM</modelDescription>\n <modelName>CGA4131COM</modelName>\n <modelNumber>CGA4131COM</modelNumber>\n <modelURL>http://www.cisco.com</modelURL>\n <serialNumber></serialNumber>\n <UDN>uuid:11111111-2222-3333-4444-555555555555</UDN>\n <UPC>CGA4131COM</UPC>\n <serviceList>\n <service>\n <serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType>\n <serviceId>urn:upnp-org:serviceId:WANIPConn1</serviceId>\n <SCPDURL>/WANIPConnectionServiceSCPD.xml</SCPDURL>\n <controlURL>/upnp/control/WANIPConnection0</controlURL>\n <eventSubURL>/upnp/event/WANIPConnection0</eventSubURL>\n </service>\n </serviceList>\n </device>\n</deviceList>\n</device>\n</deviceList>\n<presentationURL>http://10.1.10.1/</presentationURL></device>\n</root>\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))

View file

@ -28,6 +28,26 @@ class TestSOAPSerialization(unittest.TestCase):
b"\r\n" \
b"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>\n<u:GetExternalIPAddressResponse xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\">\r\n<NewExternalIPAddress>11.22.33.44</NewExternalIPAddress>\r\n</u:GetExternalIPAddressResponse>\r\n</s:Body> </s:Envelope>"
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"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>\n</s:Body> </s:Envelope>"
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"<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\"><s:Body>\n<u:GetExternalIPAddressResponse xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"></u:GetExternalIPAddressResponse>\r\n</s:Body> </s:Envelope>"
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:

View file

@ -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())

View file

@ -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
@ -44,20 +171,20 @@ class TestDiscoverDLinkDIR890L(TestBase):
}
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',
@ -111,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"
}

70
tests/test_interfaces.py Normal file
View file

@ -0,0 +1,70 @@
from unittest import mock
from aioupnp.fault import UPnPError
from aioupnp.upnp import UPnP
from tests import AsyncioTestCase
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(AsyncioTestCase):
def test_parse_interfaces(self):
with mock.patch('aioupnp.interfaces.get_netifaces') as patch:
patch.return_value = mock_netifaces
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)

125
tests/test_upnp.py Normal file

File diff suppressed because one or more lines are too long