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 "" % (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 "" % (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 "" % (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