├── .gitignore ├── doc └── upnp-arch-devicearchitecture-v1.0.pdf ├── examples ├── run.sh ├── server.py ├── discover.py ├── README.md ├── action_call.py └── upnp_dump.py ├── README.md ├── LICENSE └── src └── upnpclient.py /.gitignore: -------------------------------------------------------------------------------- 1 | src/*.pyc 2 | -------------------------------------------------------------------------------- /doc/upnp-arch-devicearchitecture-v1.0.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fboender/pyupnpclient/HEAD/doc/upnp-arch-devicearchitecture-v1.0.pdf -------------------------------------------------------------------------------- /examples/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | usage () { 4 | echo "Usage: $0 " >&2 5 | exit 1 6 | } 7 | 8 | [ $# -lt 1 ] && usage 9 | 10 | export PYTHONPATH=../src/ 11 | 12 | python $* 13 | -------------------------------------------------------------------------------- /examples/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Direct UPnP device connect without device discovery. 4 | # 5 | 6 | import upnpclient 7 | 8 | server = upnpclient.Server('http://192.168.1.254:80/upnp/IGD.xml') 9 | print server.friendly_name 10 | 11 | for services in server: 12 | print service 13 | -------------------------------------------------------------------------------- /examples/discover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Demonstrate a simple UPnP device discovery. 4 | # 5 | 6 | import upnpclient 7 | 8 | # De 9 | ssdp = upnpclient.SSDP(wait_time=5) 10 | servers = ssdp.discover() 11 | 12 | for server in servers: 13 | print server.friendly_name, '@', server.location 14 | for service in server.services: 15 | print " ", service.service_id, service.service_type 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pyUPNPclient 2 | ============ 3 | 4 | Example UPNP client written in Python. 5 | 6 | **NOT FOR PRODUCTION USE**: Check [flyte/upnpclient](https://github.com/flyte/upnpclient) for a 7 | more mature fork of this project. 8 | 9 | This code was written for 10 | [this blog post](https://www.electricmonk.nl/log/2016/07/05/exploring-upnp-with-python/). 11 | 12 | See `examples/` for examples on how to use it. You can run the examples from 13 | the Github repository: 14 | 15 | $ cd examples 16 | $ ./run.sh discover.py 17 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | These are examples of how to use upnpclient. Since these examples are dependant 2 | on actual physical devices on your network, they will probably not work for 3 | you. 4 | 5 | Examples can be run with the `run.sh` file: 6 | 7 | $ ./run.sh discover.py 8 | 9 | The following examples are included: 10 | 11 | * `discover.py` 12 | 13 | Discover UPnP devices on the network and print some info. 14 | 15 | * `server.py` 16 | 17 | Directly connect to an UPnP device without discovery. 18 | 19 | * `action_call.py` 20 | 21 | Find and call an action on an UPnP device. 22 | 23 | * `upnp_dump.py` 24 | 25 | Discover all UPnP devices and list all their actions including parameters, etc. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2016, Ferry Boender 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /examples/action_call.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Show how to actually perform UPnP calls. 4 | # 5 | 6 | import upnpclient 7 | 8 | # Get a upnpclient.Server class instance for the UPnP service at our local 9 | # router. 10 | server = upnpclient.Server('http://192.168.1.1:37215/upnpdev.xml') 11 | 12 | # Find the 'GetGenericPortMappingEntry' action on the server, regardless of the 13 | # (virtual) device/service/etc it's on. You can use the find_action call on 14 | # most levels of the upnpclient stack.In essence you can use find_action on all 15 | # instances. 16 | 17 | action = server.find_action('GetGenericPortMappingEntry') 18 | response = action.call(NewPortMappingIndex=0) 19 | print response 20 | # Output: {u'NewPortMappingDescription': u'Transmission at 6881', 21 | # u'NewLeaseDuration': 0, u'NewInternalClient': u'192.168.1.10', 22 | # u'NewEnabled': True, u'NewExternalPort': 6881, u'NewRemoteHost': '', 23 | # u'NewProtocol': u'UDP', u'NewInternalPort': 6881} 24 | 25 | # It's also possible to pass in a dictionary with the required parameters 26 | action = server.find_action('GetGenericPortMappingEntry') 27 | response = action.call({'NewPortMappingIndex': 0}) 28 | print response 29 | 30 | # If we don't pass a required parameter, a UPNPError will be thrown 31 | try: 32 | response = action.call() 33 | except upnpclient.UPNPError, e: 34 | print str(e) 35 | -------------------------------------------------------------------------------- /examples/upnp_dump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Dump all the methods on every UPnP server found. 4 | # 5 | 6 | import upnpclient 7 | 8 | # Create an SSDP (Simple Service Discovery Protocol) client. This is the first 9 | # step in discovering UPnP devices on the network. 10 | ssdp = upnpclient.SSDP(wait_time=5) 11 | 12 | # Do a device discovery on the network by broadcasting an HTTPU M-SEARCH using 13 | # UDP over the network. We wait for devices to reply for 5 seconds. 14 | servers = ssdp.discover() 15 | 16 | # The discovery phase has provided us with a list of upnpclient.Server class 17 | # instances. We'll walk through them, print some information on them and list 18 | # their services, actions and the arguments for those actions. 19 | if not servers: 20 | print "No UPnP servers discovered on your network. Maybe try turning on" 21 | print "UPnP on one of your devices?" 22 | else: 23 | for server in servers: 24 | print "%s: %s (%s)" % (server.friendly_name, server.model_description, server.location) 25 | for service in server.services: 26 | print " %s" % (service.service_type) 27 | for action in service.actions: 28 | print " %s" % (action.name) 29 | for arg_name, arg_def in action.argsdef_in: 30 | valid = ', '.join(arg_def['allowed_values']) or '*' 31 | print " in: %s (%s): %s" % (arg_name, arg_def['datatype'], valid) 32 | for arg_name, arg_def in action.argsdef_out: 33 | valid = ', '.join(arg_def['allowed_values']) or '*' 34 | print " out: %s (%s): %s" % (arg_name, arg_def['datatype'], valid) 35 | 36 | -------------------------------------------------------------------------------- /src/upnpclient.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012-2016, Ferry Boender 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | 22 | # Todo: 23 | # - Allow persistance of discovered servers. 24 | # - The control point should wait at least the amount of time specified in the 25 | # MX header for responses to arrive from devices. 26 | # - Date/datetime 27 | # - Store all properties 28 | # - SSDP.discover(st): Allow to discover only certain service types 29 | # - .find() method on most classes. 30 | # - async discover (if possible). 31 | # - Read parameter types and verify them when doing a call. 32 | # - Marshall return values to the correct databases. 33 | # - Handle SOAP error: 34 | # 35 | # 36 | # 37 | # 38 | # s:Client 39 | # UPnPError 40 | # 41 | # 42 | # 714 43 | # No such entry in array 44 | # 45 | # 46 | # 47 | # 48 | # 49 | # - Test params and responses with XML entities in them "<", "&", etc. 50 | # - AllowedValueRange 51 | # 52 | # minimum value 53 | # maximum value 54 | # increment value 55 | # 56 | # - Name params as 'NewFoo', or not (See spec)? 57 | 58 | """ 59 | This module provides an UPnP Control Point (client), and provides an easy 60 | interface to discover and communicate with UPnP servers. It implements SSDP 61 | (Simple Service Discovery Protocol), SCPD (Simple Control Point Definition) and 62 | a minimal SOAP (Simple Object Access Protocol) implementation. 63 | 64 | The usual flow for working with UPnP servers is: 65 | 66 | - Discover UPnP servers using SSDP. 67 | 68 | SSDP is a simple HTTP-over-UDP protocol. An M-SEARCH HTTP request is broad- 69 | casted over the network and any UPnP servers should respond with an HTTP 70 | response. This response includes an URL to an XML file containing information 71 | about the server. The SSDP.discover() method returns a list of Server 72 | instances. If you already know the URL of the XML file, you can skip this 73 | step and instantiate a Server instance directly. 74 | 75 | - Inspect Server capabilities using SCPD. 76 | 77 | The XML file returned by UPnP servers during discovery is read and information 78 | about the server and the services it offers is stored in a Server instance. The 79 | Server.services property contains a list of Service instances supported by that 80 | server. 81 | 82 | - Inspect Services capabilities using SCPD. 83 | 84 | Each Server may contain more than one Services. For each Service, a separate 85 | XML file exists. The Service class reads that XML file and determines which 86 | actions a service supports. The Service.actions property contains a list of 87 | Action instances supported by that service. 88 | 89 | - Inspect an Action using SCPD. 90 | 91 | An Action instance may be inspected to determine which arguments need to be 92 | passed into it and what it returns. Information on the type and possible 93 | values of each argument can also be queried. 94 | 95 | - Call an Action using SOAP. 96 | 97 | An Action instance may then be called using the Action.call(arguments) method. 98 | The Action class will verify the correctness of arguments, possibly 99 | converting them. A SOAP call is then made to the UPnP server and the results 100 | are returned. 101 | 102 | Classes: 103 | 104 | * SSDP: Discover UPnP servers using the SSDP class. 105 | * Server: Connect to an UPnP server and retrieve information/capabilities using the Server class. 106 | * Service: Query a Server class instance for the various services it supports. 107 | * Action: Query a Service class instance for the various actions it supports and call them. 108 | 109 | Various convenience methods are provided at almost all levels. For instance, 110 | the find_action() methods can directly find a method (by name) in an UPnP 111 | server/service. The call() method can be used at most levels to directly call 112 | an action. 113 | 114 | The following example discovers all UPnP servers on the local network and then 115 | dumps all their services and actions: 116 | 117 | ------------------------------------------------------------------------------ 118 | import upnpclient 119 | 120 | ssdp = upnpclient.SSDP() 121 | servers = ssdp.discover() 122 | 123 | for server in servers: 124 | print "%s: %s" % (server.friendly_name, server.model_description) 125 | for service in server.services: 126 | print " %s" % (service.service_type) 127 | for action in service.actions: 128 | print " %s" % (action.name) 129 | for arg_name, arg_def in action.argsdef_in: 130 | valid = ', '.join(arg_def['allowed_values']) or '*' 131 | print " in: %s (%s): %s" % (arg_name, arg_def['datatype'], valid) 132 | for arg_name, arg_def in action.argsdef_out: 133 | valid = ', '.join(arg_def['allowed_values']) or '*' 134 | print " out: %s (%s): %s" % (arg_name, arg_def['datatype'], valid) 135 | ------------------------------------------------------------------------------ 136 | """ 137 | 138 | import logging 139 | import socket 140 | import struct 141 | import urllib2 142 | import xml.dom.minidom 143 | import sys 144 | from urlparse import urljoin 145 | 146 | def _XMLGetNodeText(node): 147 | """ 148 | Return text contents of an XML node. 149 | """ 150 | text = [] 151 | for childNode in node.childNodes: 152 | if childNode.nodeType == node.TEXT_NODE: 153 | text.append(childNode.data) 154 | return(''.join(text)) 155 | 156 | def _XMLFindNodeText(node, tag_name): 157 | """ 158 | Find the first XML node matching `tag_name` and return its text contents. 159 | If no node is found, return empty string. Use for non-required nodes. 160 | """ 161 | target_nodes = node.getElementsByTagName(tag_name) 162 | try: 163 | return(_XMLGetNodeText(target_nodes[0])) 164 | except IndexError: 165 | return('') 166 | 167 | def _getLogger(name): 168 | """ 169 | Retrieve a logger instance. Checks if a handler is defined so we avoid the 170 | 'No handlers could be found' message. 171 | """ 172 | logger = logging.getLogger(name) 173 | if not logging.root.handlers: 174 | logger.disabled = 1 175 | return(logger) 176 | 177 | class UPNPError(Exception): 178 | """ 179 | Exceptio class for UPnP errors. 180 | """ 181 | pass 182 | 183 | class SSDP(object): 184 | """ 185 | Simple Service Discovery Protocol. The SSDP class allows for discovery of 186 | UPnP devices by broadcasting on the local network. It does so by sending an 187 | HTTP M-SEARCH command over multicast UDP. The `discover()` method does the 188 | actual discovering. It returns a list of `upnp.Server` class instances of 189 | servers that responded. After discovery, these servers can also be accessed 190 | through the `servers` propery. 191 | 192 | Example: 193 | 194 | >>> ssdp = SSDP(1) 195 | >>> servers = ssdp.discover() 196 | >>> print upnpservers 197 | [, ] 198 | """ 199 | def __init__(self, wait_time=2, listen_port=12333): 200 | """ 201 | Create a new SSDP class. `wait_time` determines how long to wait for 202 | responses from servers. `listen_port` determines the UDP port on which 203 | to send/receive replies. 204 | """ 205 | self.listen_port = listen_port 206 | self.wait_time = wait_time 207 | self._log = _getLogger('SSDP') 208 | 209 | def discover_raw(self): 210 | """ 211 | Discover UPnP devices on the network via UDP multicast. Returns a list 212 | of dictionaries, each of which contains the HTTPMU reply headers. 213 | """ 214 | msg = \ 215 | 'M-SEARCH * HTTP/1.1\r\n' \ 216 | 'HOST:239.255.255.250:1900\r\n' \ 217 | 'MAN:"ssdp:discover"\r\n' \ 218 | 'MX:2\r\n' \ 219 | 'ST:upnp:rootdevice\r\n' \ 220 | '\r\n' 221 | 222 | # Send discovery broadcast message 223 | self._log.debug('M-SEARCH broadcast discovery') 224 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 225 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 226 | s.settimeout(self.wait_time) 227 | s.sendto(msg, ('239.255.255.250', 1900) ) 228 | 229 | # Wait for replies 230 | ssdp_replies = [] 231 | servers = [] 232 | try: 233 | while True: 234 | self._log.debug('Waiting for replies...') 235 | data, addr = s.recvfrom(65507) 236 | ssdp_reply_headers = {} 237 | for line in data.splitlines(): 238 | if ':' in line: 239 | key, value = line.split(':', 1) 240 | ssdp_reply_headers[key.strip().lower()] = value.strip() 241 | self._log.info('Response from %s:%i %s' % (addr[0], addr[1], ssdp_reply_headers['server'])) 242 | self._log.info('%s:%i at %s' % (addr[0], addr[1], ssdp_reply_headers['location'])) 243 | if not ssdp_reply_headers in ssdp_replies: 244 | # Prevent multiple responses from showing up multiple 245 | # times. 246 | ssdp_replies.append(ssdp_reply_headers) 247 | except socket.timeout: 248 | pass 249 | 250 | s.close() 251 | return(ssdp_replies) 252 | 253 | def discover(self): 254 | """ 255 | Convenience method to discover UPnP devices on the network. Returns a 256 | list of `upnp.Server` instances. Any invalid servers are silently 257 | ignored. If you do not want this, use the `SSDP.discover_raw` method. 258 | """ 259 | servers = [] 260 | for ssdp_reply in self.discover_raw(): 261 | try: 262 | upnp_server = Server(ssdp_reply['location'], ssdp_reply['server']) 263 | servers.append(upnp_server) 264 | except Exception, e: 265 | self._log.error('Error \'%s\' for %s' % (e, ssdp_reply['server'])) 266 | pass 267 | return(servers) 268 | 269 | class Server(object): 270 | """ 271 | UPNP Server represention. 272 | This class represents an UPnP server. `location` is an URL to a control XML 273 | file, per UPnP standard section 2.1 ('Device Description'). This MUST match 274 | the URL as given in the 'Location' header when using discovery (SSDP). 275 | `server_name` is a name for the server, which may be obtained using the 276 | SSDP class or may be made up by the caller. 277 | 278 | Raises urllib2.HTTPError when the location is invalid 279 | 280 | Example: 281 | 282 | >>> server = Server('http://192.168.1.254:80/upnp/IGD.xml') 283 | >>> for service in server.services: 284 | ... print service.service_id 285 | ... 286 | urn:upnp-org:serviceId:layer3f 287 | urn:upnp-org:serviceId:wancic 288 | urn:upnp-org:serviceId:wandsllc:pvc_Internet 289 | urn:upnp-org:serviceId:wanipc:Internet 290 | """ 291 | def __init__(self, location, server_name=None): 292 | """ 293 | Create a new Server instance. `location` is an URL to an XML file 294 | describing the server's services. 295 | """ 296 | self.location = location 297 | if server_name: 298 | self.server_name = server_name 299 | else: 300 | self.server_name = location 301 | self.services = [] 302 | self._log = _getLogger('SERVER') 303 | 304 | response = urllib2.urlopen(self.location) 305 | self._root_xml = xml.dom.minidom.parseString(response.read()) 306 | self.device_type = _XMLFindNodeText(self._root_xml, 'deviceType') 307 | self.friendly_name = _XMLFindNodeText(self._root_xml, 'friendlyName') 308 | self.manufacturer = _XMLFindNodeText(self._root_xml, 'manufacturer') 309 | self.model_description = _XMLFindNodeText(self._root_xml, 'modelDescription') 310 | self.model_name = _XMLFindNodeText(self._root_xml, 'modelName') 311 | self.model_number = _XMLFindNodeText(self._root_xml, 'modelNumber') 312 | self.serial_number = _XMLFindNodeText(self._root_xml, 'serialNumber') 313 | response.close() 314 | 315 | self._url_base = _XMLFindNodeText(self._root_xml, 'URLBase') 316 | if self._url_base == '': 317 | # If no URL Base is given, the UPnP specification says: "the base 318 | # URL is the URL from which the device description was retrieved" 319 | self._url_base = self.location 320 | self._readServices() 321 | 322 | def _readServices(self): 323 | """ 324 | Read the control XML file and populate self.services with a list of 325 | services in the form of Service class instances. 326 | """ 327 | # Build a flat list of all services offered by the UPNP server 328 | for node in self._root_xml.getElementsByTagName('service'): 329 | service_type = _XMLGetNodeText(node.getElementsByTagName('serviceType')[0]) 330 | service_id = _XMLGetNodeText(node.getElementsByTagName('serviceId')[0]) 331 | control_url = _XMLGetNodeText(node.getElementsByTagName('controlURL')[0]) 332 | scpd_url = _XMLGetNodeText(node.getElementsByTagName('SCPDURL')[0]) 333 | event_sub_url = _XMLGetNodeText(node.getElementsByTagName('eventSubURL')[0]) 334 | self._log.info('%s: Service "%s" at %s' % (self.server_name, service_type, scpd_url)) 335 | self.services.append(Service(self._url_base, service_type, service_id, control_url, scpd_url, event_sub_url)) 336 | 337 | def find_action(self, action_name): 338 | """Find an action by name. 339 | Convenience method that searches through all the services offered by 340 | the Server for an action and returns an Action instance. If the action 341 | is not found, returns None. If multiple actions with the same name are 342 | found it returns the first one. 343 | """ 344 | for service in self.services: 345 | action = service.find_action(action_name) 346 | if action: 347 | return(action) 348 | return(None) 349 | 350 | def call(self, action_name, args={}, **kwargs): 351 | """Directly call an action 352 | Convenience method for quickly finding and calling an Action on a 353 | Server. 354 | """ 355 | args = args.copy() 356 | if kwargs: 357 | # Allow both a dictionary of arguments and normal named arguments 358 | args.update(kwargs) 359 | 360 | action = self.find_action(action_name) 361 | if action: 362 | return(action.call(args)) 363 | return(None) 364 | 365 | def __repr__(self): 366 | return("" % (self.friendly_name)) 367 | 368 | class Service(object): 369 | """ 370 | Service Control Point Definition. This class reads an SCPD XML file and 371 | parses the actions and state variables. It can then be used to call 372 | actions. 373 | """ 374 | # FIXME: marshall call arguments 375 | # FIXME: Check allowed string values 376 | def __init__(self, url_base, service_type, service_id, control_url, scpd_url, event_sub_url): 377 | self._url_base = url_base 378 | self.service_type = service_type 379 | self.service_id = service_id 380 | self._control_url = control_url 381 | self._scpd_url = scpd_url 382 | self._event_sub_url = event_sub_url 383 | 384 | self.actions = [] 385 | self._action_map = {} 386 | self.statevars = {} 387 | self._log = _getLogger('SERVICE') 388 | 389 | self._log.debug('%s url_base: %s' % (self.service_id, self._url_base)) 390 | self._log.debug('%s SCPDURL: %s' % (self.service_id, self._scpd_url)) 391 | self._log.debug('%s controlURL: %s' % (self.service_id, self._control_url)) 392 | self._log.debug('%s eventSubURL: %s' % (self.service_id, self._event_sub_url)) 393 | 394 | # FIXME: http://192.168.1.2:1780/InternetGatewayDevice.xml/x_layer3forwarding.xml 395 | self._log.info('Reading %s' % (urljoin(self._url_base, self._scpd_url))) 396 | response = urllib2.urlopen(urljoin(self._url_base, self._scpd_url)) 397 | self.scpd_xml = xml.dom.minidom.parseString(response.read()) 398 | response.close() 399 | 400 | self._readStateVariables() 401 | self._readActions() 402 | 403 | def _readStateVariables(self): 404 | for statevar_node in self.scpd_xml.getElementsByTagName('stateVariable'): 405 | statevar_name = _XMLGetNodeText(statevar_node.getElementsByTagName('name')[0]) 406 | statevar_datatype = _XMLGetNodeText(statevar_node.getElementsByTagName('dataType')[0]) 407 | statevar_allowed_values = [] 408 | 409 | for allowed_node in statevar_node.getElementsByTagName('allowedValueList'): 410 | for allowed_value_node in allowed_node.getElementsByTagName('allowedValue'): 411 | statevar_allowed_values.append(_XMLGetNodeText(allowed_value_node)) 412 | self.statevars[statevar_name] = { 413 | 'name': statevar_name, 414 | 'datatype': statevar_datatype, 415 | 'allowed_values': statevar_allowed_values, 416 | } 417 | 418 | def _readActions(self): 419 | action_url = urljoin(self._url_base, self._control_url) 420 | for action_node in self.scpd_xml.getElementsByTagName('action'): 421 | name = _XMLGetNodeText(action_node.getElementsByTagName('name')[0]) 422 | argsdef_in = [] 423 | argsdef_out = [] 424 | for arg_node in action_node.getElementsByTagName('argument'): 425 | arg_name = _XMLGetNodeText(arg_node.getElementsByTagName('name')[0]) 426 | arg_dir = _XMLGetNodeText(arg_node.getElementsByTagName('direction')[0]) 427 | arg_statevar = self.statevars[ 428 | _XMLGetNodeText(arg_node.getElementsByTagName('relatedStateVariable')[0]) 429 | ] 430 | if arg_dir == 'in': 431 | argsdef_in.append( (arg_name, arg_statevar) ) 432 | else: 433 | argsdef_out.append( (arg_name, arg_statevar) ) 434 | action = Action(action_url, self.service_type, name, argsdef_in, argsdef_out) 435 | self._action_map[name] = action 436 | self.actions.append(action) 437 | 438 | def find_action(self, action_name): 439 | if action_name in self._action_map: 440 | return(self._action_map[action_name]) 441 | return(None) 442 | 443 | # FIXME: Maybe move this? 444 | @staticmethod 445 | def marshall_from(datatype, value): 446 | dt_conv = { 447 | 'ui1' : lambda x: int(x), 448 | 'ui2' : lambda x: int(x), 449 | 'ui4' : lambda x: int(x), 450 | 'i1' : lambda x: int(x), 451 | 'i2' : lambda x: int(x), 452 | 'i4' : lambda x: int(x), 453 | 'int' : lambda x: int(x), 454 | 'r4' : lambda x: float(x), 455 | 'r8' : lambda x: float(x), 456 | 'number' : lambda x: float(x), 457 | 'fixed' : lambda x: float(x), 458 | 'float' : lambda x: float(x), 459 | 'char' : lambda x: x, 460 | 'string' : lambda x: x, 461 | 'date' : Exception, 462 | 'dateTime' : Exception, 463 | 'dateTime.tz' : Exception, 464 | 'boolean' : lambda x: bool(x), 465 | 'bin.base64' : lambda x: x, 466 | 'bin.hex' : lambda x: x, 467 | 'uri' : lambda x: x, 468 | 'uuid' : lambda x: x, 469 | } 470 | return(dt_conv[datatype](value)) 471 | 472 | def call(self, action_name, args={}, **kwargs): 473 | """Directly call an action 474 | Convenience method for quickly finding and calling an Action on a 475 | Service. 476 | """ 477 | args = args.copy() 478 | if kwargs: 479 | # Allow both a dictionary of arguments and normal named arguments 480 | args.update(kwargs) 481 | 482 | action = self.find_action(action_name) 483 | if action: 484 | return(action.call(args)) 485 | return(None) 486 | 487 | def __repr__(self): 488 | return("" % (self.service_id)) 489 | 490 | class Action(object): 491 | def __init__(self, url, service_type, name, argsdef_in={}, argsdef_out={}): 492 | self.url = url 493 | self.service_type = service_type 494 | self.name = name 495 | self.argsdef_in = argsdef_in 496 | self.argsdef_out = argsdef_out 497 | self._log = _getLogger('ACTION') 498 | 499 | def call(self, args={}, **kwargs): 500 | args = args.copy() 501 | if kwargs: 502 | # Allow both a dictionary of arguments and normal named arguments 503 | args.update(kwargs) 504 | 505 | # Validate arguments using the SCPD stateVariable definitions 506 | for name, statevar in self.argsdef_in: 507 | if not name in args: 508 | raise UPNPError('Missing required param \'%s\'' % (name)) 509 | self._validate_arg(name, args[name], statevar) 510 | 511 | # Make the actual call 512 | soap_client = SOAP(self.url, self.service_type) 513 | soap_response = soap_client.call(self.name, args) 514 | 515 | # Marshall the response to python data types 516 | out = {} 517 | for name, statevar in self.argsdef_out: 518 | out[name] = Service.marshall_from(statevar['datatype'], soap_response[name]) 519 | 520 | return(out) 521 | 522 | def _validate_arg(self, name, arg, argdef): 523 | """ 524 | Validate and convert an incoming (unicode) string argument according 525 | the UPnP spec. Raises UPNPError. 526 | """ 527 | datatype = argdef['datatype'] 528 | try: 529 | if datatype == 'ui1': 530 | v = int(arg); assert v >= 0 and v <= 255 531 | elif datatype == 'ui2': 532 | v = int(arg); assert v >= 0 and v <= 65535 533 | elif datatype == 'ui4' : 534 | v = int(arg); assert v >= 0 and v <= 4294967295 535 | if datatype == 'i1': 536 | v = int(arg); assert v >= -128 and v <= 127 537 | elif datatype == 'i2': 538 | v = int(arg); assert v >= -32768 and v <= 32767 539 | elif datatype in ['i4', 'int']: 540 | v = int(arg); 541 | elif datatype == 'r4': 542 | v = float(arg); assert v >= 1.17549435E-38 and v <= 3.40282347E+38 543 | elif datatype in ['r8', 'number', 'float', 'fixed.14.4'] : 544 | v = float(arg); # r8 is too big for python, so we don't check anything 545 | elif datatype == 'char': 546 | v = arg.decode('utf8'); assert len(v) == 1 547 | elif datatype == 'string': 548 | v = arg.decode('utf8'); 549 | if argdef['allowed_values'] and not v in argdef['allowed_values']: 550 | raise UPNPError('Value \'%s\' not allowed for param \'%s\'' % (arg, name)) 551 | elif datatype == 'date': 552 | v = arg # FIXME 553 | elif datatype == 'dateTime': 554 | v = arg # FIXME 555 | elif datatype == 'dateTime.tz': 556 | v = arg # FIXME 557 | elif datatype == 'time': 558 | v = arg # FIXME 559 | elif datatype == 'time.tz': 560 | v = arg # FIXME 561 | elif datatype == 'boolean': 562 | if arg.lower() in ['true', 'yes']: 563 | v = 1 564 | elif arg.lower() in ['false', 'no']: 565 | v = 0 566 | v = [0, 1][bool(arg)] 567 | elif datatype == 'bin.base64': 568 | v = arg # FIXME 569 | elif datatype == 'bin.hex': 570 | v = arg # FIXME 571 | elif datatype == 'uri': 572 | v = arg # FIXME 573 | elif datatype == 'uuid': 574 | v = arg # FIXME 575 | except Exception: 576 | raise UPNPError("%s should be of type '%s'" % (name, datatype)) 577 | return(v) 578 | 579 | def __repr__(self): 580 | return("" % (self.name)) 581 | 582 | class SOAPError(Exception): 583 | pass 584 | 585 | class SOAP(object): 586 | """SOAP (Simple Object Access Protocol) implementation 587 | This class defines a simple SOAP client. 588 | """ 589 | def __init__(self, url, service_type): 590 | self.url = url 591 | self.service_type = service_type 592 | self._host = self.url.split('//', 1)[1].split('/', 1)[0] # Get hostname portion of url 593 | self._log = _getLogger('SOAP') 594 | 595 | 596 | def call(self, action_name, arg_in={}, debug=False): 597 | arg_values = '\n'.join( ['<%s>%s' % (k, v, k) for k, v in arg_in.items()] ) 598 | body = \ 599 | '\n' \ 600 | '\n' \ 601 | ' \n' \ 602 | ' \n' \ 603 | ' %(arg_values)s\n' \ 604 | ' \n' \ 605 | ' \n' \ 606 | '\n' % { 607 | 'action_name': action_name, 608 | 'service_type': self.service_type, 609 | 'arg_values': arg_values, 610 | } 611 | headers = { 612 | 'SOAPAction': '"%s#%s"' % (self.service_type, action_name), 613 | 'Host': self._host, 614 | 'Content-Type': 'text/xml', 615 | 'Content-Length': len(body), 616 | } 617 | 618 | # Uncomment this for debugging. 619 | # urllib2.install_opener(urllib2.build_opener(urllib2.HTTPHandler(debuglevel=1))) 620 | request = urllib2.Request(self.url, body, headers) 621 | try: 622 | response = urllib2.urlopen(request) 623 | except urllib2.HTTPError, e: 624 | soap_error_xml = xml.dom.minidom.parseString(e.read()) 625 | raise SOAPError( 626 | int(_XMLGetNodeText(soap_error_xml.getElementsByTagName('errorCode')[0])), 627 | _XMLGetNodeText(soap_error_xml.getElementsByTagName('errorDescription')[0]), 628 | ) 629 | 630 | raw_xml = response.read() 631 | contents = xml.dom.minidom.parseString(raw_xml) 632 | response.close() 633 | 634 | params_out = {} 635 | for node in contents.getElementsByTagName('*'): 636 | if node.localName.lower().endswith('response'): 637 | for param_out_node in node.childNodes: 638 | if param_out_node.nodeType == param_out_node.ELEMENT_NODE: 639 | params_out[param_out_node.localName] = _XMLGetNodeText(param_out_node) 640 | 641 | return(params_out) 642 | 643 | if __name__ == '__main__': 644 | import unittest 645 | 646 | #logging.root.setLevel(logging.DEBUG) 647 | #log = logging.basicConfig(level=logging.DEBUG, 648 | # format='%(asctime)s:%(levelname)s:%(name)s:%(message)s') 649 | 650 | class TestUPNP(unittest.TestCase): 651 | def setUp(self): 652 | self.server = Server('http://192.168.1.254:80/upnp/IGD.xml') 653 | 654 | def test_discover(self): 655 | ssdp = SSDP(1) 656 | upnp_servers = ssdp.discover() 657 | 658 | def test_server(self): 659 | server = Server('http://192.168.1.254:80/upnp/IGD.xml') 660 | 661 | def test_server_props(self): 662 | server = Server('http://192.168.1.254:80/upnp/IGD.xml') 663 | self.assertTrue(server.device_type == 'urn:schemas-upnp-org:device:InternetGatewayDevice:1') 664 | self.assertTrue(server.friendly_name == 'SpeedTouch 5x6 (0612BH95K)') 665 | self.assertTrue(server.manufacturer == 'THOMSON') 666 | self.assertTrue(server.model_description == 'DSL Internet Gateway Device') 667 | self.assertTrue(server.model_name == 'SpeedTouch') 668 | self.assertTrue(server.model_number == '546') 669 | self.assertTrue(server.serial_number == '0612BH95K') 670 | 671 | def test_server_nonexists(self): 672 | self.assertRaises(urllib2.HTTPError, Server, 'http://192.168.1.254:80/upnp/DOESNOTEXIST.xml') 673 | 674 | def test_services(self): 675 | service_ids = [service.service_id for service in self.server.services] 676 | self.assertTrue('urn:upnp-org:serviceId:layer3f' in service_ids) 677 | self.assertTrue('urn:upnp-org:serviceId:wancic' in service_ids) 678 | self.assertTrue('urn:upnp-org:serviceId:wandsllc:pvc_Internet' in service_ids) 679 | self.assertTrue('urn:upnp-org:serviceId:wanipc:Internet' in service_ids) 680 | 681 | def test_actions(self): 682 | actions = [] 683 | [actions.extend(service.actions) for service in self.server.services] 684 | action_names = [action.name for action in actions] 685 | self.assertTrue('SetDefaultConnectionService' in action_names) 686 | self.assertTrue('GetCommonLinkProperties' in action_names) 687 | self.assertTrue('SetDSLLinkType' in action_names) 688 | self.assertTrue('SetConnectionType' in action_names) 689 | 690 | def test_findaction_server(self): 691 | action = self.server.find_action('GetStatusInfo') 692 | self.assertTrue(isinstance(action, Action)) 693 | 694 | def test_findaction_server_nonexists(self): 695 | action = self.server.find_action('GetNoneExistingAction') 696 | self.assertTrue(action == None) 697 | 698 | def test_findaction_service_nonexists(self): 699 | service = self.server.services[0] 700 | action = self.server.find_action('GetNoneExistingAction') 701 | self.assertTrue(action == None) 702 | 703 | def test_callaction_server(self): 704 | self.server.call('GetStatusInfo') 705 | 706 | def test_callaction_noparam(self): 707 | action = self.server.find_action('GetStatusInfo') 708 | response = action.call() 709 | self.assertTrue('NewLastConnectionError' in response) 710 | self.assertTrue('NewUptime' in response) 711 | self.assertTrue('NewConnectionStatus' in response) 712 | 713 | def test_callaction_param(self): 714 | action = self.server.find_action('GetGenericPortMappingEntry') 715 | response = action.call({'NewPortMappingIndex': 0}) 716 | self.assertTrue('NewInternalClient' in response) 717 | 718 | def test_callaction_param_kw(self): 719 | action = self.server.find_action('GetGenericPortMappingEntry') 720 | response = action.call(NewPortMappingIndex=0) 721 | self.assertTrue('NewInternalClient' in response) 722 | 723 | def test_callaction_param_missing(self): 724 | action = self.server.find_action('GetGenericPortMappingEntry') 725 | self.assertRaises(UPNPError, action.call) 726 | 727 | def test_callaction_param_invalid_ui2(self): 728 | action = self.server.find_action('GetGenericPortMappingEntry') 729 | self.assertRaises(UPNPError, action.call, {'NewPortMappingIndex': 'ZERO'}) 730 | 731 | def test_callaction_param_invalid_allowedval(self): 732 | action = self.server.find_action('SetDSLLinkType') 733 | name = 'NewLinkType' 734 | arg = 'WRONG' 735 | statevar = action.argsdef_in[0][1] 736 | self.assertRaises(UPNPError, action._validate_arg, name, arg, statevar) 737 | 738 | def test_callaction_param_mashall_out(self): 739 | action = self.server.find_action('GetGenericPortMappingEntry') 740 | response = action.call(NewPortMappingIndex=0) 741 | self.assertTrue(isinstance(response['NewInternalClient'], str) or isinstance(response['NewInternalClient'], unicode)) 742 | self.assertTrue(isinstance(response['NewExternalPort'], int)) 743 | self.assertTrue(isinstance(response['NewEnabled'], bool)) 744 | 745 | def test_callaction_nonexisting(self): 746 | service = self.server.services[0] 747 | try: 748 | service.call('NoSuchFunction') 749 | except SOAPError, e: 750 | self.assertTrue(e.args[0] == 401) 751 | self.assertTrue(e.args[1] == 'Invalid action') 752 | 753 | def test_callaction_forbidden(self): 754 | action = self.server.find_action('ForceTermination') 755 | try: 756 | action.call() 757 | except SOAPError, e: 758 | self.assertTrue(e.args[0] == 401) 759 | self.assertTrue(e.args[1] == 'Invalid action') 760 | 761 | unittest.main() 762 | --------------------------------------------------------------------------------