convert to asyncio
This commit is contained in:
parent
2bd4b5e3e6
commit
02a00e7ef4
31 changed files with 1500 additions and 1354 deletions
|
@ -6,10 +6,10 @@ python: "3.7"
|
||||||
before_install:
|
before_install:
|
||||||
- pip install pylint coverage
|
- pip install pylint coverage
|
||||||
- pip install -e .
|
- pip install -e .
|
||||||
# - pylint txupnp
|
# - pylint aioupnp
|
||||||
|
|
||||||
script:
|
script:
|
||||||
- HOME=/tmp coverage run --source=txupnp -m twisted.trial tests
|
- HOME=/tmp coverage run --source=aioupnp -m unittest -v
|
||||||
|
|
||||||
after_success:
|
after_success:
|
||||||
- bash <(curl -s https://codecov.io/bash)
|
- bash <(curl -s https://codecov.io/bash)
|
||||||
|
|
21
README.md
21
README.md
|
@ -1,28 +1,27 @@
|
||||||
[![codecov](https://codecov.io/gh/lbryio/txupnp/branch/master/graph/badge.svg)](https://codecov.io/gh/lbryio/txupnp)
|
[![codecov](https://codecov.io/gh/lbryio/txupnp/branch/master/graph/badge.svg)](https://codecov.io/gh/lbryio/txupnp)
|
||||||
|
|
||||||
# UPnP for Twisted
|
# UPnP for asyncio
|
||||||
|
|
||||||
`txupnp` is a python 3 library to interact with UPnP gateways using `twisted`
|
`aioupnp` is a python 3 library and command line tool to interact with UPnP gateways using asyncio. `aioupnp` requires the `netifaces` module.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install --upgrade txupnp
|
pip install --upgrade aioupnp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
usage: txupnp-cli [-h] [--debug_logging] [--include_igd_xml] command
|
usage: txupnp [-h] [--debug_logging=<debug_logging>] [--interface=<interface>]
|
||||||
|
[--gateway_address=<gateway_address>]
|
||||||
|
[--lan_address=<lan_address>] [--timeout=<timeout>]
|
||||||
|
[--service=<service>]
|
||||||
|
command [--<arg name>=<arg>]...
|
||||||
|
|
||||||
positional arguments:
|
commands: add_port_mapping | delete_port_mapping | get_external_ip | get_next_mapping | get_port_mapping_by_index | get_redirects | get_soap_commands | get_specific_port_mapping | m_search
|
||||||
command debug_device | list_mappings | get_external_ip |
|
|
||||||
add_mapping | delete_mapping
|
|
||||||
|
|
||||||
optional arguments:
|
for help with a specific command: txupnp help <command>
|
||||||
-h, --help show this help message and exit
|
|
||||||
--debug_logging
|
|
||||||
--include_igd_xml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
__version__ = "0.0.1a11"
|
__version__ = "0.0.1a11"
|
||||||
__name__ = "txupnp"
|
__name__ = "aioupnp"
|
||||||
__author__ = "Jack Robison"
|
__author__ = "Jack Robison"
|
||||||
__maintainer__ = "Jack Robison"
|
__maintainer__ = "Jack Robison"
|
||||||
__license__ = "MIT"
|
__license__ = "MIT"
|
90
aioupnp/__main__.py
Normal file
90
aioupnp/__main__.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from aioupnp.upnp import UPnP
|
||||||
|
from aioupnp.constants import UPNP_ORG_IGD
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger("aioupnp")
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s'))
|
||||||
|
log.addHandler(handler)
|
||||||
|
log.setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_help(command):
|
||||||
|
fn = getattr(UPnP, command)
|
||||||
|
params = command + " " + " ".join(["[--%s=<%s>]" % (k, k) for k in fn.__annotations__ if k != 'return'])
|
||||||
|
return \
|
||||||
|
"usage: aioupnp [--debug_logging=<debug_logging>] [--interface=<interface>]\n" \
|
||||||
|
" [--gateway_address=<gateway_address>]\n" \
|
||||||
|
" [--lan_address=<lan_address>] [--timeout=<timeout>]\n" \
|
||||||
|
" [--service=<service>]\n" \
|
||||||
|
" %s\n" % params
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
commands = [n for n in dir(UPnP) if hasattr(getattr(UPnP, n, None), "_cli")]
|
||||||
|
help_str = " | ".join(commands)
|
||||||
|
usage = \
|
||||||
|
"usage: aioupnp [-h] [--debug_logging=<debug_logging>] [--interface=<interface>]\n" \
|
||||||
|
" [--gateway_address=<gateway_address>]\n" \
|
||||||
|
" [--lan_address=<lan_address>] [--timeout=<timeout>]\n" \
|
||||||
|
" [--service=<service>]\n" \
|
||||||
|
" command [--<arg name>=<arg>]...\n" \
|
||||||
|
"\n" \
|
||||||
|
"commands: %s\n\nfor help with a specific command: aioupnp help <command>" % help_str
|
||||||
|
|
||||||
|
args = sys.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))
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
'debug_logging': False,
|
||||||
|
'interface': 'default',
|
||||||
|
'gateway_address': '',
|
||||||
|
'lan_address': '',
|
||||||
|
'timeout': 1,
|
||||||
|
'service': UPNP_ORG_IGD,
|
||||||
|
'return_as_json': True
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
command = None
|
||||||
|
for arg in args:
|
||||||
|
if arg.startswith("--"):
|
||||||
|
k, v = arg.split("=")
|
||||||
|
k = k.lstrip('--')
|
||||||
|
options[k] = v
|
||||||
|
else:
|
||||||
|
command = arg
|
||||||
|
break
|
||||||
|
if not command:
|
||||||
|
print("no command given")
|
||||||
|
sys.exit(print(usage))
|
||||||
|
kwargs = {}
|
||||||
|
for arg in args[len(options)+1:]:
|
||||||
|
if arg.startswith("--"):
|
||||||
|
k, v = arg.split("=")
|
||||||
|
k = k.lstrip('--')
|
||||||
|
kwargs[k] = v
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
for k, v in defaults.items():
|
||||||
|
if k not in options:
|
||||||
|
options[k] = v
|
||||||
|
|
||||||
|
if options.pop('debug_logging'):
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
UPnP.run_cli(
|
||||||
|
command.replace('-', '_'), options.pop('lan_address'), options.pop('gateway_address'),
|
||||||
|
options.pop('timeout'), options.pop('service'), options.pop('interface'),
|
||||||
|
kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -1,27 +1,25 @@
|
||||||
from txupnp.util import return_types, none_or_str, none
|
def none_or_str(x):
|
||||||
|
return None if not x or x == 'None' else str(x)
|
||||||
|
|
||||||
|
|
||||||
class SCPDCommands: # TODO use type annotations
|
class SCPDCommands:
|
||||||
def debug_commands(self) -> dict:
|
def debug_commands(self) -> dict:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int,
|
||||||
def AddPortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str, NewInternalPort: int,
|
|
||||||
NewInternalClient: str, NewEnabled: bool, NewPortMappingDescription: str,
|
NewInternalClient: str, NewEnabled: bool, NewPortMappingDescription: str,
|
||||||
NewLeaseDuration: str = '') -> None:
|
NewLeaseDuration: str = '') -> None:
|
||||||
"""Returns None"""
|
"""Returns None"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(bool, bool)
|
async def GetNATRSIPStatus() -> (bool, bool):
|
||||||
def GetNATRSIPStatus() -> (bool, bool):
|
|
||||||
"""Returns (NewRSIPAvailable, NewNATEnabled)"""
|
"""Returns (NewRSIPAvailable, NewNATEnabled)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none_or_str, int, str, int, str, bool, str, int)
|
async def GetGenericPortMappingEntry(NewPortMappingIndex: int) -> (none_or_str, int, str, int, str, bool, str, int):
|
||||||
def GetGenericPortMappingEntry(NewPortMappingIndex) -> (none_or_str, int, str, int, str, bool, str, int):
|
|
||||||
"""
|
"""
|
||||||
Returns (NewRemoteHost, NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled,
|
Returns (NewRemoteHost, NewExternalPort, NewProtocol, NewInternalPort, NewInternalClient, NewEnabled,
|
||||||
NewPortMappingDescription, NewLeaseDuration)
|
NewPortMappingDescription, NewLeaseDuration)
|
||||||
|
@ -29,70 +27,62 @@ class SCPDCommands: # TODO use type annotations
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(int, str, bool, str, int)
|
async def GetSpecificPortMappingEntry(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> (int, str, bool, str, int):
|
||||||
def GetSpecificPortMappingEntry(NewRemoteHost, NewExternalPort, NewProtocol) -> (int, str, bool, str, int):
|
|
||||||
"""Returns (NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration)"""
|
"""Returns (NewInternalPort, NewInternalClient, NewEnabled, NewPortMappingDescription, NewLeaseDuration)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def SetConnectionType(NewConnectionType: str) -> None:
|
||||||
def SetConnectionType(NewConnectionType) -> None:
|
|
||||||
"""Returns None"""
|
"""Returns None"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(str)
|
async def GetExternalIPAddress() -> str:
|
||||||
def GetExternalIPAddress() -> str:
|
|
||||||
"""Returns (NewExternalIPAddress)"""
|
"""Returns (NewExternalIPAddress)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(str, str)
|
async def GetConnectionTypeInfo() -> (str, str):
|
||||||
def GetConnectionTypeInfo() -> (str, str):
|
|
||||||
"""Returns (NewConnectionType, NewPossibleConnectionTypes)"""
|
"""Returns (NewConnectionType, NewPossibleConnectionTypes)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(str, str, int)
|
async def GetStatusInfo() -> (str, str, int):
|
||||||
def GetStatusInfo() -> (str, str, int):
|
|
||||||
"""Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)"""
|
"""Returns (NewConnectionStatus, NewLastConnectionError, NewUptime)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def ForceTermination() -> None:
|
||||||
def ForceTermination() -> None:
|
|
||||||
"""Returns None"""
|
"""Returns None"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def DeletePortMapping(NewRemoteHost: str, NewExternalPort: int, NewProtocol: str) -> None:
|
||||||
def DeletePortMapping(NewRemoteHost, NewExternalPort, NewProtocol) -> None:
|
|
||||||
"""Returns None"""
|
"""Returns None"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def RequestConnection() -> None:
|
||||||
def RequestConnection() -> None:
|
|
||||||
"""Returns None"""
|
"""Returns None"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetCommonLinkProperties():
|
async def GetCommonLinkProperties():
|
||||||
"""Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)"""
|
"""Returns (NewWANAccessType, NewLayer1UpstreamMaxBitRate, NewLayer1DownstreamMaxBitRate, NewPhysicalLinkStatus)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetTotalBytesSent():
|
async def GetTotalBytesSent():
|
||||||
"""Returns (NewTotalBytesSent)"""
|
"""Returns (NewTotalBytesSent)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetTotalBytesReceived():
|
async def GetTotalBytesReceived():
|
||||||
"""Returns (NewTotalBytesReceived)"""
|
"""Returns (NewTotalBytesReceived)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetTotalPacketsSent():
|
async def GetTotalPacketsSent():
|
||||||
"""Returns (NewTotalPacketsSent)"""
|
"""Returns (NewTotalPacketsSent)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@ -102,36 +92,33 @@ class SCPDCommands: # TODO use type annotations
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def X_GetICSStatistics():
|
async def X_GetICSStatistics() -> (int, int, int, int, str, str):
|
||||||
"""Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)"""
|
"""Returns (TotalBytesSent, TotalBytesReceived, TotalPacketsSent, TotalPacketsReceived, Layer1DownstreamMaxBitRate, Uptime)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetDefaultConnectionService():
|
async def GetDefaultConnectionService():
|
||||||
"""Returns (NewDefaultConnectionService)"""
|
"""Returns (NewDefaultConnectionService)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def SetDefaultConnectionService(NewDefaultConnectionService) -> None:
|
async def SetDefaultConnectionService(NewDefaultConnectionService: str) -> None:
|
||||||
"""Returns (None)"""
|
"""Returns (None)"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(none)
|
async def SetEnabledForInternet(NewEnabledForInternet: bool) -> None:
|
||||||
def SetEnabledForInternet(NewEnabledForInternet) -> None:
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(bool)
|
async def GetEnabledForInternet() -> bool:
|
||||||
def GetEnabledForInternet() -> bool:
|
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def GetMaximumActiveConnections(NewActiveConnectionIndex):
|
async def GetMaximumActiveConnections(NewActiveConnectionIndex: int):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@return_types(str, str)
|
async def GetActiveConnections() -> (str, str):
|
||||||
def GetActiveConnections() -> (str, str):
|
|
||||||
"""Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID"""
|
"""Returns (NewActiveConnDeviceContainer, NewActiveConnectionServiceID"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
|
@ -18,11 +18,6 @@ WAN_SCHEMA = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1'
|
||||||
LAYER_SCHEMA = 'urn:schemas-upnp-org:service:Layer3Forwarding:1'
|
LAYER_SCHEMA = 'urn:schemas-upnp-org:service:Layer3Forwarding:1'
|
||||||
IP_SCHEMA = 'urn:schemas-upnp-org:service:WANIPConnection:1'
|
IP_SCHEMA = 'urn:schemas-upnp-org:service:WANIPConnection:1'
|
||||||
|
|
||||||
service_types = [
|
|
||||||
UPNP_ORG_IGD,
|
|
||||||
# WIFI_ALLIANCE_ORG_IGD,
|
|
||||||
]
|
|
||||||
|
|
||||||
SSDP_IP_ADDRESS = '239.255.255.250'
|
SSDP_IP_ADDRESS = '239.255.255.250'
|
||||||
SSDP_PORT = 1900
|
SSDP_PORT = 1900
|
||||||
SSDP_HOST = "%s:%i" % (SSDP_IP_ADDRESS, SSDP_PORT)
|
SSDP_HOST = "%s:%i" % (SSDP_IP_ADDRESS, SSDP_PORT)
|
95
aioupnp/device.py
Normal file
95
aioupnp/device.py
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CaseInsensitive:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
not_evaluated = {}
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
if k.startswith("_"):
|
||||||
|
not_evaluated[k] = v
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
getattr(self, k)
|
||||||
|
setattr(self, k, v)
|
||||||
|
except AttributeError as err:
|
||||||
|
not_evaluated[k] = v
|
||||||
|
if not_evaluated:
|
||||||
|
log.debug("%s did not apply kwargs: %s", self.__class__.__name__, not_evaluated)
|
||||||
|
|
||||||
|
def _get_attr_name(self, case_insensitive: str) -> str:
|
||||||
|
for k, v in self.__dict__.items():
|
||||||
|
if k.lower() == case_insensitive.lower():
|
||||||
|
return k
|
||||||
|
|
||||||
|
def __getattr__(self, item):
|
||||||
|
if item in self.__dict__:
|
||||||
|
return self.__dict__[item]
|
||||||
|
for k, v in self.__class__.__dict__.items():
|
||||||
|
if k.lower() == item.lower():
|
||||||
|
if k not in self.__dict__:
|
||||||
|
self.__dict__[k] = v
|
||||||
|
return v
|
||||||
|
raise AttributeError(item)
|
||||||
|
|
||||||
|
def __setattr__(self, item, value):
|
||||||
|
if item in self.__dict__:
|
||||||
|
self.__dict__[item] = value
|
||||||
|
return
|
||||||
|
to_update = None
|
||||||
|
for k, v in self.__dict__.items():
|
||||||
|
if k.lower() == item.lower():
|
||||||
|
to_update = k
|
||||||
|
break
|
||||||
|
self.__dict__[to_update or item] = value
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
k: v for k, v in self.__dict__.items() if not k.startswith("_") and not callable(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Service(CaseInsensitive):
|
||||||
|
serviceType = None
|
||||||
|
serviceId = None
|
||||||
|
controlURL = None
|
||||||
|
eventSubURL = None
|
||||||
|
SCPDURL = 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
|
||||||
|
|
||||||
|
def __init__(self, devices, services, **kwargs):
|
||||||
|
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 self.deviceList:
|
||||||
|
for kw in self.deviceList.values():
|
||||||
|
if isinstance(kw, dict):
|
||||||
|
d = Device(devices, services, **kw)
|
||||||
|
devices.append(d)
|
||||||
|
elif isinstance(kw, list):
|
||||||
|
for _inner_kw in kw:
|
||||||
|
d = Device(devices, services, **_inner_kw)
|
||||||
|
devices.append(d)
|
||||||
|
else:
|
||||||
|
log.warning("failed to parse device:\n%s", kw)
|
|
@ -1,5 +1,5 @@
|
||||||
from txupnp.util import flatten_keys
|
from aioupnp.util import flatten_keys
|
||||||
from txupnp.constants import FAULT, CONTROL
|
from aioupnp.constants import FAULT, CONTROL
|
||||||
|
|
||||||
|
|
||||||
class UPnPError(Exception):
|
class UPnPError(Exception):
|
181
aioupnp/gateway.py
Normal file
181
aioupnp/gateway.py
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
import logging
|
||||||
|
from aioupnp.util import get_dict_val_case_insensitive, BASE_PORT_REGEX, BASE_ADDRESS_REGEX
|
||||||
|
from aioupnp.constants import SPEC_VERSION, UPNP_ORG_IGD, SERVICE
|
||||||
|
from aioupnp.commands import SCPDCommands
|
||||||
|
from aioupnp.device import Device, Service
|
||||||
|
from aioupnp.protocols.ssdp import m_search
|
||||||
|
from aioupnp.protocols.scpd import scpd_get
|
||||||
|
from aioupnp.protocols.soap import SCPDCommand
|
||||||
|
from aioupnp.util import flatten_keys
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_action_list(element_dict: dict) -> list: # [(<method>, [<input1>, ...], [<output1, ...]), ...]
|
||||||
|
service_info = flatten_keys(element_dict, "{%s}" % SERVICE)
|
||||||
|
if "actionList" in service_info:
|
||||||
|
action_list = service_info["actionList"]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if not len(action_list): # it could be an empty string
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
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'], [], []))
|
||||||
|
else:
|
||||||
|
arg_dicts = action['argumentList']['argument']
|
||||||
|
if not isinstance(arg_dicts, list): # when there is one arg
|
||||||
|
arg_dicts = [arg_dicts]
|
||||||
|
result.append((
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class Gateway:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
flattened = {
|
||||||
|
k.lower(): v for k, v in kwargs.items()
|
||||||
|
}
|
||||||
|
usn = flattened["usn"]
|
||||||
|
server = flattened["server"]
|
||||||
|
location = flattened["location"]
|
||||||
|
st = flattened["st"]
|
||||||
|
|
||||||
|
cache_control = flattened.get("cache_control") or flattened.get("cache-control") or ""
|
||||||
|
date = flattened.get("date", "")
|
||||||
|
ext = flattened.get("ext", "")
|
||||||
|
|
||||||
|
self.usn = usn.encode()
|
||||||
|
self.ext = ext.encode()
|
||||||
|
self.server = server.encode()
|
||||||
|
self.location = location.encode()
|
||||||
|
self.cache_control = cache_control.encode()
|
||||||
|
self.date = date.encode()
|
||||||
|
self.urn = st.encode()
|
||||||
|
|
||||||
|
self._xml_response = ""
|
||||||
|
self._service_descriptors = {}
|
||||||
|
self.base_address = BASE_ADDRESS_REGEX.findall(self.location)[0]
|
||||||
|
self.port = int(BASE_PORT_REGEX.findall(self.location)[0])
|
||||||
|
self.base_ip = self.base_address.lstrip(b"http://").split(b":")[0]
|
||||||
|
self.path = self.location.split(b"%s:%i/" % (self.base_ip, self.port))[1]
|
||||||
|
|
||||||
|
self.spec_version = None
|
||||||
|
self.url_base = None
|
||||||
|
|
||||||
|
self._device = None
|
||||||
|
self._devices = []
|
||||||
|
self._services = []
|
||||||
|
|
||||||
|
self._unsupported_actions = {}
|
||||||
|
self._registered_commands = {}
|
||||||
|
self.commands = SCPDCommands()
|
||||||
|
|
||||||
|
def gateway_descriptor(self) -> dict:
|
||||||
|
r = {
|
||||||
|
'server': self.server.decode(),
|
||||||
|
'urlBase': self.url_base,
|
||||||
|
'location': self.location.decode(),
|
||||||
|
"specVersion": self.spec_version,
|
||||||
|
'usn': self.usn.decode(),
|
||||||
|
'urn': self.urn.decode(),
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
|
||||||
|
@property
|
||||||
|
def services(self) -> dict:
|
||||||
|
if not self._device:
|
||||||
|
return {}
|
||||||
|
return {service.serviceType: service for service in self._services}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def devices(self) -> dict:
|
||||||
|
if not self._device:
|
||||||
|
return {}
|
||||||
|
return {device.udn: device for device in self._devices}
|
||||||
|
|
||||||
|
def get_service(self, service_type: str) -> Service:
|
||||||
|
for service in self._services:
|
||||||
|
if service.serviceType.lower() == service_type.lower():
|
||||||
|
return service
|
||||||
|
|
||||||
|
def debug_commands(self):
|
||||||
|
return {
|
||||||
|
'available': self._registered_commands,
|
||||||
|
'failed': self._unsupported_actions
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def discover_gateway(cls, lan_address: str, gateway_address: str, timeout: int = 1,
|
||||||
|
service: str = UPNP_ORG_IGD):
|
||||||
|
datagram = await m_search(lan_address, gateway_address, timeout, service)
|
||||||
|
gateway = cls(**datagram.as_dict())
|
||||||
|
await gateway.discover_commands()
|
||||||
|
return gateway
|
||||||
|
|
||||||
|
async def discover_commands(self):
|
||||||
|
response = await scpd_get("/" + self.path.decode(), self.base_ip.decode(), self.port)
|
||||||
|
|
||||||
|
self.spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION)
|
||||||
|
self.url_base = get_dict_val_case_insensitive(response, "urlbase")
|
||||||
|
if not self.url_base:
|
||||||
|
self.url_base = self.base_address.decode()
|
||||||
|
if response:
|
||||||
|
self._device = Device(
|
||||||
|
self._devices, self._services, **get_dict_val_case_insensitive(response, "device")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._device = Device(self._devices, self._services)
|
||||||
|
for service_type in self.services.keys():
|
||||||
|
await self.register_commands(self.services[service_type])
|
||||||
|
|
||||||
|
async def register_commands(self, service: Service):
|
||||||
|
service_dict = await scpd_get(("" if service.SCPDURL.startswith("/") else "/") + service.SCPDURL,
|
||||||
|
self.base_ip.decode(), self.port)
|
||||||
|
if not service_dict:
|
||||||
|
return
|
||||||
|
|
||||||
|
action_list = get_action_list(service_dict)
|
||||||
|
|
||||||
|
for name, inputs, outputs in action_list:
|
||||||
|
try:
|
||||||
|
current = getattr(self.commands, name)
|
||||||
|
annotations = current.__annotations__
|
||||||
|
return_types = annotations.get('return', None)
|
||||||
|
if return_types:
|
||||||
|
if not isinstance(return_types, tuple):
|
||||||
|
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 = SCPDCommand(
|
||||||
|
self.base_ip.decode(), self.port, service.controlURL, service.serviceType.encode(),
|
||||||
|
name, param_types, return_types, inputs, outputs)
|
||||||
|
setattr(command, "__doc__", current.__doc__)
|
||||||
|
setattr(self.commands, command.method, command)
|
||||||
|
|
||||||
|
self._registered_commands[command.method] = service.serviceType
|
||||||
|
log.debug("registered %s::%s", service.serviceType, command.method)
|
||||||
|
except AttributeError:
|
||||||
|
s = self._unsupported_actions.get(service.serviceType, [])
|
||||||
|
s.append(name)
|
||||||
|
self._unsupported_actions[service.serviceType] = s
|
||||||
|
log.debug("available command for %s does not have a wrapper implemented: %s %s %s",
|
||||||
|
service.serviceType, name, inputs, outputs)
|
0
aioupnp/protocols/__init__.py
Normal file
0
aioupnp/protocols/__init__.py
Normal file
46
aioupnp/protocols/multicast.py
Normal file
46
aioupnp/protocols/multicast.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import struct
|
||||||
|
import socket
|
||||||
|
from asyncio.protocols import DatagramProtocol
|
||||||
|
from asyncio.transports import DatagramTransport
|
||||||
|
|
||||||
|
|
||||||
|
class MulticastProtocol(DatagramProtocol):
|
||||||
|
@property
|
||||||
|
def socket(self) -> socket.socket:
|
||||||
|
return self.transport.get_extra_info('socket')
|
||||||
|
|
||||||
|
def get_ttl(self) -> int:
|
||||||
|
return self.socket.getsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL)
|
||||||
|
|
||||||
|
def set_ttl(self, ttl: int = 1) -> None:
|
||||||
|
self.socket.setsockopt(
|
||||||
|
socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, struct.pack('b', ttl)
|
||||||
|
)
|
||||||
|
|
||||||
|
def join_group(self, addr: str, interface: str) -> None:
|
||||||
|
addr = socket.inet_aton(addr)
|
||||||
|
interface = socket.inet_aton(interface)
|
||||||
|
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, addr + interface)
|
||||||
|
|
||||||
|
def leave_group(self, addr: str, interface: str) -> None:
|
||||||
|
addr = socket.inet_aton(addr)
|
||||||
|
interface = socket.inet_aton(interface)
|
||||||
|
self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, addr + interface)
|
||||||
|
|
||||||
|
def connection_made(self, transport: DatagramTransport) -> None:
|
||||||
|
self.transport = transport
|
||||||
|
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_socket(cls, bind_address: str, multicast_address: str):
|
||||||
|
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))
|
||||||
|
sock.setsockopt(
|
||||||
|
socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP,
|
||||||
|
socket.inet_aton(multicast_address) + socket.inet_aton(bind_address)
|
||||||
|
)
|
||||||
|
sock.setblocking(False)
|
||||||
|
return sock
|
||||||
|
|
||||||
|
|
88
aioupnp/protocols/scpd.py
Normal file
88
aioupnp/protocols/scpd.py
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import logging
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
import asyncio
|
||||||
|
from asyncio.protocols import Protocol
|
||||||
|
from aioupnp.fault import UPnPError
|
||||||
|
from aioupnp.serialization.scpd import deserialize_scpd_get_response
|
||||||
|
from aioupnp.serialization.scpd import serialize_scpd_get
|
||||||
|
from aioupnp.serialization.soap import serialize_soap_post, deserialize_soap_post_response
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SCPDHTTPClientProtocol(Protocol):
|
||||||
|
POST = 'POST'
|
||||||
|
GET = 'GET'
|
||||||
|
|
||||||
|
def __init__(self, method: str, message: bytes, finished: asyncio.Future, soap_method: str=None,
|
||||||
|
soap_service_id: str=None, close_after_send: bool = False):
|
||||||
|
self.method = method
|
||||||
|
assert soap_service_id is not None and soap_method is not None if method == 'POST' else True, \
|
||||||
|
'soap args not provided'
|
||||||
|
self.message = message
|
||||||
|
self.response_buff = b""
|
||||||
|
self.finished = finished
|
||||||
|
self.soap_method = soap_method
|
||||||
|
self.soap_service_id = soap_service_id
|
||||||
|
self.close_after_send = close_after_send
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
transport.write(self.message)
|
||||||
|
if self.close_after_send:
|
||||||
|
self.finished.set_result(None)
|
||||||
|
|
||||||
|
def data_received(self, data):
|
||||||
|
self.response_buff += data
|
||||||
|
if self.method == self.GET:
|
||||||
|
try:
|
||||||
|
packet = deserialize_scpd_get_response(self.response_buff)
|
||||||
|
if not packet:
|
||||||
|
return
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
pass
|
||||||
|
except UPnPError as err:
|
||||||
|
self.finished.set_exception(err)
|
||||||
|
else:
|
||||||
|
self.finished.set_result(packet)
|
||||||
|
elif self.method == self.POST:
|
||||||
|
try:
|
||||||
|
packet = deserialize_soap_post_response(self.response_buff, self.soap_method, self.soap_service_id)
|
||||||
|
if not packet:
|
||||||
|
return
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
pass
|
||||||
|
except UPnPError as err:
|
||||||
|
self.finished.set_exception(err)
|
||||||
|
else:
|
||||||
|
self.finished.set_result(packet)
|
||||||
|
|
||||||
|
|
||||||
|
async def scpd_get(control_url: str, address: str, port: int) -> dict:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
finished = asyncio.Future()
|
||||||
|
packet = serialize_scpd_get(control_url, address)
|
||||||
|
transport, protocol = await loop.create_connection(
|
||||||
|
lambda : SCPDHTTPClientProtocol('GET', packet, finished), address, port
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(finished, 1.0)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def scpd_post(control_url: str, address: str, port: int, method: str, param_names: list, service_id: bytes,
|
||||||
|
close_after_send: bool, **kwargs):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
finished = asyncio.Future()
|
||||||
|
packet = serialize_soap_post(method, param_names, service_id, address.encode(), control_url.encode(), **kwargs)
|
||||||
|
transport, protocol = await loop.create_connection(
|
||||||
|
lambda : SCPDHTTPClientProtocol(
|
||||||
|
'POST', packet, finished, soap_method=method, soap_service_id=service_id.decode(),
|
||||||
|
close_after_send=close_after_send
|
||||||
|
), address, port
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(finished, 1.0)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
32
aioupnp/protocols/soap.py
Normal file
32
aioupnp/protocols/soap.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import logging
|
||||||
|
from aioupnp.protocols.scpd import scpd_post
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SCPDCommand:
|
||||||
|
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):
|
||||||
|
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
|
||||||
|
|
||||||
|
async def __call__(self, **kwargs):
|
||||||
|
if set(kwargs.keys()) != set(self.param_types.keys()):
|
||||||
|
raise Exception("argument mismatch: %s vs %s" % (kwargs.keys(), self.param_types.keys()))
|
||||||
|
close_after_send = not self.return_types or self.return_types == [None]
|
||||||
|
response = await scpd_post(
|
||||||
|
self.control_url, self.gateway_address, self.service_port, self.method, self.param_order, self.service_id,
|
||||||
|
close_after_send, **{n: self.param_types[n](kwargs[n]) for n in self.param_types.keys()}
|
||||||
|
)
|
||||||
|
extracted_response = tuple([None if self.return_types[n] is None else self.return_types[n](response[n])
|
||||||
|
for n in self.return_order]) or (None, )
|
||||||
|
if len(extracted_response) == 1:
|
||||||
|
return extracted_response[0]
|
||||||
|
return extracted_response
|
104
aioupnp/protocols/ssdp.py
Normal file
104
aioupnp/protocols/ssdp.py
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
import re
|
||||||
|
import binascii
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import DefaultDict
|
||||||
|
from asyncio.coroutines import coroutine
|
||||||
|
from asyncio.futures import Future
|
||||||
|
from asyncio.transports import DatagramTransport
|
||||||
|
from aioupnp.fault import UPnPError
|
||||||
|
from aioupnp.serialization.ssdp import SSDPDatagram
|
||||||
|
from aioupnp.constants import UPNP_ORG_IGD, SSDP_IP_ADDRESS, SSDP_PORT, SSDP_DISCOVER, SSDP_ROOT_DEVICE
|
||||||
|
from aioupnp.protocols.multicast import MulticastProtocol
|
||||||
|
|
||||||
|
ADDRESS_REGEX = re.compile("^http:\/\/(\d+\.\d+\.\d+\.\d+)\:(\d*)(\/[\w|\/|\:|\-|\.]*)$")
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SSDPProtocol(MulticastProtocol):
|
||||||
|
def __init__(self, lan_address):
|
||||||
|
super().__init__()
|
||||||
|
self.lan_address = lan_address
|
||||||
|
self.discover_callbacks: DefaultDict[coroutine] = {}
|
||||||
|
self.transport: DatagramTransport
|
||||||
|
self.notifications = []
|
||||||
|
self.replies = []
|
||||||
|
|
||||||
|
def connection_made(self, transport: DatagramTransport):
|
||||||
|
super().connection_made(transport)
|
||||||
|
self.set_ttl(1)
|
||||||
|
|
||||||
|
async def m_search(self, address, timeout: int = 1, service=UPNP_ORG_IGD) -> SSDPDatagram:
|
||||||
|
if (address, service) in self.discover_callbacks:
|
||||||
|
return self.discover_callbacks[(address, service)]
|
||||||
|
packet = SSDPDatagram(
|
||||||
|
SSDPDatagram._M_SEARCH, host="{}:{}".format(SSDP_IP_ADDRESS, SSDP_PORT), st=service, man=SSDP_DISCOVER,
|
||||||
|
mx=1
|
||||||
|
)
|
||||||
|
self.transport.sendto(packet.encode().encode(), (address, SSDP_PORT))
|
||||||
|
f = Future()
|
||||||
|
self.discover_callbacks[(address, service)] = f
|
||||||
|
return await asyncio.wait_for(f, timeout)
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr) -> None:
|
||||||
|
if addr[0] == self.lan_address:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
packet = SSDPDatagram.decode(data)
|
||||||
|
log.debug("decoded %s from %s:%i:\n%s", packet.get_friendly_name(), addr[0], addr[1], packet.encode())
|
||||||
|
except UPnPError as err:
|
||||||
|
log.error("failed to decode SSDP packet from %s:%i: %s\npacket: %s", addr[0], addr[1], err,
|
||||||
|
binascii.hexlify(data))
|
||||||
|
return
|
||||||
|
|
||||||
|
if packet._packet_type == packet._OK:
|
||||||
|
log.debug("%s:%i sent us an OK", addr[0], addr[1])
|
||||||
|
if (addr[0], packet.st) in self.discover_callbacks:
|
||||||
|
if packet.st not in map(lambda p: p['st'], self.replies):
|
||||||
|
self.replies.append(packet)
|
||||||
|
f: Future = self.discover_callbacks.pop((addr[0], packet.st))
|
||||||
|
f.set_result(packet)
|
||||||
|
|
||||||
|
elif packet._packet_type == packet._NOTIFY:
|
||||||
|
if packet.nt == SSDP_ROOT_DEVICE:
|
||||||
|
address, port, path = ADDRESS_REGEX.findall(packet.location)[0]
|
||||||
|
key = None
|
||||||
|
for (addr, service) in self.discover_callbacks:
|
||||||
|
if addr == address:
|
||||||
|
key = (addr, service)
|
||||||
|
break
|
||||||
|
if key:
|
||||||
|
log.debug("got a notification with the requested m-search info")
|
||||||
|
f: Future = self.discover_callbacks.pop(key)
|
||||||
|
f.set_result(SSDPDatagram(
|
||||||
|
SSDPDatagram._OK, cache_control='', location=packet.location, server=packet.server,
|
||||||
|
st=UPNP_ORG_IGD, usn=packet.usn
|
||||||
|
))
|
||||||
|
self.notifications.append(packet.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
async def listen_ssdp(lan_address: str, gateway_address: str) -> (DatagramTransport, SSDPProtocol, str, str):
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
sock = SSDPProtocol.create_socket(lan_address, SSDP_IP_ADDRESS)
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: SSDPProtocol(lan_address), sock=sock
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to create multicast socket %s:%i", lan_address, SSDP_PORT)
|
||||||
|
raise
|
||||||
|
return transport, protocol, gateway_address, lan_address
|
||||||
|
|
||||||
|
|
||||||
|
async def m_search(lan_address: str, gateway_address: str, timeout: int = 1,
|
||||||
|
service: str = UPNP_ORG_IGD) -> SSDPDatagram:
|
||||||
|
transport, protocol, gateway_address, lan_address = await listen_ssdp(
|
||||||
|
lan_address, gateway_address
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await protocol.m_search(gateway_address, timeout=timeout, service=service)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise UPnPError("M-SEARCH for {}:{} timed out".format(gateway_address, SSDP_PORT))
|
||||||
|
finally:
|
||||||
|
transport.close()
|
0
aioupnp/serialization/__init__.py
Normal file
0
aioupnp/serialization/__init__.py
Normal file
49
aioupnp/serialization/scpd.py
Normal file
49
aioupnp/serialization/scpd.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import re
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
from aioupnp.constants import XML_VERSION, DEVICE, ROOT
|
||||||
|
from aioupnp.util import etree_to_dict, flatten_keys
|
||||||
|
|
||||||
|
|
||||||
|
CONTENT_PATTERN = re.compile(
|
||||||
|
"(\<\?xml version=\"1\.0\"\?\>(\s*.)*|\>)".encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
XML_ROOT_SANITY_PATTERN = re.compile(
|
||||||
|
"(?i)(\{|(urn:schemas-[\w|\d]*-(com|org|net))[:|-](device|service)[:|-]([\w|\d|\:|\-|\_]*)|\}([\w|\d|\:|\-|\_]*))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_scpd_get(path: str, address: str) -> bytes:
|
||||||
|
if "http://" in address:
|
||||||
|
host = address.split("http://")[1]
|
||||||
|
else:
|
||||||
|
host = address
|
||||||
|
if ":" in host:
|
||||||
|
host = host.split(":")[0]
|
||||||
|
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)
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize_scpd_get_response(content: bytes) -> dict:
|
||||||
|
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()))
|
||||||
|
schema_key = DEVICE
|
||||||
|
root = ROOT
|
||||||
|
for k in xml_dict.keys():
|
||||||
|
m = 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]
|
||||||
|
break
|
||||||
|
return flatten_keys(xml_dict, "{%s}" % schema_key)[root]
|
63
aioupnp/serialization/soap.py
Normal file
63
aioupnp/serialization/soap.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
)
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize_soap_post_response(response: bytes, method: str, service_id: str) -> dict:
|
||||||
|
parsed = 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()))
|
||||||
|
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
|
||||||
|
response_key = None
|
||||||
|
|
||||||
|
for key in body:
|
||||||
|
if method in key:
|
||||||
|
response_key = key
|
||||||
|
break
|
||||||
|
if not response_key:
|
||||||
|
raise UPnPError("unknown response fields for %s")
|
||||||
|
return body[response_key]
|
|
@ -1,12 +1,12 @@
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
import binascii
|
import binascii
|
||||||
from txupnp.fault import UPnPError
|
from aioupnp.fault import UPnPError
|
||||||
from txupnp.constants import line_separator
|
from aioupnp.constants import line_separator
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ssdp_datagram_patterns = {
|
ssdp_datagram_patterns = {
|
||||||
'host': (re.compile("^(?i)(host):(.*)$"), str),
|
'host': (re.compile("^(?i)(host):(.*)$"), str),
|
||||||
'st': (re.compile("^(?i)(st):(.*)$"), str),
|
'st': (re.compile("^(?i)(st):(.*)$"), str),
|
||||||
'man': (re.compile("^(?i)(man):|(\"(.*)\")$"), str),
|
'man': (re.compile("^(?i)(man):|(\"(.*)\")$"), str),
|
||||||
|
@ -19,7 +19,7 @@ _ssdp_datagram_patterns = {
|
||||||
'server': (re.compile("^(?i)(server):(.*)$"), str),
|
'server': (re.compile("^(?i)(server):(.*)$"), str),
|
||||||
}
|
}
|
||||||
|
|
||||||
_vendor_pattern = re.compile("^([\w|\d]*)\.([\w|\d]*\.com):([ \"|\w|\d\:]*)$")
|
vendor_pattern = re.compile("^([\w|\d]*)\.([\w|\d]*\.com):([ \"|\w|\d\:]*)$")
|
||||||
|
|
||||||
|
|
||||||
class SSDPDatagram(object):
|
class SSDPDatagram(object):
|
||||||
|
@ -39,9 +39,9 @@ class SSDPDatagram(object):
|
||||||
_OK: "m-search response"
|
_OK: "m-search response"
|
||||||
}
|
}
|
||||||
|
|
||||||
_vendor_field_pattern = _vendor_pattern
|
_vendor_field_pattern = vendor_pattern
|
||||||
|
|
||||||
_patterns = _ssdp_datagram_patterns
|
_patterns = ssdp_datagram_patterns
|
||||||
|
|
||||||
_required_fields = {
|
_required_fields = {
|
||||||
_M_SEARCH: [
|
_M_SEARCH: [
|
||||||
|
@ -95,6 +95,9 @@ class SSDPDatagram(object):
|
||||||
if not k.startswith("_") and hasattr(self, k.lower()) and getattr(self, k.lower()) is None:
|
if not k.startswith("_") and hasattr(self, k.lower()) and getattr(self, k.lower()) is None:
|
||||||
setattr(self, k.lower(), v)
|
setattr(self, k.lower(), v)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return ("SSDPDatagram(packet_type=%s, " % self._packet_type) + ", ".join("%s=%s" % (n, v) for n, v in self.as_dict().items()) + ")"
|
||||||
|
|
||||||
def __getitem__(self, item):
|
def __getitem__(self, item):
|
||||||
for i in self._required_fields[self._packet_type]:
|
for i in self._required_fields[self._packet_type]:
|
||||||
if i.lower() == item.lower():
|
if i.lower() == item.lower():
|
||||||
|
@ -166,7 +169,7 @@ class SSDPDatagram(object):
|
||||||
lines = [l for l in datagram.split(line_separator) if l]
|
lines = [l for l in datagram.split(line_separator) if l]
|
||||||
if lines[0] == cls._start_lines[cls._M_SEARCH]:
|
if lines[0] == cls._start_lines[cls._M_SEARCH]:
|
||||||
return cls._from_request(lines[1:])
|
return cls._from_request(lines[1:])
|
||||||
if lines[0] == cls._start_lines[cls._NOTIFY]:
|
if lines[0] in [cls._start_lines[cls._NOTIFY], cls._start_lines[cls._NOTIFY] + " "]:
|
||||||
return cls._from_notify(lines[1:])
|
return cls._from_notify(lines[1:])
|
||||||
if lines[0] == cls._start_lines[cls._OK]:
|
if lines[0] == cls._start_lines[cls._OK]:
|
||||||
return cls._from_response(lines[1:])
|
return cls._from_response(lines[1:])
|
258
aioupnp/upnp.py
Normal file
258
aioupnp/upnp.py
Normal file
|
@ -0,0 +1,258 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
from aioupnp.fault import UPnPError
|
||||||
|
from aioupnp.gateway import Gateway
|
||||||
|
from aioupnp.constants import UPNP_ORG_IGD
|
||||||
|
from aioupnp.util import get_gateway_and_lan_addresses
|
||||||
|
from aioupnp.protocols.ssdp import m_search
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cli(format_result=None):
|
||||||
|
def _cli(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
async def _inner(*args, **kwargs):
|
||||||
|
result = await fn(*args, **kwargs)
|
||||||
|
if not format_result or not result or not isinstance(result, (list, dict, tuple)):
|
||||||
|
return result
|
||||||
|
self = args[0]
|
||||||
|
return {k: v for k, v in zip(getattr(self.gateway.commands, format_result).return_order, result)}
|
||||||
|
f = _inner
|
||||||
|
f._cli = True
|
||||||
|
return f
|
||||||
|
return _cli
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(x):
|
||||||
|
if isinstance(x, bytes):
|
||||||
|
return x.decode()
|
||||||
|
elif isinstance(x, Exception):
|
||||||
|
return str(x)
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
class UPnP:
|
||||||
|
def __init__(self, lan_address: str, gateway_address: str, gateway: Gateway):
|
||||||
|
self.lan_address = lan_address
|
||||||
|
self.gateway_address = gateway_address
|
||||||
|
self.gateway = gateway
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_lan_and_gateway(cls, lan_address: str = '', gateway_address: str = '',
|
||||||
|
interface_name: str = 'default') -> (str, str):
|
||||||
|
if not lan_address or not gateway_address:
|
||||||
|
gateway_addr, lan_addr = get_gateway_and_lan_addresses(interface_name)
|
||||||
|
lan_address = lan_address or lan_addr
|
||||||
|
gateway_address = gateway_address or gateway_addr
|
||||||
|
return lan_address, gateway_address
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def discover(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1,
|
||||||
|
service: str = UPNP_ORG_IGD, interface_name: str = 'default'):
|
||||||
|
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))
|
||||||
|
gateway = await Gateway.discover_gateway(lan_address, gateway_address, timeout, service)
|
||||||
|
return cls(lan_address, gateway_address, gateway)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@cli()
|
||||||
|
async def m_search(cls, lan_address: str = '', gateway_address: str = '', timeout: int = 1,
|
||||||
|
service: str = UPNP_ORG_IGD, interface_name: str = 'default') -> dict:
|
||||||
|
lan_address, gateway_address = cls.get_lan_and_gateway(lan_address, gateway_address, interface_name)
|
||||||
|
datagram = await m_search(lan_address, gateway_address, timeout, service)
|
||||||
|
return {
|
||||||
|
'lan_address': lan_address,
|
||||||
|
'gateway_address': gateway_address,
|
||||||
|
'discover_reply': datagram.as_dict()
|
||||||
|
}
|
||||||
|
|
||||||
|
@cli()
|
||||||
|
async def get_external_ip(self) -> str:
|
||||||
|
return await self.gateway.commands.GetExternalIPAddress()
|
||||||
|
|
||||||
|
@cli("AddPortMapping")
|
||||||
|
async def add_port_mapping(self, external_port: int, protocol: str, internal_port, lan_address: str,
|
||||||
|
description: str) -> None:
|
||||||
|
return await self.gateway.commands.AddPortMapping(
|
||||||
|
NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol,
|
||||||
|
NewInternalPort=internal_port, NewInternalClient=lan_address,
|
||||||
|
NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration=""
|
||||||
|
)
|
||||||
|
|
||||||
|
@cli("GetGenericPortMappingEntry")
|
||||||
|
async def get_port_mapping_by_index(self, index: int) -> dict:
|
||||||
|
return await self._get_port_mapping_by_index(index)
|
||||||
|
|
||||||
|
async def _get_port_mapping_by_index(self, index: int) -> (str, int, str, int, str, bool, str, int):
|
||||||
|
try:
|
||||||
|
redirect = await self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index)
|
||||||
|
return redirect
|
||||||
|
except UPnPError:
|
||||||
|
return
|
||||||
|
|
||||||
|
@cli()
|
||||||
|
async def get_redirects(self) -> list:
|
||||||
|
redirects = []
|
||||||
|
cnt = 0
|
||||||
|
redirect = await self.get_port_mapping_by_index(cnt)
|
||||||
|
while redirect:
|
||||||
|
redirects.append(redirect)
|
||||||
|
cnt += 1
|
||||||
|
redirect = await self.get_port_mapping_by_index(cnt)
|
||||||
|
return redirects
|
||||||
|
|
||||||
|
@cli("GetSpecificPortMappingEntry")
|
||||||
|
async def get_specific_port_mapping(self, external_port: int, protocol: str):
|
||||||
|
"""
|
||||||
|
:param external_port: (int) external port to listen on
|
||||||
|
:param protocol: (str) 'UDP' | 'TCP'
|
||||||
|
:return: (int) <internal port>, (str) <lan ip>, (bool) <enabled>, (str) <description>, (int) <lease time>
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self.gateway.commands.GetSpecificPortMappingEntry(
|
||||||
|
NewRemoteHost=None, NewExternalPort=external_port, NewProtocol=protocol
|
||||||
|
)
|
||||||
|
except UPnPError:
|
||||||
|
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(
|
||||||
|
NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol
|
||||||
|
)
|
||||||
|
|
||||||
|
@cli("AddPortMapping")
|
||||||
|
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 = internal_port or port
|
||||||
|
redirect_tups = []
|
||||||
|
cnt = 0
|
||||||
|
port = int(port)
|
||||||
|
internal_port = int(internal_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)
|
||||||
|
|
||||||
|
redirects = {
|
||||||
|
"%i:%s" % (ext_port, proto): (int_host, int_port, desc)
|
||||||
|
for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirect_tups
|
||||||
|
}
|
||||||
|
while ("%i:%s" % (port, protocol)) in redirects:
|
||||||
|
int_host, int_port, _ = redirects["%i:%s" % (port, protocol)]
|
||||||
|
if int_host == self.lan_address and int_port == internal_port:
|
||||||
|
break
|
||||||
|
port += 1
|
||||||
|
|
||||||
|
await self.add_port_mapping( # set one up
|
||||||
|
port, protocol, internal_port, self.lan_address, description
|
||||||
|
)
|
||||||
|
return port
|
||||||
|
|
||||||
|
@cli()
|
||||||
|
async def get_soap_commands(self) -> dict:
|
||||||
|
return {
|
||||||
|
'supported': list(self.gateway._registered_commands.keys()),
|
||||||
|
'unsupported': self.gateway._unsupported_actions
|
||||||
|
}
|
||||||
|
|
||||||
|
@cli()
|
||||||
|
async def generate_test_data(self):
|
||||||
|
external_ip = await self.get_external_ip()
|
||||||
|
redirects = await self.get_redirects()
|
||||||
|
ext_port = await self.get_next_mapping(4567, "UDP", "aioupnp test mapping")
|
||||||
|
delete = await self.delete_port_mapping(ext_port, "UDP")
|
||||||
|
after_delete = await self.get_specific_port_mapping(ext_port, "UDP")
|
||||||
|
|
||||||
|
commands_test_case = (
|
||||||
|
("get_external_ip", (), "1.2.3.4"),
|
||||||
|
("get_redirects", (), redirects),
|
||||||
|
("get_next_mapping", (4567, "UDP", "aioupnp test mapping"), ext_port),
|
||||||
|
("delete_port_mapping", (ext_port, "UDP"), delete),
|
||||||
|
("get_specific_port_mapping", (ext_port, "UDP"), after_delete),
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway = self.gateway
|
||||||
|
device = list(gateway.devices.values())[0]
|
||||||
|
assert device.manufacturer and device.modelName
|
||||||
|
device_path = os.path.join(os.getcwd(), "%s %s" % (device.manufacturer, device.modelName))
|
||||||
|
commands = gateway.debug_commands()
|
||||||
|
with open(device_path, "w") as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"router_address": self.gateway_address,
|
||||||
|
"client_address": self.lan_address,
|
||||||
|
"port": gateway.port,
|
||||||
|
"gateway_dict": gateway.gateway_descriptor(),
|
||||||
|
'expected_devices': [
|
||||||
|
{
|
||||||
|
'cache_control': 'max-age=1800',
|
||||||
|
'location': gateway.location,
|
||||||
|
'server': gateway.server,
|
||||||
|
'st': gateway.urn,
|
||||||
|
'usn': gateway.usn
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'commands': commands,
|
||||||
|
# 'ssdp': u.sspd_factory.get_ssdp_packet_replay(),
|
||||||
|
# 'scpd': gateway.requester.dump_packets(),
|
||||||
|
'soap': commands_test_case
|
||||||
|
}, default=_encode, indent=2).replace(external_ip, "1.2.3.4"))
|
||||||
|
return "Generated test data! -> %s" % device_path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def run_cli(cls, method, lan_address: str = '', gateway_address: str = '', timeout: int = 60,
|
||||||
|
service: str = UPNP_ORG_IGD, interface_name: str = 'default',
|
||||||
|
kwargs: dict = None):
|
||||||
|
kwargs = kwargs or {}
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
fut = asyncio.Future()
|
||||||
|
|
||||||
|
async def wrapper():
|
||||||
|
if method == 'm_search':
|
||||||
|
fn = lambda *_a, **_kw: cls.m_search(lan_address, gateway_address, timeout, service, interface_name)
|
||||||
|
else:
|
||||||
|
u = await cls.discover(
|
||||||
|
lan_address, gateway_address, timeout, service, interface_name
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
result = await fn(**kwargs)
|
||||||
|
fut.set_result(result)
|
||||||
|
except UPnPError as err:
|
||||||
|
fut.set_exception(err)
|
||||||
|
except Exception as err:
|
||||||
|
log.exception("uncaught error")
|
||||||
|
fut.set_exception(UPnPError("uncaught error: %s" % str(err)))
|
||||||
|
|
||||||
|
asyncio.run(wrapper())
|
||||||
|
try:
|
||||||
|
result = fut.result()
|
||||||
|
except UPnPError as err:
|
||||||
|
print("error: %s" % str(err))
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(result, (list, tuple, dict)):
|
||||||
|
print(json.dumps(result, indent=2, default=_encode))
|
||||||
|
else:
|
||||||
|
print(result)
|
|
@ -1,7 +1,9 @@
|
||||||
import re
|
import re
|
||||||
import functools
|
import socket
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
import netifaces
|
||||||
|
|
||||||
|
|
||||||
BASE_ADDRESS_REGEX = re.compile("^(http:\/\/\d*\.\d*\.\d*\.\d*:\d*)\/.*$".encode())
|
BASE_ADDRESS_REGEX = re.compile("^(http:\/\/\d*\.\d*\.\d*\.\d*:\d*)\/.*$".encode())
|
||||||
BASE_PORT_REGEX = re.compile("^http:\/\/\d*\.\d*\.\d*\.\d*:(\d*)\/.*$".encode())
|
BASE_PORT_REGEX = re.compile("^http:\/\/\d*\.\d*\.\d*\.\d*:(\d*)\/.*$".encode())
|
||||||
|
@ -50,35 +52,37 @@ def get_dict_val_case_insensitive(d, k):
|
||||||
raise KeyError("overlapping keys")
|
raise KeyError("overlapping keys")
|
||||||
return d[match[0]]
|
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 verify_return_types(*types):
|
|
||||||
"""
|
|
||||||
Attempt to recast results to expected result types
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _verify_return_types(fn):
|
def get_interfaces():
|
||||||
@functools.wraps(fn)
|
r = {
|
||||||
def _inner(*result):
|
interface_name: (router_address, netifaces.ifaddresses(interface_name)[netifaces.AF_INET][0]['addr'])
|
||||||
r = fn(*tuple(t(r) for t, r in zip(types, result)))
|
for router_address, interface_name, _ in netifaces.gateways()[socket.AF_INET]
|
||||||
if isinstance(r, tuple) and len(r) == 1:
|
}
|
||||||
return r[0]
|
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
|
return r
|
||||||
return _inner
|
|
||||||
return _verify_return_types
|
|
||||||
|
|
||||||
|
|
||||||
def return_types(*types):
|
def get_gateway_and_lan_addresses(interface_name: str) -> (str, str):
|
||||||
"""
|
for iface_name, (gateway, lan) in get_interfaces().items():
|
||||||
Decorator to set the expected return types of a SOAP function call
|
if interface_name == iface_name:
|
||||||
"""
|
return gateway, lan
|
||||||
|
return None, None
|
||||||
def return_types_wrapper(fn):
|
|
||||||
fn._return_types = types
|
|
||||||
return fn
|
|
||||||
|
|
||||||
return return_types_wrapper
|
|
||||||
|
|
||||||
|
|
||||||
none_or_str = lambda x: None if not x or x == 'None' else str(x)
|
|
||||||
|
|
||||||
none = lambda _: None
|
|
13
setup.py
13
setup.py
|
@ -1,12 +1,12 @@
|
||||||
import os
|
import os
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
from txupnp import __version__, __name__, __email__, __author__, __license__
|
from aioupnp import __version__, __name__, __email__, __author__, __license__
|
||||||
|
|
||||||
console_scripts = [
|
console_scripts = [
|
||||||
'txupnp-cli = txupnp.cli:main',
|
'aioupnp = aioupnp.__main__:main',
|
||||||
]
|
]
|
||||||
|
|
||||||
package_name = "txupnp"
|
package_name = "aioupnp"
|
||||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
base_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
with open(os.path.join(base_dir, 'README.md'), 'rb') as f:
|
with open(os.path.join(base_dir, 'README.md'), 'rb') as f:
|
||||||
long_description = f.read().decode('utf-8')
|
long_description = f.read().decode('utf-8')
|
||||||
|
@ -16,15 +16,14 @@ setup(
|
||||||
version=__version__,
|
version=__version__,
|
||||||
author=__author__,
|
author=__author__,
|
||||||
author_email=__email__,
|
author_email=__email__,
|
||||||
description="UPnP for twisted",
|
description="UPnP for asyncio",
|
||||||
keywords="upnp twisted",
|
keywords="upnp asyncio",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
url="https://github.com/lbryio/txupnp",
|
url="https://github.com/lbryio/aioupnp",
|
||||||
license=__license__,
|
license=__license__,
|
||||||
packages=find_packages(exclude=['tests']),
|
packages=find_packages(exclude=['tests']),
|
||||||
entry_points={'console_scripts': console_scripts},
|
entry_points={'console_scripts': console_scripts},
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'twisted[tls]',
|
|
||||||
'netifaces',
|
'netifaces',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
log = logging.getLogger("txupnp")
|
log = logging.getLogger("aioupnp")
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s'))
|
handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s'))
|
||||||
log.addHandler(handler)
|
log.addHandler(handler)
|
||||||
|
|
260
tests/devices/D-Link DIR-890L
Normal file
260
tests/devices/D-Link DIR-890L
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,117 +1,117 @@
|
||||||
from twisted.internet import reactor, defer
|
# from twisted.internet import reactor, defer
|
||||||
from twisted.trial import unittest
|
# from twisted.trial import unittest
|
||||||
from txupnp.constants import SSDP_PORT, SSDP_IP_ADDRESS
|
# from aioupnp.constants import SSDP_PORT, SSDP_IP_ADDRESS
|
||||||
from txupnp.upnp import UPnP
|
# from aioupnp.upnp import UPnP
|
||||||
from txupnp.mocks import MockReactor, MockSSDPServiceGatewayProtocol, get_device_test_case
|
# from aioupnp.mocks import MockReactor, MockSSDPServiceGatewayProtocol, get_device_test_case
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestDevice(unittest.TestCase):
|
# class TestDevice(unittest.TestCase):
|
||||||
manufacturer, model = "Cisco", "CGA4131COM"
|
# manufacturer, model = "Cisco", "CGA4131COM"
|
||||||
|
#
|
||||||
device = get_device_test_case(manufacturer, model)
|
# device = get_device_test_case(manufacturer, model)
|
||||||
router_address = device.device_dict['router_address']
|
# router_address = device.device_dict['router_address']
|
||||||
client_address = device.device_dict['client_address']
|
# client_address = device.device_dict['client_address']
|
||||||
expected_devices = device.device_dict['expected_devices']
|
# expected_devices = device.device_dict['expected_devices']
|
||||||
packets_rx = device.device_dict['ssdp']['received']
|
# packets_rx = device.device_dict['ssdp']['received']
|
||||||
packets_tx = device.device_dict['ssdp']['sent']
|
# packets_tx = device.device_dict['ssdp']['sent']
|
||||||
expected_available_commands = device.device_dict['commands']['available']
|
# expected_available_commands = device.device_dict['commands']['available']
|
||||||
scdp_packets = device.device_dict['scpd']
|
# scdp_packets = device.device_dict['scpd']
|
||||||
|
#
|
||||||
def setUp(self):
|
# def setUp(self):
|
||||||
fake_reactor = MockReactor(self.client_address, self.scdp_packets)
|
# fake_reactor = MockReactor(self.client_address, self.scdp_packets)
|
||||||
reactor.listenMulticast = fake_reactor.listenMulticast
|
# reactor.listenMulticast = fake_reactor.listenMulticast
|
||||||
self.reactor = reactor
|
# self.reactor = reactor
|
||||||
server_protocol = MockSSDPServiceGatewayProtocol(
|
# server_protocol = MockSSDPServiceGatewayProtocol(
|
||||||
self.client_address, self.router_address, self.packets_rx, self.packets_tx
|
# self.client_address, self.router_address, self.packets_rx, self.packets_tx
|
||||||
)
|
# )
|
||||||
self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address)
|
# self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address)
|
||||||
self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address)
|
# self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address)
|
||||||
|
#
|
||||||
self.upnp = UPnP(
|
# self.upnp = UPnP(
|
||||||
self.reactor, debug_ssdp=True, router_ip=self.router_address,
|
# self.reactor, debug_ssdp=True, router_ip=self.router_address,
|
||||||
lan_ip=self.client_address, iface_name='mock'
|
# lan_ip=self.client_address, iface_name='mock'
|
||||||
)
|
# )
|
||||||
|
#
|
||||||
def tearDown(self):
|
# def tearDown(self):
|
||||||
self.upnp.sspd_factory.disconnect()
|
# self.upnp.sspd_factory.disconnect()
|
||||||
self.server_port.stopListening()
|
# self.server_port.stopListening()
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestSSDP(TestDevice):
|
# class TestSSDP(TestDevice):
|
||||||
@defer.inlineCallbacks
|
# @defer.inlineCallbacks
|
||||||
def test_discover_device(self):
|
# def test_discover_device(self):
|
||||||
result = yield self.upnp.m_search(self.router_address, timeout=1)
|
# result = yield self.upnp.m_search(self.router_address, timeout=1)
|
||||||
self.assertEqual(len(self.expected_devices), len(result))
|
# self.assertEqual(len(self.expected_devices), len(result))
|
||||||
self.assertEqual(len(result), 1)
|
# self.assertEqual(len(result), 1)
|
||||||
self.assertDictEqual(self.expected_devices[0], result[0])
|
# self.assertDictEqual(self.expected_devices[0], result[0])
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestSCPD(TestDevice):
|
# class TestSCPD(TestDevice):
|
||||||
@defer.inlineCallbacks
|
# @defer.inlineCallbacks
|
||||||
def setUp(self):
|
# def setUp(self):
|
||||||
fake_reactor = MockReactor(self.client_address, self.scdp_packets)
|
# fake_reactor = MockReactor(self.client_address, self.scdp_packets)
|
||||||
reactor.listenMulticast = fake_reactor.listenMulticast
|
# reactor.listenMulticast = fake_reactor.listenMulticast
|
||||||
reactor.connectTCP = fake_reactor.connectTCP
|
# reactor.connectTCP = fake_reactor.connectTCP
|
||||||
self.reactor = reactor
|
# self.reactor = reactor
|
||||||
server_protocol = MockSSDPServiceGatewayProtocol(
|
# server_protocol = MockSSDPServiceGatewayProtocol(
|
||||||
self.client_address, self.router_address, self.packets_rx, self.packets_tx
|
# self.client_address, self.router_address, self.packets_rx, self.packets_tx
|
||||||
)
|
# )
|
||||||
self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address)
|
# self.server_port = self.reactor.listenMulticast(SSDP_PORT, server_protocol, interface=self.router_address)
|
||||||
self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address)
|
# self.server_port.transport.joinGroup(SSDP_IP_ADDRESS, interface=self.router_address)
|
||||||
|
#
|
||||||
self.upnp = UPnP(
|
# self.upnp = UPnP(
|
||||||
self.reactor, debug_ssdp=True, router_ip=self.router_address,
|
# self.reactor, debug_ssdp=True, router_ip=self.router_address,
|
||||||
lan_ip=self.client_address, iface_name='mock'
|
# lan_ip=self.client_address, iface_name='mock'
|
||||||
)
|
# )
|
||||||
yield self.upnp.discover()
|
# yield self.upnp.discover()
|
||||||
|
#
|
||||||
def test_parse_available_commands(self):
|
# def test_parse_available_commands(self):
|
||||||
self.assertDictEqual(self.expected_available_commands, self.upnp.gateway.debug_commands()['available'])
|
# self.assertDictEqual(self.expected_available_commands, self.upnp.gateway.debug_commands()['available'])
|
||||||
|
#
|
||||||
def test_parse_gateway(self):
|
# def test_parse_gateway(self):
|
||||||
self.assertDictEqual(self.device.device_dict['gateway_dict'], self.upnp.gateway.as_dict())
|
# self.assertDictEqual(self.device.device_dict['gateway_dict'], self.upnp.gateway.as_dict())
|
||||||
|
#
|
||||||
@defer.inlineCallbacks
|
# @defer.inlineCallbacks
|
||||||
def test_commands(self):
|
# def test_commands(self):
|
||||||
method, args, expected = self.device.device_dict['soap'][0]
|
# method, args, expected = self.device.device_dict['soap'][0]
|
||||||
command1 = getattr(self.upnp, method)
|
# command1 = getattr(self.upnp, method)
|
||||||
result = yield command1(*tuple(args))
|
# result = yield command1(*tuple(args))
|
||||||
self.assertEqual(result, expected)
|
# self.assertEqual(result, expected)
|
||||||
|
#
|
||||||
method, args, expected = self.device.device_dict['soap'][1]
|
# method, args, expected = self.device.device_dict['soap'][1]
|
||||||
command2 = getattr(self.upnp, method)
|
# command2 = getattr(self.upnp, method)
|
||||||
result = yield command2(*tuple(args))
|
# result = yield command2(*tuple(args))
|
||||||
result = [[i for i in r] for r in result]
|
# result = [[i for i in r] for r in result]
|
||||||
self.assertListEqual(result, expected)
|
# self.assertListEqual(result, expected)
|
||||||
|
#
|
||||||
method, args, expected = self.device.device_dict['soap'][2]
|
# method, args, expected = self.device.device_dict['soap'][2]
|
||||||
command3 = getattr(self.upnp, method)
|
# command3 = getattr(self.upnp, method)
|
||||||
result = yield command3(*tuple(args))
|
# result = yield command3(*tuple(args))
|
||||||
self.assertEqual(result, expected)
|
# self.assertEqual(result, expected)
|
||||||
|
#
|
||||||
method, args, expected = self.device.device_dict['soap'][3]
|
# method, args, expected = self.device.device_dict['soap'][3]
|
||||||
command4 = getattr(self.upnp, method)
|
# command4 = getattr(self.upnp, method)
|
||||||
result = yield command4(*tuple(args))
|
# result = yield command4(*tuple(args))
|
||||||
result = [r for r in result]
|
# result = [r for r in result]
|
||||||
self.assertEqual(result, expected)
|
# self.assertEqual(result, expected)
|
||||||
|
#
|
||||||
method, args, expected = self.device.device_dict['soap'][4]
|
# method, args, expected = self.device.device_dict['soap'][4]
|
||||||
command5 = getattr(self.upnp, method)
|
# command5 = getattr(self.upnp, method)
|
||||||
result = yield command5(*tuple(args))
|
# result = yield command5(*tuple(args))
|
||||||
self.assertEqual(result, expected)
|
# self.assertEqual(result, expected)
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestDDWRTSSDP(TestSSDP):
|
# class TestDDWRTSSDP(TestSSDP):
|
||||||
manufacturer, model = "DD-WRT", "router"
|
# manufacturer, model = "DD-WRT", "router"
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestDDWRTSCPD(TestSCPD):
|
# class TestDDWRTSCPD(TestSCPD):
|
||||||
manufacturer, model = "DD-WRT", "router"
|
# manufacturer, model = "DD-WRT", "router"
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestMiniUPnPMiniUPnPd(TestSSDP):
|
# class TestMiniUPnPMiniUPnPd(TestSSDP):
|
||||||
manufacturer, model = "MiniUPnP", "MiniUPnPd"
|
# manufacturer, model = "MiniUPnP", "MiniUPnPd"
|
||||||
|
#
|
||||||
|
#
|
||||||
class TestMiniUPnPMiniUPnPdSCPD(TestSCPD):
|
# class TestMiniUPnPMiniUPnPdSCPD(TestSCPD):
|
||||||
manufacturer, model = "MiniUPnP", "MiniUPnPd"
|
# manufacturer, model = "MiniUPnP", "MiniUPnPd"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from twisted.trial import unittest
|
import unittest
|
||||||
from txupnp.ssdp_datagram import SSDPDatagram
|
from aioupnp.serialization.ssdp import SSDPDatagram
|
||||||
from txupnp.fault import UPnPError
|
from aioupnp.fault import UPnPError
|
||||||
|
|
||||||
|
|
||||||
class TestParseMSearchRequest(unittest.TestCase):
|
class TestParseMSearchRequest(unittest.TestCase):
|
||||||
|
@ -99,3 +99,27 @@ class TestFailToParseMSearchResponseNoLocation(TestFailToParseMSearchResponseNoS
|
||||||
'st: urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1',
|
'st: urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1',
|
||||||
'USN: uuid:00000000-0000-0000-0000-000000000000::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1'
|
'USN: uuid:00000000-0000-0000-0000-000000000000::urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1'
|
||||||
]).encode()
|
]).encode()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseNotify(unittest.TestCase):
|
||||||
|
datagram = \
|
||||||
|
b'NOTIFY * HTTP/1.1 \r\n' \
|
||||||
|
b'Host: 239.255.255.250:1900\r\n' \
|
||||||
|
b'Cache-Control: max-age=180\r\n' \
|
||||||
|
b'Location: http://192.168.1.1:5431/dyndev/uuid:000c-29ea-247500c00068\r\n' \
|
||||||
|
b'NT: upnp:rootdevice\r\n' \
|
||||||
|
b'NTS: ssdp:alive\r\n' \
|
||||||
|
b'SERVER: LINUX/2.4 UPnP/1.0 BRCM400/1.0\r\n' \
|
||||||
|
b'USN: uuid:000c-29ea-247500c00068::upnp:rootdevice\r\n' \
|
||||||
|
b'\r\n'
|
||||||
|
|
||||||
|
def test_parse_notify(self):
|
||||||
|
packet = SSDPDatagram.decode(self.datagram)
|
||||||
|
self.assertTrue(packet._packet_type, packet._NOTIFY)
|
||||||
|
self.assertEqual(packet.host, '239.255.255.250:1900')
|
||||||
|
self.assertEqual(packet.cache_control, 'max-age=180')
|
||||||
|
self.assertEqual(packet.location, 'http://192.168.1.1:5431/dyndev/uuid:000c-29ea-247500c00068')
|
||||||
|
self.assertEqual(packet.nt, 'upnp:rootdevice')
|
||||||
|
self.assertEqual(packet.nts, 'ssdp:alive')
|
||||||
|
self.assertEqual(packet.server, 'LINUX/2.4 UPnP/1.0 BRCM400/1.0')
|
||||||
|
self.assertEqual(packet.usn, 'uuid:000c-29ea-247500c00068::upnp:rootdevice')
|
||||||
|
|
159
txupnp/cli.py
159
txupnp/cli.py
|
@ -1,159 +0,0 @@
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
from twisted.internet import reactor, defer
|
|
||||||
from txupnp.upnp import UPnP
|
|
||||||
|
|
||||||
log = logging.getLogger("txupnp")
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def get_external_ip(u, *_):
|
|
||||||
ip = yield u.get_external_ip()
|
|
||||||
print(ip)
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def list_mappings(u, *_):
|
|
||||||
redirects = yield u.get_redirects()
|
|
||||||
ext_ip = yield u.get_external_ip()
|
|
||||||
for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirects:
|
|
||||||
print("{}:{}/{} --> {}:{} ({}) (expires: {}) - {} ".format(
|
|
||||||
ext_host or ext_ip, ext_port, proto, int_host, int_port, "enabled" if enabled else "disabled",
|
|
||||||
"never" if not lease else lease, desc)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def add_mapping(u, *_):
|
|
||||||
port = 51413
|
|
||||||
protocol = "UDP"
|
|
||||||
description = "txupnp test mapping"
|
|
||||||
ext_port = yield u.get_next_mapping(port, protocol, description)
|
|
||||||
if ext_port:
|
|
||||||
print("external port: %i to local %i/%s" % (ext_port, port, protocol))
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def delete_mapping(u, *_):
|
|
||||||
port = 4567
|
|
||||||
protocol = "UDP"
|
|
||||||
yield u.delete_port_mapping(port, protocol)
|
|
||||||
mapping = yield u.get_specific_port_mapping(port, protocol)
|
|
||||||
if mapping:
|
|
||||||
print("failed to remove mapping")
|
|
||||||
else:
|
|
||||||
print("removed mapping")
|
|
||||||
|
|
||||||
|
|
||||||
def _encode(x):
|
|
||||||
if isinstance(x, bytes):
|
|
||||||
return x.decode()
|
|
||||||
elif isinstance(x, Exception):
|
|
||||||
return str(x)
|
|
||||||
return x
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def generate_test_data(u, *_):
|
|
||||||
external_ip = yield u.get_external_ip()
|
|
||||||
redirects = yield u.get_redirects()
|
|
||||||
ext_port = yield u.get_next_mapping(4567, "UDP", "txupnp test mapping")
|
|
||||||
delete = yield u.delete_port_mapping(ext_port, "UDP")
|
|
||||||
after_delete = yield u.get_specific_port_mapping(ext_port, "UDP")
|
|
||||||
|
|
||||||
commands_test_case = (
|
|
||||||
("get_external_ip", (), "1.2.3.4"),
|
|
||||||
("get_redirects", (), redirects),
|
|
||||||
("get_next_mapping", (4567, "UDP", "txupnp test mapping"), ext_port),
|
|
||||||
("delete_port_mapping", (ext_port, "UDP"), delete),
|
|
||||||
("get_specific_port_mapping", (ext_port, "UDP"), after_delete),
|
|
||||||
)
|
|
||||||
|
|
||||||
gateway = u.gateway
|
|
||||||
device = list(gateway.devices.values())[0]
|
|
||||||
assert device.manufacturer and device.modelName
|
|
||||||
device_path = os.path.join(os.getcwd(), "%s %s" % (device.manufacturer, device.modelName))
|
|
||||||
commands = gateway.debug_commands()
|
|
||||||
with open(device_path, "w") as f:
|
|
||||||
f.write(json.dumps({
|
|
||||||
"router_address": u.router_ip,
|
|
||||||
"client_address": u.lan_address,
|
|
||||||
"port": gateway.port,
|
|
||||||
"gateway_dict": gateway.as_dict(),
|
|
||||||
'expected_devices': [
|
|
||||||
{
|
|
||||||
'cache_control': 'max-age=1800',
|
|
||||||
'location': gateway.location,
|
|
||||||
'server': gateway.server,
|
|
||||||
'st': gateway.urn,
|
|
||||||
'usn': gateway.usn
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'commands': commands,
|
|
||||||
'ssdp': u.sspd_factory.get_ssdp_packet_replay(),
|
|
||||||
'scpd': gateway.requester.dump_packets(),
|
|
||||||
'soap': commands_test_case
|
|
||||||
}, default=_encode, indent=2).replace(external_ip, "1.2.3.4"))
|
|
||||||
print("Generated test data! -> %s" % device_path)
|
|
||||||
|
|
||||||
|
|
||||||
cli_commands = {
|
|
||||||
"get_external_ip": get_external_ip,
|
|
||||||
"list_mappings": list_mappings,
|
|
||||||
"add_mapping": add_mapping,
|
|
||||||
"delete_mapping": delete_mapping,
|
|
||||||
"generate_test_data": generate_test_data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def run_command(found, u, command, debug_xml):
|
|
||||||
if not found:
|
|
||||||
print("failed to find gateway")
|
|
||||||
reactor.callLater(0, reactor.stop)
|
|
||||||
return
|
|
||||||
if command not in cli_commands:
|
|
||||||
print("unrecognized command: valid commands: %s" % list(cli_commands.keys()))
|
|
||||||
else:
|
|
||||||
yield cli_commands[command](u, debug_xml)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
import logging
|
|
||||||
log = logging.getLogger("txupnp")
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(logging.Formatter('%(asctime)-15s-%(filename)s:%(lineno)s->%(message)s'))
|
|
||||||
log.addHandler(handler)
|
|
||||||
log.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="upnp command line utility")
|
|
||||||
parser.add_argument(dest="command", type=str, help="debug_gateway | list_mappings | get_external_ip | add_mapping | delete_mapping")
|
|
||||||
parser.add_argument("--debug_logging", dest="debug_logging", default=False, action="store_true")
|
|
||||||
parser.add_argument("--include_igd_xml", dest="include_igd_xml", default=False, action="store_true")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.debug_logging:
|
|
||||||
# from twisted.python import log as tx_log
|
|
||||||
# observer = tx_log.PythonLoggingObserver(loggerName="txupnp")
|
|
||||||
# observer.start()
|
|
||||||
log.setLevel(logging.DEBUG)
|
|
||||||
command = args.command
|
|
||||||
command = command.replace("-", "_")
|
|
||||||
if command not in cli_commands:
|
|
||||||
print("unrecognized command: %s is not in %s" % (command, cli_commands.keys()))
|
|
||||||
return
|
|
||||||
|
|
||||||
def show(err):
|
|
||||||
print("error: {}".format(err))
|
|
||||||
|
|
||||||
u = UPnP(reactor, debug_ssdp=(command == "generate_test_data"))
|
|
||||||
d = u.discover()
|
|
||||||
d.addCallback(run_command, u, command, args.include_igd_xml)
|
|
||||||
d.addErrback(show)
|
|
||||||
d.addBoth(lambda _: reactor.callLater(0, reactor.stop))
|
|
||||||
reactor.run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -1,217 +0,0 @@
|
||||||
import logging
|
|
||||||
from twisted.internet import defer
|
|
||||||
from txupnp.scpd import SCPDCommand, SCPDRequester
|
|
||||||
from txupnp.util import get_dict_val_case_insensitive, verify_return_types, BASE_PORT_REGEX, BASE_ADDRESS_REGEX
|
|
||||||
from txupnp.constants import SPEC_VERSION
|
|
||||||
from txupnp.commands import SCPDCommands
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class CaseInsensitive:
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
not_evaluated = {}
|
|
||||||
for k, v in kwargs.items():
|
|
||||||
if k.startswith("_"):
|
|
||||||
not_evaluated[k] = v
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
getattr(self, k)
|
|
||||||
setattr(self, k, v)
|
|
||||||
except AttributeError as err:
|
|
||||||
not_evaluated[k] = v
|
|
||||||
if not_evaluated:
|
|
||||||
log.debug("%s did not apply kwargs: %s", self.__class__.__name__, not_evaluated)
|
|
||||||
|
|
||||||
def _get_attr_name(self, case_insensitive: str) -> str:
|
|
||||||
for k, v in self.__dict__.items():
|
|
||||||
if k.lower() == case_insensitive.lower():
|
|
||||||
return k
|
|
||||||
|
|
||||||
def __getattr__(self, item):
|
|
||||||
if item in self.__dict__:
|
|
||||||
return self.__dict__[item]
|
|
||||||
for k, v in self.__class__.__dict__.items():
|
|
||||||
if k.lower() == item.lower():
|
|
||||||
if k not in self.__dict__:
|
|
||||||
self.__dict__[k] = v
|
|
||||||
return v
|
|
||||||
raise AttributeError(item)
|
|
||||||
|
|
||||||
def __setattr__(self, item, value):
|
|
||||||
if item in self.__dict__:
|
|
||||||
self.__dict__[item] = value
|
|
||||||
return
|
|
||||||
to_update = None
|
|
||||||
for k, v in self.__dict__.items():
|
|
||||||
if k.lower() == item.lower():
|
|
||||||
to_update = k
|
|
||||||
break
|
|
||||||
self.__dict__[to_update or item] = value
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
k: v for k, v in self.__dict__.items() if not k.startswith("_") and not callable(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Service(CaseInsensitive):
|
|
||||||
serviceType = None
|
|
||||||
serviceId = None
|
|
||||||
controlURL = None
|
|
||||||
eventSubURL = None
|
|
||||||
SCPDURL = 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
|
|
||||||
|
|
||||||
def __init__(self, devices, services, **kwargs):
|
|
||||||
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 self.deviceList:
|
|
||||||
for kw in self.deviceList.values():
|
|
||||||
if isinstance(kw, dict):
|
|
||||||
d = Device(devices, services, **kw)
|
|
||||||
devices.append(d)
|
|
||||||
elif isinstance(kw, list):
|
|
||||||
for _inner_kw in kw:
|
|
||||||
d = Device(devices, services, **_inner_kw)
|
|
||||||
devices.append(d)
|
|
||||||
else:
|
|
||||||
log.warning("failed to parse device:\n%s", kw)
|
|
||||||
|
|
||||||
|
|
||||||
class Gateway:
|
|
||||||
def __init__(self, reactor, **kwargs):
|
|
||||||
flattened = {
|
|
||||||
k.lower(): v for k, v in kwargs.items()
|
|
||||||
}
|
|
||||||
usn = flattened["usn"]
|
|
||||||
server = flattened["server"]
|
|
||||||
location = flattened["location"]
|
|
||||||
st = flattened["st"]
|
|
||||||
|
|
||||||
cache_control = flattened.get("cache_control") or flattened.get("cache-control") or ""
|
|
||||||
date = flattened.get("date", "")
|
|
||||||
ext = flattened.get("ext", "")
|
|
||||||
|
|
||||||
self.usn = usn.encode()
|
|
||||||
self.ext = ext.encode()
|
|
||||||
self.server = server.encode()
|
|
||||||
self.location = location.encode()
|
|
||||||
self.cache_control = cache_control.encode()
|
|
||||||
self.date = date.encode()
|
|
||||||
self.urn = st.encode()
|
|
||||||
|
|
||||||
self._xml_response = ""
|
|
||||||
self._service_descriptors = {}
|
|
||||||
self.base_address = BASE_ADDRESS_REGEX.findall(self.location)[0]
|
|
||||||
self.port = int(BASE_PORT_REGEX.findall(self.location)[0])
|
|
||||||
self.spec_version = None
|
|
||||||
self.url_base = None
|
|
||||||
|
|
||||||
self._device = None
|
|
||||||
self._devices = []
|
|
||||||
self._services = []
|
|
||||||
|
|
||||||
self._reactor = reactor
|
|
||||||
self._unsupported_actions = {}
|
|
||||||
self._registered_commands = {}
|
|
||||||
self.commands = SCPDCommands()
|
|
||||||
self.requester = SCPDRequester(self._reactor)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict:
|
|
||||||
r = {
|
|
||||||
'server': self.server.decode(),
|
|
||||||
'urlBase': self.url_base,
|
|
||||||
'location': self.location.decode(),
|
|
||||||
"specVersion": self.spec_version,
|
|
||||||
'usn': self.usn.decode(),
|
|
||||||
'urn': self.urn.decode(),
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def discover_commands(self):
|
|
||||||
response = yield self.requester.scpd_get(self.location.decode().split(self.base_address.decode())[1], self.base_address.decode(), self.port)
|
|
||||||
self.spec_version = get_dict_val_case_insensitive(response, SPEC_VERSION)
|
|
||||||
self.url_base = get_dict_val_case_insensitive(response, "urlbase")
|
|
||||||
if not self.url_base:
|
|
||||||
self.url_base = self.base_address.decode()
|
|
||||||
if response:
|
|
||||||
self._device = Device(
|
|
||||||
self._devices, self._services, **get_dict_val_case_insensitive(response, "device")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._device = Device(self._devices, self._services)
|
|
||||||
for service_type in self.services.keys():
|
|
||||||
service = self.services[service_type]
|
|
||||||
yield self.register_commands(service)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def register_commands(self, service: Service):
|
|
||||||
try:
|
|
||||||
action_list = yield self.requester.scpd_get_supported_actions(service, self.base_address.decode(), self.port)
|
|
||||||
except Exception as err:
|
|
||||||
log.exception("failed to register service %s: %s", service.serviceType, str(err))
|
|
||||||
return
|
|
||||||
for name, inputs, outputs in action_list:
|
|
||||||
try:
|
|
||||||
command = SCPDCommand(self.requester, self.base_address, self.port,
|
|
||||||
service.controlURL.encode(),
|
|
||||||
service.serviceType.encode(), name, inputs, outputs)
|
|
||||||
current = getattr(self.commands, command.method)
|
|
||||||
if hasattr(current, "_return_types"):
|
|
||||||
command._process_result = verify_return_types(*current._return_types)(command._process_result)
|
|
||||||
setattr(command, "__doc__", current.__doc__)
|
|
||||||
setattr(self.commands, command.method, command)
|
|
||||||
self._registered_commands[command.method] = service.serviceType
|
|
||||||
log.debug("registered %s::%s", service.serviceType, command.method)
|
|
||||||
except AttributeError:
|
|
||||||
s = self._unsupported_actions.get(service.serviceType, [])
|
|
||||||
s.append(name)
|
|
||||||
self._unsupported_actions[service.serviceType] = s
|
|
||||||
log.debug("available command for %s does not have a wrapper implemented: %s %s %s",
|
|
||||||
service.serviceType, name, inputs, outputs)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def services(self) -> dict:
|
|
||||||
if not self._device:
|
|
||||||
return {}
|
|
||||||
return {service.serviceType: service for service in self._services}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def devices(self) -> dict:
|
|
||||||
if not self._device:
|
|
||||||
return {}
|
|
||||||
return {device.udn: device for device in self._devices}
|
|
||||||
|
|
||||||
def get_service(self, service_type: str) -> Service:
|
|
||||||
for service in self._services:
|
|
||||||
if service.serviceType.lower() == service_type.lower():
|
|
||||||
return service
|
|
||||||
|
|
||||||
def debug_commands(self):
|
|
||||||
return {
|
|
||||||
'available': self._registered_commands,
|
|
||||||
'failed': self._unsupported_actions
|
|
||||||
}
|
|
177
txupnp/mocks.py
177
txupnp/mocks.py
|
@ -1,177 +0,0 @@
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from twisted.internet import task, defer
|
|
||||||
from twisted.internet.error import ConnectionDone
|
|
||||||
from twisted.internet.protocol import DatagramProtocol
|
|
||||||
from twisted.python.failure import Failure
|
|
||||||
from twisted.test.proto_helpers import _FakePort
|
|
||||||
from txupnp.ssdp_datagram import SSDPDatagram
|
|
||||||
|
|
||||||
log = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
class MockResponse:
|
|
||||||
def __init__(self, content):
|
|
||||||
self._content = content
|
|
||||||
self.headers = {}
|
|
||||||
|
|
||||||
def content(self):
|
|
||||||
return defer.succeed(self._content)
|
|
||||||
|
|
||||||
|
|
||||||
class MockDevice:
|
|
||||||
def __init__(self, manufacturer, model):
|
|
||||||
self.manufacturer = manufacturer
|
|
||||||
self.model = model
|
|
||||||
device_path = os.path.join(
|
|
||||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "devices"), "{} {}".format(manufacturer, model)
|
|
||||||
)
|
|
||||||
assert os.path.isfile(device_path)
|
|
||||||
with open(device_path, "r") as f:
|
|
||||||
self.device_dict = json.loads(f.read())
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return "MockDevice(manufacturer={}, model={})".format(self.manufacturer, self.model)
|
|
||||||
|
|
||||||
|
|
||||||
def get_mock_devices():
|
|
||||||
return [
|
|
||||||
MockDevice(path.split(" ")[0], path.split(" ")[1])
|
|
||||||
for path in os.listdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), "devices"))
|
|
||||||
if ".py" not in path and "pycache" not in path
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_device_test_case(manufacturer: str, model: str) -> MockDevice:
|
|
||||||
r = [
|
|
||||||
MockDevice(path.split(" ")[0], path.split(" ")[1])
|
|
||||||
for path in os.listdir(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tests", "devices"))
|
|
||||||
if ".py" not in path and "pycache" not in path and path.split(" ") == [manufacturer, model]
|
|
||||||
]
|
|
||||||
return r[0]
|
|
||||||
|
|
||||||
|
|
||||||
class MockMulticastTransport:
|
|
||||||
def __init__(self, address, port, max_packet_size, network, protocol):
|
|
||||||
self.address = address
|
|
||||||
self.port = port
|
|
||||||
self.max_packet_size = max_packet_size
|
|
||||||
self._network = network
|
|
||||||
self._protocol = protocol
|
|
||||||
|
|
||||||
def write(self, data, address):
|
|
||||||
if address[0] in self._network.group:
|
|
||||||
destinations = self._network.group[address[0]]
|
|
||||||
else:
|
|
||||||
destinations = address[0]
|
|
||||||
for address, dest in self._network.peers.items():
|
|
||||||
if address[0] in destinations and dest.address != self.address:
|
|
||||||
dest._protocol.datagramReceived(data, (self.address, self.port))
|
|
||||||
|
|
||||||
def setTTL(self, ttl):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def joinGroup(self, address, interface=None):
|
|
||||||
group = self._network.group.get(address, [])
|
|
||||||
group.append(interface)
|
|
||||||
self._network.group[address] = group
|
|
||||||
|
|
||||||
def leaveGroup(self, address, interface=None):
|
|
||||||
group = self._network.group.get(address, [])
|
|
||||||
if interface in group:
|
|
||||||
group.remove(interface)
|
|
||||||
self._network.group[address] = group
|
|
||||||
|
|
||||||
|
|
||||||
class MockTCPTransport(_FakePort):
|
|
||||||
def __init__(self, address, port, callback, mock_requests):
|
|
||||||
super().__init__((address, port))
|
|
||||||
self._callback = callback
|
|
||||||
self._mock_requests = mock_requests
|
|
||||||
|
|
||||||
def write(self, data):
|
|
||||||
if data.startswith(b"POST"):
|
|
||||||
for url, packets in self._mock_requests['POST'].items():
|
|
||||||
for request_response in packets:
|
|
||||||
if data.decode() == request_response['request']:
|
|
||||||
self._callback(request_response['response'].encode())
|
|
||||||
return
|
|
||||||
elif data.startswith(b"GET"):
|
|
||||||
for url, packets in self._mock_requests['GET'].items():
|
|
||||||
if data.decode() == packets['request']:
|
|
||||||
self._callback(packets['response'].encode())
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class MockMulticastPort(_FakePort):
|
|
||||||
def __init__(self, protocol, remover, address, transport):
|
|
||||||
super().__init__((address, 1900))
|
|
||||||
self.protocol = protocol
|
|
||||||
self._remover = remover
|
|
||||||
self.transport = transport
|
|
||||||
|
|
||||||
def startListening(self, reason=None):
|
|
||||||
self.protocol.transport = self.transport
|
|
||||||
return self.protocol.startProtocol()
|
|
||||||
|
|
||||||
def stopListening(self, reason=None):
|
|
||||||
result = self.protocol.stopProtocol()
|
|
||||||
self._remover()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class MockNetwork:
|
|
||||||
def __init__(self):
|
|
||||||
self.peers = {}
|
|
||||||
self.group = {}
|
|
||||||
|
|
||||||
def add_peer(self, port, protocol, interface, maxPacketSize):
|
|
||||||
transport = MockMulticastTransport(interface, port, maxPacketSize, self, protocol)
|
|
||||||
self.peers[(interface, port)] = transport
|
|
||||||
|
|
||||||
def remove_peer():
|
|
||||||
if self.peers.get((interface, port)):
|
|
||||||
del self.peers[(interface, port)]
|
|
||||||
return transport, remove_peer
|
|
||||||
|
|
||||||
|
|
||||||
class MockReactor(task.Clock):
|
|
||||||
def __init__(self, client_addr, mock_scpd_requests):
|
|
||||||
super().__init__()
|
|
||||||
self.client_addr = client_addr
|
|
||||||
self._mock_scpd_requests = mock_scpd_requests
|
|
||||||
self.network = MockNetwork()
|
|
||||||
|
|
||||||
def listenMulticast(self, port, protocol, interface=None, maxPacketSize=8192, listenMultiple=True):
|
|
||||||
interface = interface or self.client_addr
|
|
||||||
transport, remover = self.network.add_peer(port, protocol, interface, maxPacketSize)
|
|
||||||
port = MockMulticastPort(protocol, remover, interface, transport)
|
|
||||||
port.startListening()
|
|
||||||
return port
|
|
||||||
|
|
||||||
def connectTCP(self, host, port, factory, timeout=30, bindAddress=None):
|
|
||||||
protocol = factory.buildProtocol(host)
|
|
||||||
|
|
||||||
def _write_and_close(data):
|
|
||||||
protocol.dataReceived(data)
|
|
||||||
protocol.connectionLost(Failure(ConnectionDone()))
|
|
||||||
|
|
||||||
protocol.transport = MockTCPTransport(host, port, _write_and_close, self._mock_scpd_requests)
|
|
||||||
protocol.connectionMade()
|
|
||||||
|
|
||||||
|
|
||||||
class MockSSDPServiceGatewayProtocol(DatagramProtocol):
|
|
||||||
def __init__(self, client_addr: int, iface: str, packets_rx: list, packets_tx: list):
|
|
||||||
self.client_addr = client_addr
|
|
||||||
self.iface = iface
|
|
||||||
self.packets_tx = [SSDPDatagram.decode(packet.encode()) for packet in packets_tx] # sent by client
|
|
||||||
self.packets_rx = [((addr, port), SSDPDatagram.decode(packet.encode())) for (addr, port), packet in packets_rx] # rx by client
|
|
||||||
|
|
||||||
def datagramReceived(self, datagram, address):
|
|
||||||
packet = SSDPDatagram.decode(datagram)
|
|
||||||
if packet.st in map(lambda p: p[1].st, self.packets_rx): # this contains one of the service types the server replied to
|
|
||||||
reply = list(filter(lambda p: p[1].st == packet.st, self.packets_rx))[0][1]
|
|
||||||
self.transport.write(reply.encode().encode(), (self.client_addr, 1900))
|
|
||||||
else:
|
|
||||||
pass
|
|
264
txupnp/scpd.py
264
txupnp/scpd.py
|
@ -1,264 +0,0 @@
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
from twisted.internet.protocol import Protocol, ClientFactory
|
|
||||||
from twisted.internet import defer, error
|
|
||||||
from txupnp.constants import XML_VERSION, DEVICE, ROOT, SERVICE, ENVELOPE, BODY
|
|
||||||
from txupnp.util import etree_to_dict, flatten_keys
|
|
||||||
from txupnp.fault import handle_fault, UPnPError
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
CONTENT_PATTERN = re.compile(
|
|
||||||
"(\<\?xml version=\"1\.0\"\?\>(\s*.)*|\>)".encode()
|
|
||||||
)
|
|
||||||
CONTENT_NO_XML_VERSION_PATTERN = re.compile(
|
|
||||||
"(\<s\:Envelope xmlns\:s=\"http\:\/\/schemas\.xmlsoap\.org\/soap\/envelope\/\"(\s*.)*\>)".encode()
|
|
||||||
)
|
|
||||||
|
|
||||||
XML_ROOT_SANITY_PATTERN = re.compile(
|
|
||||||
"(?i)(\{|(urn:schemas-[\w|\d]*-(com|org|net))[:|-](device|service)[:|-]([\w|\d|\:|\-|\_]*)|\}([\w|\d|\:|\-|\_]*))"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_service_description(content: bytes):
|
|
||||||
if not content:
|
|
||||||
return []
|
|
||||||
element_dict = etree_to_dict(ElementTree.fromstring(content.decode()))
|
|
||||||
service_info = flatten_keys(element_dict, "{%s}" % SERVICE)
|
|
||||||
if "scpd" not in service_info:
|
|
||||||
return []
|
|
||||||
action_list = service_info["scpd"]["actionList"]
|
|
||||||
if not len(action_list): # it could be an empty string
|
|
||||||
return []
|
|
||||||
result = []
|
|
||||||
if isinstance(action_list["action"], dict):
|
|
||||||
arg_dicts = action_list["action"]['argumentList']['argument']
|
|
||||||
if not isinstance(arg_dicts, list): # when there is one arg, ew
|
|
||||||
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'], [], []))
|
|
||||||
else:
|
|
||||||
arg_dicts = action['argumentList']['argument']
|
|
||||||
if not isinstance(arg_dicts, list): # when there is one arg, ew
|
|
||||||
arg_dicts = [arg_dicts]
|
|
||||||
result.append((
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class SCPDHTTPClientProtocol(Protocol):
|
|
||||||
def connectionMade(self):
|
|
||||||
self.response_buff = b""
|
|
||||||
log.debug("Sending HTTP:\n%s", self.factory.packet.decode())
|
|
||||||
self.factory.reactor.callLater(0, self.transport.write, self.factory.packet)
|
|
||||||
|
|
||||||
def dataReceived(self, data):
|
|
||||||
self.response_buff += data
|
|
||||||
|
|
||||||
def connectionLost(self, reason):
|
|
||||||
if reason.trap(error.ConnectionDone):
|
|
||||||
log.debug("Received HTTP:\n%s", self.response_buff.decode())
|
|
||||||
if XML_VERSION.encode() in self.response_buff:
|
|
||||||
parsed = CONTENT_PATTERN.findall(self.response_buff)
|
|
||||||
result = b'' if not parsed else parsed[0][0]
|
|
||||||
self.factory.finished_deferred.callback(result)
|
|
||||||
else:
|
|
||||||
parsed = CONTENT_NO_XML_VERSION_PATTERN.findall(self.response_buff)
|
|
||||||
result = b'' if not parsed else XML_VERSION.encode() + b'\r\n' + parsed[0][0]
|
|
||||||
self.factory.finished_deferred.callback(result)
|
|
||||||
|
|
||||||
|
|
||||||
class SCPDHTTPClientFactory(ClientFactory):
|
|
||||||
protocol = SCPDHTTPClientProtocol
|
|
||||||
|
|
||||||
def __init__(self, reactor, packet):
|
|
||||||
self.reactor = reactor
|
|
||||||
self.finished_deferred = defer.Deferred()
|
|
||||||
self.packet = packet
|
|
||||||
|
|
||||||
def buildProtocol(self, addr):
|
|
||||||
p = self.protocol()
|
|
||||||
p.factory = self
|
|
||||||
return p
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def post(cls, reactor, command, **kwargs):
|
|
||||||
args = "".join("<%s>%s</%s>" % (n, kwargs.get(n), n) for n in command.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, command.method, command.service_id.decode(),
|
|
||||||
args, command.method))
|
|
||||||
if "http://" in command.gateway_address.decode():
|
|
||||||
host = command.gateway_address.decode().split("http://")[1]
|
|
||||||
else:
|
|
||||||
host = command.gateway_address.decode()
|
|
||||||
data = (
|
|
||||||
(
|
|
||||||
'POST %s HTTP/1.1\r\n'
|
|
||||||
'Host: %s\r\n'
|
|
||||||
'User-Agent: python3/txupnp, 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'
|
|
||||||
) % (
|
|
||||||
command.control_url.decode(), # could be just / even if it shouldn't be
|
|
||||||
host,
|
|
||||||
len(soap_body),
|
|
||||||
command.service_id.decode(), # maybe no quotes
|
|
||||||
command.method,
|
|
||||||
soap_body
|
|
||||||
)
|
|
||||||
).encode()
|
|
||||||
return cls(reactor, data)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(cls, reactor, control_url: str, address: str):
|
|
||||||
if "http://" in address:
|
|
||||||
host = address.split("http://")[1]
|
|
||||||
else:
|
|
||||||
host = address
|
|
||||||
if ":" in host:
|
|
||||||
host = host.split(":")[0]
|
|
||||||
if not control_url.startswith("/"):
|
|
||||||
control_url = "/" + control_url
|
|
||||||
data = (
|
|
||||||
(
|
|
||||||
'GET %s HTTP/1.1\r\n'
|
|
||||||
'Accept-Encoding: gzip\r\n'
|
|
||||||
'Host: %s\r\n'
|
|
||||||
'\r\n'
|
|
||||||
) % (control_url, host)
|
|
||||||
).encode()
|
|
||||||
return cls(reactor, data)
|
|
||||||
|
|
||||||
|
|
||||||
class SCPDRequester:
|
|
||||||
client_factory = SCPDHTTPClientFactory
|
|
||||||
|
|
||||||
def __init__(self, reactor):
|
|
||||||
self._reactor = reactor
|
|
||||||
self._get_requests = {}
|
|
||||||
self._post_requests = {}
|
|
||||||
|
|
||||||
def _save_get(self, request: bytes, response: bytes, destination: str) -> None:
|
|
||||||
self._get_requests[destination.lstrip("/")] = {
|
|
||||||
'request': request,
|
|
||||||
'response': response
|
|
||||||
}
|
|
||||||
|
|
||||||
def _save_post(self, request: bytes, response: bytes, destination: str) -> None:
|
|
||||||
p = self._post_requests.get(destination.lstrip("/"), [])
|
|
||||||
p.append({
|
|
||||||
'request': request,
|
|
||||||
'response': response,
|
|
||||||
})
|
|
||||||
self._post_requests[destination.lstrip("/")] = p
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def _scpd_get_soap_xml(self, control_url: str, address: str, service_port: int) -> bytes:
|
|
||||||
factory = self.client_factory.get(self._reactor, control_url, address)
|
|
||||||
url = address.split("http://")[1].split(":")[0]
|
|
||||||
self._reactor.connectTCP(url, service_port, factory)
|
|
||||||
xml_response_bytes = yield factory.finished_deferred
|
|
||||||
self._save_get(factory.packet, xml_response_bytes, control_url)
|
|
||||||
return xml_response_bytes
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def scpd_post_soap(self, command, **kwargs) -> tuple:
|
|
||||||
factory = self.client_factory.post(self._reactor, command, **kwargs)
|
|
||||||
url = command.gateway_address.split(b"http://")[1].split(b":")[0]
|
|
||||||
self._reactor.connectTCP(url.decode(), command.service_port, factory)
|
|
||||||
xml_response_bytes = yield factory.finished_deferred
|
|
||||||
self._save_post(
|
|
||||||
factory.packet, xml_response_bytes, command.gateway_address.decode() + command.control_url.decode()
|
|
||||||
)
|
|
||||||
content_dict = etree_to_dict(ElementTree.fromstring(xml_response_bytes.decode()))
|
|
||||||
envelope = content_dict[ENVELOPE]
|
|
||||||
response_body = flatten_keys(envelope[BODY], "{%s}" % command.service_id)
|
|
||||||
body = handle_fault(response_body) # raises UPnPError if there is a fault
|
|
||||||
response_key = None
|
|
||||||
for key in body:
|
|
||||||
if command.method in key:
|
|
||||||
response_key = key
|
|
||||||
break
|
|
||||||
if not response_key:
|
|
||||||
raise UPnPError("unknown response fields for %s")
|
|
||||||
response = body[response_key]
|
|
||||||
extracted_response = tuple([response[n] for n in command.returns])
|
|
||||||
return extracted_response
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def scpd_get_supported_actions(self, service, address: str, port: int) -> list:
|
|
||||||
xml_bytes = yield self._scpd_get_soap_xml(service.SCPDURL, address, port)
|
|
||||||
return parse_service_description(xml_bytes)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def scpd_get(self, control_url: str, service_address: str, service_port: int) -> dict:
|
|
||||||
xml_bytes = yield self._scpd_get_soap_xml(control_url, service_address, service_port)
|
|
||||||
xml_dict = etree_to_dict(ElementTree.fromstring(xml_bytes.decode()))
|
|
||||||
schema_key = DEVICE
|
|
||||||
root = ROOT
|
|
||||||
for k in xml_dict.keys():
|
|
||||||
m = 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]
|
|
||||||
break
|
|
||||||
flattened_xml = flatten_keys(xml_dict, "{%s}" % schema_key)[root]
|
|
||||||
return flattened_xml
|
|
||||||
|
|
||||||
def dump_packets(self) -> dict:
|
|
||||||
return {
|
|
||||||
'GET': self._get_requests,
|
|
||||||
'POST': self._post_requests
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SCPDCommand:
|
|
||||||
def __init__(self, scpd_requester: SCPDRequester, gateway_address, service_port, control_url, service_id, method,
|
|
||||||
param_names,
|
|
||||||
returns):
|
|
||||||
self.scpd_requester = scpd_requester
|
|
||||||
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_names = param_names
|
|
||||||
self.returns = returns
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_result(*results):
|
|
||||||
"""
|
|
||||||
this method gets decorated automatically with a function that maps result types to the types
|
|
||||||
defined in the @return_types decorator
|
|
||||||
"""
|
|
||||||
return results
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def __call__(self, **kwargs):
|
|
||||||
if set(kwargs.keys()) != set(self.param_names):
|
|
||||||
raise Exception("argument mismatch: %s vs %s" % (kwargs.keys(), self.param_names))
|
|
||||||
response = yield self.scpd_requester.scpd_post_soap(self, **kwargs)
|
|
||||||
try:
|
|
||||||
result = self._process_result(*response)
|
|
||||||
except Exception as err:
|
|
||||||
log.error("error formatting response (%s):\n%s", err, response)
|
|
||||||
raise err
|
|
||||||
defer.returnValue(result)
|
|
168
txupnp/ssdp.py
168
txupnp/ssdp.py
|
@ -1,168 +0,0 @@
|
||||||
import logging
|
|
||||||
import binascii
|
|
||||||
from twisted.internet import defer
|
|
||||||
from twisted.internet.protocol import DatagramProtocol
|
|
||||||
from txupnp.constants import UPNP_ORG_IGD, SSDP_DISCOVER, SSDP_IP_ADDRESS, SSDP_PORT, service_types
|
|
||||||
from txupnp.constants import SSDP_HOST
|
|
||||||
from txupnp.fault import UPnPError
|
|
||||||
from txupnp.ssdp_datagram import SSDPDatagram
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SSDPProtocol(DatagramProtocol):
|
|
||||||
def __init__(self, reactor, iface, router, ssdp_address=SSDP_IP_ADDRESS,
|
|
||||||
ssdp_port=SSDP_PORT, ttl=1, max_devices=None, debug_packets=False,
|
|
||||||
debug_sent=None, debug_received=None):
|
|
||||||
self._reactor = reactor
|
|
||||||
self._sem = defer.DeferredSemaphore(1)
|
|
||||||
self.discover_callbacks = {}
|
|
||||||
self.iface = iface
|
|
||||||
self.router = router
|
|
||||||
self.ssdp_address = ssdp_address
|
|
||||||
self.ssdp_port = ssdp_port
|
|
||||||
self.ttl = ttl
|
|
||||||
self._start = None
|
|
||||||
self.max_devices = max_devices
|
|
||||||
self.devices = []
|
|
||||||
self.debug_packets = debug_packets
|
|
||||||
self.debug_sent = debug_sent if debug_sent is not None else []
|
|
||||||
self.debug_received = debug_received if debug_sent is not None else []
|
|
||||||
|
|
||||||
def _send_m_search(self, service=UPNP_ORG_IGD):
|
|
||||||
packet = SSDPDatagram(SSDPDatagram._M_SEARCH, host=SSDP_HOST, st=service, man=SSDP_DISCOVER, mx=1)
|
|
||||||
log.debug("sending packet to %s:\n%s", SSDP_HOST, packet.encode())
|
|
||||||
try:
|
|
||||||
msg_bytes = packet.encode().encode()
|
|
||||||
if self.debug_packets:
|
|
||||||
self.debug_sent.append(msg_bytes)
|
|
||||||
self.transport.write(msg_bytes, (self.ssdp_address, self.ssdp_port))
|
|
||||||
except Exception as err:
|
|
||||||
log.exception("failed to write %s to %s:%i", packet.encode(), self.ssdp_address, self.ssdp_port)
|
|
||||||
raise err
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _gather(finished_deferred, max_results, results: list):
|
|
||||||
def discover_cb(packet):
|
|
||||||
if not finished_deferred.called and packet.st in service_types:
|
|
||||||
results.append(packet.as_dict())
|
|
||||||
if len(results) >= max_results:
|
|
||||||
finished_deferred.callback(results)
|
|
||||||
|
|
||||||
return discover_cb
|
|
||||||
|
|
||||||
def m_search(self, address, timeout, max_devices):
|
|
||||||
# return deferred for a pending call if we have one
|
|
||||||
if address in self.discover_callbacks:
|
|
||||||
d = self.discover_callbacks[address][1]
|
|
||||||
if not d.called: # the existing deferred has already fired, make a new one
|
|
||||||
return d
|
|
||||||
|
|
||||||
def _trap_timeout_and_return_results(err):
|
|
||||||
if err.check(defer.TimeoutError):
|
|
||||||
return self.devices
|
|
||||||
raise err
|
|
||||||
|
|
||||||
d = defer.Deferred()
|
|
||||||
d.addTimeout(timeout, self._reactor)
|
|
||||||
d.addErrback(_trap_timeout_and_return_results)
|
|
||||||
found_cb = self._gather(d, max_devices, self.devices)
|
|
||||||
self.discover_callbacks[address] = found_cb, d
|
|
||||||
for st in service_types:
|
|
||||||
self._send_m_search(service=st)
|
|
||||||
return d
|
|
||||||
|
|
||||||
def startProtocol(self):
|
|
||||||
self._start = self._reactor.seconds()
|
|
||||||
self.transport.setTTL(self.ttl)
|
|
||||||
self.transport.joinGroup(self.ssdp_address, interface=self.iface)
|
|
||||||
|
|
||||||
def datagramReceived(self, datagram, address):
|
|
||||||
if address[0] == self.iface:
|
|
||||||
return
|
|
||||||
if self.debug_packets:
|
|
||||||
self.debug_received.append((address, datagram))
|
|
||||||
try:
|
|
||||||
packet = SSDPDatagram.decode(datagram)
|
|
||||||
log.debug("decoded %s from %s:%i:\n%s", packet.get_friendly_name(), address[0], address[1], packet.encode())
|
|
||||||
except UPnPError as err:
|
|
||||||
log.error("failed to decode SSDP packet from %s:%i: %s\npacket: %s", address[0], address[1], err,
|
|
||||||
binascii.hexlify(datagram))
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
log.exception("failed to decode: %s", binascii.hexlify(datagram))
|
|
||||||
return
|
|
||||||
if packet._packet_type == packet._OK:
|
|
||||||
log.debug("%s:%i replied to our m-search with new xml url: %s", address[0], address[1], packet.location)
|
|
||||||
# if address[0] in self.discover_callbacks and packet.location not in map(lambda p: p['location'], self.devices):
|
|
||||||
if packet.location not in map(lambda p: p['location'], self.devices):
|
|
||||||
if address[0] not in self.discover_callbacks:
|
|
||||||
self.devices.append(packet.as_dict())
|
|
||||||
else:
|
|
||||||
self._sem.run(self.discover_callbacks[address[0]][0], packet)
|
|
||||||
else:
|
|
||||||
log.info("ignored packet from %s:%s (%s) %s", address[0], address[1], packet._packet_type, packet.location)
|
|
||||||
elif packet._packet_type == packet._NOTIFY:
|
|
||||||
log.debug("%s:%i sent us a notification (type: %s), url: %s", address[0], address[1], packet.nts,
|
|
||||||
packet.location)
|
|
||||||
|
|
||||||
|
|
||||||
class SSDPFactory:
|
|
||||||
def __init__(self, reactor, lan_address, router_address, debug_packets=False):
|
|
||||||
self.lan_address = lan_address
|
|
||||||
self.router_address = router_address
|
|
||||||
self._reactor = reactor
|
|
||||||
self.protocol = None
|
|
||||||
self.port = None
|
|
||||||
self.debug_packets = debug_packets
|
|
||||||
self.debug_sent = []
|
|
||||||
self.debug_received = []
|
|
||||||
self.server_infos = []
|
|
||||||
|
|
||||||
def disconnect(self):
|
|
||||||
if not self.port:
|
|
||||||
return
|
|
||||||
self.protocol.transport.leaveGroup(SSDP_IP_ADDRESS, interface=self.lan_address)
|
|
||||||
self.port.stopListening()
|
|
||||||
self.port = None
|
|
||||||
self.protocol = None
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
if not self.protocol:
|
|
||||||
self.protocol = SSDPProtocol(self._reactor, self.lan_address, self.router_address,
|
|
||||||
debug_packets=self.debug_packets, debug_sent=self.debug_sent,
|
|
||||||
debug_received=self.debug_received)
|
|
||||||
if not self.port:
|
|
||||||
self._reactor.addSystemEventTrigger("before", "shutdown", self.disconnect)
|
|
||||||
self.port = self._reactor.listenMulticast(self.protocol.ssdp_port, self.protocol, listenMultiple=True)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def m_search(self, address, timeout, max_devices):
|
|
||||||
"""
|
|
||||||
Perform a M-SEARCH (HTTP over UDP) and gather the results
|
|
||||||
|
|
||||||
:param address: (str) address to listen for responses from
|
|
||||||
:param timeout: (int) timeout for the query
|
|
||||||
:param max_devices: (int) block until timeout or at least this many devices are found
|
|
||||||
:param service_types: (list) M-SEARCH "ST" arguments to try, if None use the defaults
|
|
||||||
:return: (list) [ (dict) {
|
|
||||||
'server: (str) gateway os and version
|
|
||||||
'location': (str) upnp gateway url,
|
|
||||||
'cache-control': (str) max age,
|
|
||||||
'date': (int) server time,
|
|
||||||
'usn': (str) usn
|
|
||||||
}, ...]
|
|
||||||
"""
|
|
||||||
self.connect()
|
|
||||||
server_infos = yield self.protocol.m_search(address, timeout, max_devices)
|
|
||||||
for server_info in server_infos:
|
|
||||||
self.server_infos.append(server_info)
|
|
||||||
defer.returnValue(server_infos)
|
|
||||||
|
|
||||||
def get_ssdp_packet_replay(self) -> dict:
|
|
||||||
return {
|
|
||||||
'lan_address': self.lan_address,
|
|
||||||
'router_address': self.router_address,
|
|
||||||
'sent': self.debug_sent,
|
|
||||||
'received': self.debug_received,
|
|
||||||
}
|
|
146
txupnp/upnp.py
146
txupnp/upnp.py
|
@ -1,146 +0,0 @@
|
||||||
import netifaces
|
|
||||||
import logging
|
|
||||||
from twisted.internet import defer
|
|
||||||
from txupnp.fault import UPnPError
|
|
||||||
from txupnp.ssdp import SSDPFactory
|
|
||||||
from txupnp.gateway import Gateway
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UPnP:
|
|
||||||
def __init__(self, reactor, try_miniupnpc_fallback=False, debug_ssdp=False, router_ip=None,
|
|
||||||
lan_ip=None, iface_name=None):
|
|
||||||
self._reactor = reactor
|
|
||||||
if router_ip and lan_ip and iface_name:
|
|
||||||
self.router_ip, self.lan_address, self.iface_name = router_ip, lan_ip, iface_name
|
|
||||||
else:
|
|
||||||
self.router_ip, self.iface_name = netifaces.gateways()['default'][netifaces.AF_INET]
|
|
||||||
self.lan_address = netifaces.ifaddresses(self.iface_name)[netifaces.AF_INET][0]['addr']
|
|
||||||
self.sspd_factory = SSDPFactory(self._reactor, self.lan_address, self.router_ip, debug_packets=debug_ssdp)
|
|
||||||
self.try_miniupnpc_fallback = try_miniupnpc_fallback
|
|
||||||
self.miniupnpc_runner = None
|
|
||||||
self.miniupnpc_igd_url = None
|
|
||||||
self.gateway = None
|
|
||||||
|
|
||||||
def m_search(self, address, timeout=1, max_devices=1):
|
|
||||||
"""
|
|
||||||
Perform a HTTP over UDP M-SEARCH query
|
|
||||||
|
|
||||||
returns (list) [{
|
|
||||||
'server: <gateway os and version string>
|
|
||||||
'location': <upnp gateway url>,
|
|
||||||
'cache-control': <max age>,
|
|
||||||
'date': <server time>,
|
|
||||||
'usn': <usn>
|
|
||||||
}, ...]
|
|
||||||
"""
|
|
||||||
return self.sspd_factory.m_search(address, timeout=timeout, max_devices=max_devices)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def _discover(self, timeout=1, max_devices=1):
|
|
||||||
server_infos = yield self.sspd_factory.m_search(
|
|
||||||
self.router_ip, timeout=timeout, max_devices=max_devices
|
|
||||||
)
|
|
||||||
if not server_infos:
|
|
||||||
return False
|
|
||||||
server_info = server_infos[0]
|
|
||||||
if 'st' in server_info:
|
|
||||||
gateway = Gateway(reactor=self._reactor, **server_info)
|
|
||||||
yield gateway.discover_commands()
|
|
||||||
self.gateway = gateway
|
|
||||||
return True
|
|
||||||
elif 'st' not in server_info:
|
|
||||||
log.error("don't know how to handle gateway: %s", server_info)
|
|
||||||
return False
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def discover(self, timeout=1, max_devices=1):
|
|
||||||
try:
|
|
||||||
found = yield self._discover(timeout=timeout, max_devices=max_devices)
|
|
||||||
except defer.TimeoutError:
|
|
||||||
found = False
|
|
||||||
finally:
|
|
||||||
self.sspd_factory.disconnect()
|
|
||||||
if found:
|
|
||||||
log.debug("found upnp device")
|
|
||||||
else:
|
|
||||||
log.debug("failed to find upnp device")
|
|
||||||
return found
|
|
||||||
|
|
||||||
def get_external_ip(self) -> str:
|
|
||||||
return self.gateway.commands.GetExternalIPAddress()
|
|
||||||
|
|
||||||
def add_port_mapping(self, external_port: int, protocol: str, internal_port, lan_address: str,
|
|
||||||
description: str) -> None:
|
|
||||||
return self.gateway.commands.AddPortMapping(
|
|
||||||
NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol,
|
|
||||||
NewInternalPort=internal_port, NewInternalClient=lan_address,
|
|
||||||
NewEnabled=1, NewPortMappingDescription=description, NewLeaseDuration=""
|
|
||||||
)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def get_port_mapping_by_index(self, index: int) -> (str, int, str, int, str, bool, str, int):
|
|
||||||
try:
|
|
||||||
redirect = yield self.gateway.commands.GetGenericPortMappingEntry(NewPortMappingIndex=index)
|
|
||||||
defer.returnValue(redirect)
|
|
||||||
except UPnPError:
|
|
||||||
defer.returnValue(None)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def get_redirects(self):
|
|
||||||
redirects = []
|
|
||||||
cnt = 0
|
|
||||||
redirect = yield self.get_port_mapping_by_index(cnt)
|
|
||||||
while redirect:
|
|
||||||
redirects.append(redirect)
|
|
||||||
cnt += 1
|
|
||||||
redirect = yield self.get_port_mapping_by_index(cnt)
|
|
||||||
defer.returnValue(redirects)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def get_specific_port_mapping(self, external_port: int, protocol: str) -> (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>
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = yield self.gateway.commands.GetSpecificPortMappingEntry(
|
|
||||||
NewRemoteHost=None, NewExternalPort=external_port, NewProtocol=protocol
|
|
||||||
)
|
|
||||||
defer.returnValue(result)
|
|
||||||
except UPnPError:
|
|
||||||
defer.returnValue(None)
|
|
||||||
|
|
||||||
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 self.gateway.commands.DeletePortMapping(
|
|
||||||
NewRemoteHost="", NewExternalPort=external_port, NewProtocol=protocol
|
|
||||||
)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def get_next_mapping(self, port, protocol, description, internal_port=None):
|
|
||||||
if protocol not in ["UDP", "TCP"]:
|
|
||||||
raise UPnPError("unsupported protocol: {}".format(protocol))
|
|
||||||
internal_port = internal_port or port
|
|
||||||
redirect_tups = yield self.get_redirects()
|
|
||||||
redirects = {
|
|
||||||
"%i:%s" % (ext_port, proto): (int_host, int_port, desc)
|
|
||||||
for (ext_host, ext_port, proto, int_port, int_host, enabled, desc, lease) in redirect_tups
|
|
||||||
}
|
|
||||||
while ("%i:%s" % (port, protocol)) in redirects:
|
|
||||||
int_host, int_port, _ = redirects["%i:%s" % (port, protocol)]
|
|
||||||
if int_host == self.lan_address and int_port == internal_port:
|
|
||||||
break
|
|
||||||
port += 1
|
|
||||||
|
|
||||||
yield self.add_port_mapping( # set one up
|
|
||||||
port, protocol, internal_port, self.lan_address, description
|
|
||||||
)
|
|
||||||
defer.returnValue(port)
|
|
Loading…
Reference in a new issue