import re
|
|
import datetime
|
|
from decimal import Decimal
|
|
from base64 import b64decode
|
|
from binascii import unhexlify
|
|
from functools import partial
|
|
from collections import OrderedDict
|
|
|
|
import six
|
|
import requests
|
|
from requests.compat import urljoin, urlparse
|
|
from dateutil.parser import parse as parse_date
|
|
from lxml import etree
|
|
|
|
from .util import _getLogger
|
|
from .const import HTTP_TIMEOUT
|
|
from .soap import SOAP
|
|
from .marshal import marshal_value
|
|
|
|
|
|
class UPNPError(Exception):
|
|
"""
|
|
Exception class for UPnP errors.
|
|
"""
|
|
pass
|
|
|
|
|
|
class InvalidActionException(UPNPError):
|
|
"""
|
|
Action doesn't exist.
|
|
"""
|
|
pass
|
|
|
|
|
|
class ValidationError(UPNPError):
|
|
"""
|
|
Given value didn't validate with the given data type.
|
|
"""
|
|
def __init__(self, reasons):
|
|
super(ValidationError, self).__init__()
|
|
self.reasons = reasons
|
|
|
|
|
|
class UnexpectedResponse(UPNPError):
|
|
"""
|
|
Got a response we didn't expect.
|
|
"""
|
|
pass
|
|
|
|
|
|
class CallActionMixin(object):
|
|
def __call__(self, action_name, **kwargs):
|
|
"""
|
|
Convenience method for quickly finding and calling an Action on a
|
|
Service. Must have implemented a `find_action(action_name)` method.
|
|
"""
|
|
action = self.find_action(action_name)
|
|
if action is not None:
|
|
return action(**kwargs)
|
|
raise InvalidActionException('Action with name %r does not exist.' % action_name)
|
|
|
|
|
|
class Device(CallActionMixin):
|
|
"""
|
|
UPNP Device represention.
|
|
This class represents an UPnP device. `location` is an URL to a control XML
|
|
file, per UPnP standard section 2.3 ('Device Description'). This MUST match
|
|
the URL as given in the 'Location' header when using discovery (SSDP).
|
|
`device_name` is a name for the device, which may be obtained using the
|
|
SSDP class or may be made up by the caller.
|
|
|
|
Raises urllib2.HTTPError when the location is invalid
|
|
|
|
Example:
|
|
|
|
>>> device = Device('http://192.168.1.254:80/upnp/IGD.xml')
|
|
>>> for service in device.services:
|
|
... print service.service_id
|
|
...
|
|
urn:upnp-org:serviceId:layer3f
|
|
urn:upnp-org:serviceId:wancic
|
|
urn:upnp-org:serviceId:wandsllc:pvc_Internet
|
|
urn:upnp-org:serviceId:wanipc:Internet
|
|
"""
|
|
def __init__(
|
|
self, location, device_name=None, ignore_urlbase=False,
|
|
http_auth=None, http_headers=None):
|
|
"""
|
|
Create a new Device instance. `location` is an URL to an XML file
|
|
describing the server's services.
|
|
"""
|
|
self.location = location
|
|
self.device_name = location if device_name is None else device_name
|
|
self.services = []
|
|
self.service_map = {}
|
|
self._log = _getLogger('Device')
|
|
|
|
self.http_auth = http_auth
|
|
self.http_headers = http_headers
|
|
|
|
resp = requests.get(
|
|
location,
|
|
timeout=HTTP_TIMEOUT,
|
|
auth=self.http_auth,
|
|
headers=self.http_headers
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
root = etree.fromstring(resp.content)
|
|
findtext = partial(root.findtext, namespaces=root.nsmap)
|
|
|
|
self.device_type = findtext('device/deviceType')
|
|
self.friendly_name = findtext('device/friendlyName')
|
|
self.manufacturer = findtext('device/manufacturer')
|
|
self.manufacturer_url = findtext('device/manufacturerURL')
|
|
self.model_description = findtext('device/modelDescription')
|
|
self.model_name = findtext('device/modelName')
|
|
self.model_number = findtext('device/modelNumber')
|
|
self.serial_number = findtext('device/serialNumber')
|
|
self.udn = findtext('device/UDN')
|
|
|
|
self._url_base = findtext('URLBase')
|
|
if self._url_base is None or ignore_urlbase:
|
|
# If no URL Base is given, the UPnP specification says: "the base
|
|
# URL is the URL from which the device description was retrieved"
|
|
self._url_base = self.location
|
|
self._root_xml = root
|
|
self._findtext = findtext
|
|
self._find = partial(root.find, namespaces=root.nsmap)
|
|
self._findall = partial(root.findall, namespaces=root.nsmap)
|
|
self._read_services()
|
|
|
|
def __repr__(self):
|
|
return "<Device '%s'>" % (self.friendly_name)
|
|
|
|
def __getattr__(self, name):
|
|
"""
|
|
Allow Services to be returned as members of the Device.
|
|
"""
|
|
try:
|
|
return self.service_map[name]
|
|
except KeyError:
|
|
raise AttributeError('No attribute or service found with name %r.' % name)
|
|
|
|
def __getitem__(self, key):
|
|
"""
|
|
Allow Services to be returned as dictionary keys of the Device.
|
|
"""
|
|
return self.service_map[key]
|
|
|
|
def __dir__(self):
|
|
"""
|
|
Add Service names to `dir(device)` output for use with tab-completion in repl.
|
|
"""
|
|
return super(Device, self).__dir__() + list(self.service_map.keys())
|
|
|
|
@property
|
|
def actions(self):
|
|
actions = []
|
|
for service in self.services:
|
|
actions.extend(service.actions)
|
|
return actions
|
|
|
|
def _read_services(self):
|
|
"""
|
|
Read the control XML file and populate self.services with a list of
|
|
services in the form of Service class instances.
|
|
"""
|
|
# The double slash in the XPath is deliberate, as services can be
|
|
# listed in two places (Section 2.3 of uPNP device architecture v1.1)
|
|
for node in self._findall('device//serviceList/service'):
|
|
findtext = partial(node.findtext, namespaces=self._root_xml.nsmap)
|
|
svc = Service(
|
|
self,
|
|
self._url_base,
|
|
findtext('serviceType'),
|
|
findtext('serviceId'),
|
|
findtext('controlURL'),
|
|
findtext('SCPDURL'),
|
|
findtext('eventSubURL')
|
|
)
|
|
self._log.debug(
|
|
'%s: Service %r at %r', self.device_name, svc.service_type, svc.scpd_url)
|
|
self.services.append(svc)
|
|
self.service_map[svc.name] = svc
|
|
|
|
def find_action(self, action_name):
|
|
"""Find an action by name.
|
|
Convenience method that searches through all the services offered by
|
|
the Server for an action and returns an Action instance. If the action
|
|
is not found, returns None. If multiple actions with the same name are
|
|
found it returns the first one.
|
|
"""
|
|
for service in self.services:
|
|
action = service.find_action(action_name)
|
|
if action is not None:
|
|
return action
|
|
|
|
|
|
class Service(CallActionMixin):
|
|
"""
|
|
Service Control Point Definition. This class reads an SCPD XML file and
|
|
parses the actions and state variables. It can then be used to call
|
|
actions.
|
|
"""
|
|
def __init__(self, device, url_base, service_type, service_id,
|
|
control_url, scpd_url, event_sub_url):
|
|
self.device = device
|
|
self._url_base = url_base
|
|
self.service_type = service_type
|
|
self.service_id = service_id
|
|
self._control_url = control_url
|
|
self.scpd_url = scpd_url
|
|
self._event_sub_url = event_sub_url
|
|
|
|
self.actions = []
|
|
self.action_map = {}
|
|
self.statevars = {}
|
|
self._log = _getLogger('Service')
|
|
|
|
self._log.debug('%s url_base: %s', self.service_id, self._url_base)
|
|
self._log.debug('%s SCPDURL: %s', self.service_id, self.scpd_url)
|
|
self._log.debug('%s controlURL: %s', self.service_id, self._control_url)
|
|
self._log.debug('%s eventSubURL: %s', self.service_id, self._event_sub_url)
|
|
|
|
url = urljoin(self._url_base, self.scpd_url)
|
|
self._log.debug('Reading %s', url)
|
|
resp = requests.get(
|
|
url,
|
|
timeout=HTTP_TIMEOUT,
|
|
auth=self.device.http_auth,
|
|
headers=self.device.http_headers
|
|
)
|
|
resp.raise_for_status()
|
|
self.scpd_xml = etree.fromstring(resp.content)
|
|
self._find = partial(self.scpd_xml.find, namespaces=self.scpd_xml.nsmap)
|
|
self._findtext = partial(self.scpd_xml.findtext, namespaces=self.scpd_xml.nsmap)
|
|
self._findall = partial(self.scpd_xml.findall, namespaces=self.scpd_xml.nsmap)
|
|
|
|
self._read_state_vars()
|
|
self._read_actions()
|
|
|
|
def __repr__(self):
|
|
return "<Service service_id='%s'>" % (self.service_id)
|
|
|
|
def __getattr__(self, name):
|
|
"""
|
|
Allow Actions to be returned as members of the Service.
|
|
"""
|
|
try:
|
|
return self.action_map[name]
|
|
except KeyError:
|
|
raise AttributeError('No attribute or action found with name %r.' % name)
|
|
|
|
def __getitem__(self, key):
|
|
"""
|
|
Allow Actions to be returned as dictionary keys of the Service.
|
|
"""
|
|
return self.action_map[key]
|
|
|
|
def __dir__(self):
|
|
"""
|
|
Add Action names to `dir(service)` output for use with tab-completion in repl.
|
|
"""
|
|
return super(Service, self).__dir__() + [a.name for a in self.actions]
|
|
|
|
@property
|
|
def name(self):
|
|
try:
|
|
return self.service_id[self.service_id.rindex(":")+1:]
|
|
except ValueError:
|
|
return self.service_id
|
|
|
|
def _read_state_vars(self):
|
|
for statevar_node in self._findall('serviceStateTable/stateVariable'):
|
|
findtext = partial(statevar_node.findtext, namespaces=statevar_node.nsmap)
|
|
findall = partial(statevar_node.findall, namespaces=statevar_node.nsmap)
|
|
name = findtext('name')
|
|
datatype = findtext('dataType')
|
|
send_events = statevar_node.attrib.get('sendEvents', 'yes').lower() == 'yes'
|
|
allowed_values = set([e.text for e in findall('allowedValueList/allowedValue')])
|
|
self.statevars[name] = dict(
|
|
name=name,
|
|
datatype=datatype,
|
|
allowed_values=allowed_values,
|
|
send_events=send_events
|
|
)
|
|
|
|
def _read_actions(self):
|
|
action_url = urljoin(self._url_base, self._control_url)
|
|
|
|
for action_node in self._findall('actionList/action'):
|
|
name = action_node.findtext('name', namespaces=action_node.nsmap)
|
|
argsdef_in = []
|
|
argsdef_out = []
|
|
for arg_node in action_node.findall(
|
|
'argumentList/argument', namespaces=action_node.nsmap):
|
|
findtext = partial(arg_node.findtext, namespaces=arg_node.nsmap)
|
|
arg_name = findtext('name')
|
|
arg_statevar = self.statevars[findtext('relatedStateVariable')]
|
|
if findtext('direction').lower() == 'in':
|
|
argsdef_in.append((arg_name, arg_statevar))
|
|
else:
|
|
argsdef_out.append((arg_name, arg_statevar))
|
|
action = Action(self, action_url, self.service_type, name, argsdef_in, argsdef_out)
|
|
self.action_map[name] = action
|
|
self.actions.append(action)
|
|
|
|
@staticmethod
|
|
def validate_subscription_response(resp):
|
|
lc_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
try:
|
|
sid = lc_headers['sid']
|
|
except KeyError:
|
|
raise UnexpectedResponse('Event subscription call returned without a "SID" header')
|
|
try:
|
|
timeout_str = lc_headers['timeout'].lower()
|
|
except KeyError:
|
|
raise UnexpectedResponse('Event subscription call returned without a "Timeout" header')
|
|
if not timeout_str.startswith('second-'):
|
|
raise UnexpectedResponse(
|
|
'Event subscription call returned an invalid timeout value: %r' % timeout_str)
|
|
timeout_str = timeout_str[len('Second-'):]
|
|
try:
|
|
timeout = None if timeout_str == 'infinite' else int(timeout_str)
|
|
except ValueError:
|
|
raise UnexpectedResponse(
|
|
'Event subscription call returned a timeout value which wasn\'t "infinite" or an in'
|
|
'teger')
|
|
return sid, timeout
|
|
|
|
@staticmethod
|
|
def validate_subscription_renewal_response(resp):
|
|
lc_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
try:
|
|
timeout_str = lc_headers['timeout'].lower()
|
|
except KeyError:
|
|
raise UnexpectedResponse('Event subscription call returned without a "Timeout" header')
|
|
if not timeout_str.startswith('second-'):
|
|
raise UnexpectedResponse(
|
|
'Event subscription call returned an invalid timeout value: %r' % timeout_str)
|
|
timeout_str = timeout_str[len('Second-'):]
|
|
try:
|
|
timeout = None if timeout_str == 'infinite' else int(timeout_str)
|
|
except ValueError:
|
|
raise UnexpectedResponse(
|
|
'Event subscription call returned a timeout value which wasn\'t "infinite" or an in'
|
|
'teger')
|
|
return timeout
|
|
|
|
def find_action(self, action_name):
|
|
try:
|
|
return self.action_map[action_name]
|
|
except KeyError:
|
|
pass
|
|
|
|
def subscribe(self, callback_url, timeout=None):
|
|
"""
|
|
Set up a subscription to the events offered by this service.
|
|
"""
|
|
url = urljoin(self._url_base, self._event_sub_url)
|
|
headers = dict(
|
|
HOST=urlparse(url).netloc,
|
|
CALLBACK='<%s>' % callback_url,
|
|
NT='upnp:event'
|
|
)
|
|
if timeout is not None:
|
|
headers['TIMEOUT'] = 'Second-%s' % timeout
|
|
resp = requests.request('SUBSCRIBE', url, headers=headers, auth=self.device.http_auth)
|
|
resp.raise_for_status()
|
|
return Service.validate_subscription_response(resp)
|
|
|
|
def renew_subscription(self, sid, timeout=None):
|
|
"""
|
|
Renews a previously configured subscription.
|
|
"""
|
|
url = urljoin(self._url_base, self._event_sub_url)
|
|
headers = dict(
|
|
HOST=urlparse(url).netloc,
|
|
SID=sid
|
|
)
|
|
if timeout is not None:
|
|
headers['TIMEOUT'] = 'Second-%s' % timeout
|
|
resp = requests.request('SUBSCRIBE', url, headers=headers, auth=self.device.http_auth)
|
|
resp.raise_for_status()
|
|
return Service.validate_subscription_renewal_response(resp)
|
|
|
|
def cancel_subscription(self, sid):
|
|
"""
|
|
Unsubscribes from a previously configured subscription.
|
|
"""
|
|
url = urljoin(self._url_base, self._event_sub_url)
|
|
headers = dict(
|
|
HOST=urlparse(url).netloc,
|
|
SID=sid
|
|
)
|
|
resp = requests.request('UNSUBSCRIBE', url, headers=headers, auth=self.device.http_auth)
|
|
resp.raise_for_status()
|
|
|
|
|
|
class Action(object):
|
|
def __init__(self, service, url, service_type, name, argsdef_in=None, argsdef_out=None):
|
|
if argsdef_in is None:
|
|
argsdef_in = []
|
|
if argsdef_out is None:
|
|
argsdef_out = []
|
|
self.service = service
|
|
self.url = url
|
|
self.service_type = service_type
|
|
self.name = name
|
|
self.argsdef_in = argsdef_in
|
|
self.argsdef_out = argsdef_out
|
|
self._log = _getLogger('Action')
|
|
|
|
def __repr__(self):
|
|
return "<Action '%s'>" % (self.name)
|
|
|
|
def __call__(self, http_auth=None, http_headers=None, **kwargs):
|
|
arg_reasons = {}
|
|
call_kwargs = OrderedDict()
|
|
|
|
# Validate arguments using the SCPD stateVariable definitions
|
|
for name, statevar in self.argsdef_in:
|
|
if name not in kwargs:
|
|
raise UPNPError('Missing required param \'%s\'' % (name))
|
|
valid, reasons = self.validate_arg(kwargs[name], statevar)
|
|
if not valid:
|
|
arg_reasons[name] = reasons
|
|
# Preserve the order of call args, as listed in SCPD XML spec
|
|
call_kwargs[name] = kwargs[name]
|
|
|
|
if arg_reasons:
|
|
raise ValidationError(arg_reasons)
|
|
|
|
# Make the actual call
|
|
self._log.debug(">> %s (%s)", self.name, call_kwargs)
|
|
soap_client = SOAP(self.url, self.service_type)
|
|
|
|
soap_response = soap_client.call(
|
|
self.name,
|
|
call_kwargs,
|
|
http_auth or self.service.device.http_auth,
|
|
http_headers or self.service.device.http_headers
|
|
)
|
|
self._log.debug("<< %s (%s): %s", self.name, call_kwargs, soap_response)
|
|
|
|
# Marshall the response to python data types
|
|
out = {}
|
|
for name, statevar in self.argsdef_out:
|
|
_, value = marshal_value(statevar['datatype'], soap_response[name])
|
|
out[name] = value
|
|
|
|
return out
|
|
|
|
@staticmethod
|
|
def validate_arg(arg, argdef):
|
|
"""
|
|
Validate an incoming (unicode) string argument according the UPnP spec. Raises UPNPError.
|
|
"""
|
|
datatype = argdef['datatype']
|
|
reasons = set()
|
|
ranges = {
|
|
'ui1': (int, 0, 255),
|
|
'ui2': (int, 0, 65535),
|
|
'ui4': (int, 0, 4294967295),
|
|
'i1': (int, -128, 127),
|
|
'i2': (int, -32768, 32767),
|
|
'i4': (int, -2147483648, 2147483647),
|
|
'r4': (Decimal, Decimal('3.40282347E+38'), Decimal('1.17549435E-38'))
|
|
}
|
|
try:
|
|
if datatype in set(ranges.keys()):
|
|
v_type, v_min, v_max = ranges[datatype]
|
|
if not v_min <= v_type(arg) <= v_max:
|
|
reasons.add('%r datatype must be a number in the range %s to %s' % (
|
|
datatype, v_min, v_max))
|
|
|
|
elif datatype in {'r8', 'number', 'float', 'fixed.14.4'}:
|
|
v = Decimal(arg)
|
|
if v < 0:
|
|
assert Decimal('-1.79769313486232E308') <= v <= Decimal('4.94065645841247E-324')
|
|
else:
|
|
assert Decimal('4.94065645841247E-324') <= v <= Decimal('1.79769313486232E308')
|
|
|
|
elif datatype == 'char':
|
|
v = arg.decode('utf8') if six.PY2 or isinstance(arg, bytes) else arg
|
|
assert len(v) == 1
|
|
|
|
elif datatype == 'string':
|
|
v = arg.decode("utf8") if six.PY2 or isinstance(arg, bytes) else arg
|
|
if argdef['allowed_values'] and v not in argdef['allowed_values']:
|
|
reasons.add('Value %r not in allowed values list' % arg)
|
|
|
|
elif datatype == 'date':
|
|
v = parse_date(arg)
|
|
if any((v.hour, v.minute, v.second)):
|
|
reasons.add("'date' datatype must not contain a time")
|
|
|
|
elif datatype in ('dateTime', 'dateTime.tz'):
|
|
v = parse_date(arg)
|
|
if datatype == 'dateTime' and v.tzinfo is not None:
|
|
reasons.add("'dateTime' datatype must not contain a timezone")
|
|
|
|
elif datatype in ('time', 'time.tz'):
|
|
now = datetime.datetime.utcnow()
|
|
v = parse_date(arg, default=now)
|
|
if v.tzinfo is not None:
|
|
now += v.utcoffset()
|
|
if not all((
|
|
v.day == now.day,
|
|
v.month == now.month,
|
|
v.year == now.year)):
|
|
reasons.add('%r datatype must not contain a date' % datatype)
|
|
if datatype == 'time' and v.tzinfo is not None:
|
|
reasons.add('%r datatype must not have timezone information' % datatype)
|
|
|
|
elif datatype == 'boolean':
|
|
valid = {'true', 'yes', '1', 'false', 'no', '0'}
|
|
if arg.lower() not in valid:
|
|
reasons.add('%r datatype must be one of %s' % (datatype, ','.join(valid)))
|
|
|
|
elif datatype == 'bin.base64':
|
|
b64decode(arg)
|
|
|
|
elif datatype == 'bin.hex':
|
|
unhexlify(arg)
|
|
|
|
elif datatype == 'uri':
|
|
urlparse(arg)
|
|
|
|
elif datatype == 'uuid':
|
|
if not re.match(
|
|
r'^[0-9a-f]{8}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{4}\-[0-9a-f]{12}$',
|
|
arg, re.I):
|
|
reasons.add('%r datatype must contain a valid UUID')
|
|
|
|
else:
|
|
reasons.add("%r datatype is unrecognised." % datatype)
|
|
|
|
except ValueError as exc:
|
|
reasons.add(str(exc))
|
|
|
|
return not bool(len(reasons)), reasons
|