├── .gitignore ├── LICENCE ├── README.md ├── __main__.py ├── a-upnp-device-visible-in-the-network-center-of-windows-10.png ├── lib ├── __init__.py ├── ssdp.py └── upnp_http_server.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | venv/* 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Erwan Martin 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python UPnP Example 2 | 3 | ## Introduction 4 | 5 | This code implements a SSDP server and a HTTP server, in order to notify the network that a device is here. 6 | 7 | The base of this code was the [SSDP module](https://github.com/palfrey/coherence/blob/master/coherence/upnp/core/ssdp.py) of [coherence](http://coherence.beebits.net) . 8 | I took it, converted it to Python 3, and kept only the interesting parts for this project: the parts that responds to `MSEARCH` queries. 9 | 10 | ![Our device is the network view of Windows 10](a-upnp-device-visible-in-the-network-center-of-windows-10.png "Our device is the network view ofWindows 10") 11 | 12 | ## Technical details 13 | 14 | First, a SSDP client will try to search devices on the network by issuing 15 | a `MSEARCH *` query: 16 | 17 | M-SEARCH * HTTP/1.1 18 | HOST:239.255.255.250:1900 19 | ST:upnp:rootdevice 20 | MX:2 21 | MAN:"ssdp:discover" 22 | 23 | `lib/ssdp.py` will handle it and reply with a `200`: 24 | 25 | HTTP/1.1 200 OK 26 | SERVER: ZeWaren example SSDP Server 27 | LOCATION: http://192.168.4.207:8088/jambon-3000.xml 28 | USN: uuid:e427ce1a-3e80-43d0-ad6f-89ec42e46363::upnp:rootdevice 29 | CACHE-CONTROL: max-age=1800 30 | EXT: 31 | last-seen: 1477147409.432466 32 | ST: upnp:rootdevice 33 | DATE: Sat, 22 Oct 2016 14:44:26 GMT 34 | 35 | The client will then fetch the device description: 36 | 37 | GET /jambon-3000.xml HTTP/1.1 38 | Cache-Control: no-cache 39 | Connection: Keep-Alive 40 | Pragma: no-cache 41 | Accept: text/xml, application/xml 42 | User-Agent: FDSSDP 43 | Host: 192.168.4.207:8088 44 | 45 | 46 | And `lib/upnp_http_server.py` will build and serve that file: 47 | 48 | HTTP/1.0 200 OK 49 | Server: BaseHTTP/0.6 Python/3.4.3 50 | Date: Sat, 22 Oct 2016 14:44:26 GMT 51 | Content-type: application/xml 52 | 53 | 54 | 55 | 1 56 | 0 57 | 58 | 59 | urn:schemas-upnp-org:device:Basic:1 60 | Jambon 3000 61 | Boucherie numrique SAS 62 | http://www.boucherie.example.com/ 63 | Jambon Appliance 3000 64 | Jambon 65 | 3000 66 | http://www.boucherie.example.com/en/prducts/jambon-3000/ 67 | JBN425133 68 | uuid:e427ce1a-3e80-43d0-ad6f-89ec42e46363 69 | 70 | 71 | http://xxx.yyy.zzz.aaaa:5000 72 | urn:boucherie.example.com:service:Jambon:1 73 | urn:boucherie.example.com:serviceId:Jambon 74 | /jambon 75 | 76 | /boucherie_wsd.xml 77 | 78 | 79 | http://192.168.4.207:5000/ 80 | 81 | 82 | 83 | The client now knows (nearly) everything about the device. 84 | 85 | ## Relevant links 86 | 87 | * UPnP™ Device Architecture 1.1 (UPnP forum, October 15, 2008): http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1.pdf 88 | * Coherence - DLNA/UPnP framework for the digital living: https://pypi.python.org/pypi/Coherence 89 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | from lib.ssdp import SSDPServer 2 | from lib.upnp_http_server import UPNPHTTPServer 3 | import uuid 4 | import netifaces as ni 5 | from time import sleep 6 | import logging 7 | 8 | NETWORK_INTERFACE = 'em0' 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.DEBUG) 12 | 13 | 14 | def setup_debugging(): 15 | """ 16 | Load PyCharm's egg and start a remote debug session. 17 | :return: None 18 | """ 19 | import sys 20 | sys.path.append('/root/pycharm-debug-py3k.egg') 21 | import pydevd 22 | pydevd.settrace('192.168.4.47', port=5422, stdoutToServer=True, stderrToServer=True, suspend=False) 23 | 24 | 25 | setup_debugging() 26 | 27 | 28 | def get_network_interface_ip_address(interface='eth0'): 29 | """ 30 | Get the first IP address of a network interface. 31 | :param interface: The name of the interface. 32 | :return: The IP address. 33 | """ 34 | while True: 35 | if NETWORK_INTERFACE not in ni.interfaces(): 36 | logger.error('Could not find interface %s.' % (interface,)) 37 | exit(1) 38 | interface = ni.ifaddresses(interface) 39 | if (2 not in interface) or (len(interface[2]) == 0): 40 | logger.warning('Could not find IP of interface %s. Sleeping.' % (interface,)) 41 | sleep(60) 42 | continue 43 | return interface[2][0]['addr'] 44 | 45 | 46 | device_uuid = uuid.uuid4() 47 | local_ip_address = get_network_interface_ip_address(NETWORK_INTERFACE) 48 | 49 | http_server = UPNPHTTPServer(8088, 50 | friendly_name="Jambon 3000", 51 | manufacturer="Boucherie numérique SAS", 52 | manufacturer_url='http://www.boucherie.example.com/', 53 | model_description='Jambon Appliance 3000', 54 | model_name="Jambon", 55 | model_number="3000", 56 | model_url="http://www.boucherie.example.com/en/prducts/jambon-3000/", 57 | serial_number="JBN425133", 58 | uuid=device_uuid, 59 | presentation_url="http://{}:5000/".format(local_ip_address)) 60 | http_server.start() 61 | 62 | ssdp = SSDPServer() 63 | ssdp.register('local', 64 | 'uuid:{}::upnp:rootdevice'.format(device_uuid), 65 | 'upnp:rootdevice', 66 | 'http://{}:8088/jambon-3000.xml'.format(local_ip_address)) 67 | ssdp.run() 68 | -------------------------------------------------------------------------------- /a-upnp-device-visible-in-the-network-center-of-windows-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeWaren/python-upnp-ssdp-example/ec60c200a83eb5126b9f88cfd57b5e7276241860/a-upnp-device-visible-in-the-network-center-of-windows-10.png -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeWaren/python-upnp-ssdp-example/ec60c200a83eb5126b9f88cfd57b5e7276241860/lib/__init__.py -------------------------------------------------------------------------------- /lib/ssdp.py: -------------------------------------------------------------------------------- 1 | # Licensed under the MIT license 2 | # http://opensource.org/licenses/mit-license.php 3 | 4 | # Copyright 2005, Tim Potter 5 | # Copyright 2006 John-Mark Gurney 6 | # Copyright (C) 2006 Fluendo, S.A. (www.fluendo.com). 7 | # Copyright 2006,2007,2008,2009 Frank Scholz 8 | # Copyright 2016 Erwan Martin 9 | # 10 | # Implementation of a SSDP server. 11 | # 12 | 13 | import random 14 | import time 15 | import socket 16 | import logging 17 | from email.utils import formatdate 18 | from errno import ENOPROTOOPT 19 | 20 | SSDP_PORT = 1900 21 | SSDP_ADDR = '239.255.255.250' 22 | SERVER_ID = 'ZeWaren example SSDP Server' 23 | 24 | 25 | logger = logging.getLogger() 26 | 27 | 28 | class SSDPServer: 29 | """A class implementing a SSDP server. The notify_received and 30 | searchReceived methods are called when the appropriate type of 31 | datagram is received by the server.""" 32 | known = {} 33 | 34 | def __init__(self): 35 | self.sock = None 36 | 37 | def run(self): 38 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 39 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 40 | if hasattr(socket, "SO_REUSEPORT"): 41 | try: 42 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 43 | except socket.error as le: 44 | # RHEL6 defines SO_REUSEPORT but it doesn't work 45 | if le.errno == ENOPROTOOPT: 46 | pass 47 | else: 48 | raise 49 | 50 | addr = socket.inet_aton(SSDP_ADDR) 51 | interface = socket.inet_aton('0.0.0.0') 52 | cmd = socket.IP_ADD_MEMBERSHIP 53 | self.sock.setsockopt(socket.IPPROTO_IP, cmd, addr + interface) 54 | self.sock.bind(('0.0.0.0', SSDP_PORT)) 55 | self.sock.settimeout(1) 56 | 57 | while True: 58 | try: 59 | data, addr = self.sock.recvfrom(1024) 60 | self.datagram_received(data, addr) 61 | except socket.timeout: 62 | continue 63 | self.shutdown() 64 | 65 | def shutdown(self): 66 | for st in self.known: 67 | if self.known[st]['MANIFESTATION'] == 'local': 68 | self.do_byebye(st) 69 | 70 | def datagram_received(self, data, host_port): 71 | """Handle a received multicast datagram.""" 72 | 73 | (host, port) = host_port 74 | 75 | try: 76 | header, payload = data.decode().split('\r\n\r\n')[:2] 77 | except ValueError as err: 78 | logger.error(err) 79 | return 80 | 81 | lines = header.split('\r\n') 82 | cmd = lines[0].split(' ') 83 | lines = map(lambda x: x.replace(': ', ':', 1), lines[1:]) 84 | lines = filter(lambda x: len(x) > 0, lines) 85 | 86 | headers = [x.split(':', 1) for x in lines] 87 | headers = dict(map(lambda x: (x[0].lower(), x[1]), headers)) 88 | 89 | logger.info('SSDP command %s %s - from %s:%d' % (cmd[0], cmd[1], host, port)) 90 | logger.debug('with headers: {}.'.format(headers)) 91 | if cmd[0] == 'M-SEARCH' and cmd[1] == '*': 92 | # SSDP discovery 93 | self.discovery_request(headers, (host, port)) 94 | elif cmd[0] == 'NOTIFY' and cmd[1] == '*': 95 | # SSDP presence 96 | logger.debug('NOTIFY *') 97 | else: 98 | logger.warning('Unknown SSDP command %s %s' % (cmd[0], cmd[1])) 99 | 100 | def register(self, manifestation, usn, st, location, server=SERVER_ID, cache_control='max-age=1800', silent=False, 101 | host=None): 102 | """Register a service or device that this SSDP server will 103 | respond to.""" 104 | 105 | logging.info('Registering %s (%s)' % (st, location)) 106 | 107 | self.known[usn] = {} 108 | self.known[usn]['USN'] = usn 109 | self.known[usn]['LOCATION'] = location 110 | self.known[usn]['ST'] = st 111 | self.known[usn]['EXT'] = '' 112 | self.known[usn]['SERVER'] = server 113 | self.known[usn]['CACHE-CONTROL'] = cache_control 114 | 115 | self.known[usn]['MANIFESTATION'] = manifestation 116 | self.known[usn]['SILENT'] = silent 117 | self.known[usn]['HOST'] = host 118 | self.known[usn]['last-seen'] = time.time() 119 | 120 | if manifestation == 'local' and self.sock: 121 | self.do_notify(usn) 122 | 123 | def unregister(self, usn): 124 | logger.info("Un-registering %s" % usn) 125 | del self.known[usn] 126 | 127 | def is_known(self, usn): 128 | return usn in self.known 129 | 130 | def send_it(self, response, destination, delay, usn): 131 | logger.debug('send discovery response delayed by %ds for %s to %r' % (delay, usn, destination)) 132 | try: 133 | self.sock.sendto(response.encode(), destination) 134 | except (AttributeError, socket.error) as msg: 135 | logger.warning("failure sending out byebye notification: %r" % msg) 136 | 137 | def discovery_request(self, headers, host_port): 138 | """Process a discovery request. The response must be sent to 139 | the address specified by (host, port).""" 140 | 141 | (host, port) = host_port 142 | 143 | logger.info('Discovery request from (%s,%d) for %s' % (host, port, headers['st'])) 144 | logger.info('Discovery request for %s' % headers['st']) 145 | 146 | # Do we know about this service? 147 | for i in self.known.values(): 148 | if i['MANIFESTATION'] == 'remote': 149 | continue 150 | if headers['st'] == 'ssdp:all' and i['SILENT']: 151 | continue 152 | if i['ST'] == headers['st'] or headers['st'] == 'ssdp:all': 153 | response = ['HTTP/1.1 200 OK'] 154 | 155 | usn = None 156 | for k, v in i.items(): 157 | if k == 'USN': 158 | usn = v 159 | if k not in ('MANIFESTATION', 'SILENT', 'HOST'): 160 | response.append('%s: %s' % (k, v)) 161 | 162 | if usn: 163 | response.append('DATE: %s' % formatdate(timeval=None, localtime=False, usegmt=True)) 164 | 165 | response.extend(('', '')) 166 | delay = random.randint(0, int(headers['mx'])) 167 | 168 | self.send_it('\r\n'.join(response), (host, port), delay, usn) 169 | 170 | def do_notify(self, usn): 171 | """Do notification""" 172 | 173 | if self.known[usn]['SILENT']: 174 | return 175 | logger.info('Sending alive notification for %s' % usn) 176 | 177 | resp = [ 178 | 'NOTIFY * HTTP/1.1', 179 | 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), 180 | 'NTS: ssdp:alive', 181 | ] 182 | stcpy = dict(self.known[usn].items()) 183 | stcpy['NT'] = stcpy['ST'] 184 | del stcpy['ST'] 185 | del stcpy['MANIFESTATION'] 186 | del stcpy['SILENT'] 187 | del stcpy['HOST'] 188 | del stcpy['last-seen'] 189 | 190 | resp.extend(map(lambda x: ': '.join(x), stcpy.items())) 191 | resp.extend(('', '')) 192 | logger.debug('do_notify content', resp) 193 | try: 194 | self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) 195 | self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) 196 | except (AttributeError, socket.error) as msg: 197 | logger.warning("failure sending out alive notification: %r" % msg) 198 | 199 | def do_byebye(self, usn): 200 | """Do byebye""" 201 | 202 | logger.info('Sending byebye notification for %s' % usn) 203 | 204 | resp = [ 205 | 'NOTIFY * HTTP/1.1', 206 | 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), 207 | 'NTS: ssdp:byebye', 208 | ] 209 | try: 210 | stcpy = dict(self.known[usn].items()) 211 | stcpy['NT'] = stcpy['ST'] 212 | del stcpy['ST'] 213 | del stcpy['MANIFESTATION'] 214 | del stcpy['SILENT'] 215 | del stcpy['HOST'] 216 | del stcpy['last-seen'] 217 | resp.extend(map(lambda x: ': '.join(x), stcpy.items())) 218 | resp.extend(('', '')) 219 | logger.debug('do_byebye content', resp) 220 | if self.sock: 221 | try: 222 | self.sock.sendto('\r\n'.join(resp), (SSDP_ADDR, SSDP_PORT)) 223 | except (AttributeError, socket.error) as msg: 224 | logger.error("failure sending out byebye notification: %r" % msg) 225 | except KeyError as msg: 226 | logger.error("error building byebye notification: %r" % msg) 227 | -------------------------------------------------------------------------------- /lib/upnp_http_server.py: -------------------------------------------------------------------------------- 1 | from http.server import BaseHTTPRequestHandler, HTTPServer 2 | import threading 3 | 4 | PORT_NUMBER = 8080 5 | 6 | 7 | class UPNPHTTPServerHandler(BaseHTTPRequestHandler): 8 | """ 9 | A HTTP handler that serves the UPnP XML files. 10 | """ 11 | 12 | # Handler for the GET requests 13 | def do_GET(self): 14 | 15 | if self.path == '/boucherie_wsd.xml': 16 | self.send_response(200) 17 | self.send_header('Content-type', 'application/xml') 18 | self.end_headers() 19 | self.wfile.write(self.get_wsd_xml().encode()) 20 | return 21 | if self.path == '/jambon-3000.xml': 22 | self.send_response(200) 23 | self.send_header('Content-type', 'application/xml') 24 | self.end_headers() 25 | self.wfile.write(self.get_device_xml().encode()) 26 | return 27 | else: 28 | self.send_response(404) 29 | self.send_header('Content-type', 'text/html') 30 | self.end_headers() 31 | self.wfile.write(b"Not found.") 32 | return 33 | 34 | def get_device_xml(self): 35 | """ 36 | Get the main device descriptor xml file. 37 | """ 38 | xml = """ 39 | 40 | 1 41 | 0 42 | 43 | 44 | urn:schemas-upnp-org:device:Basic:1 45 | {friendly_name} 46 | {manufacturer} 47 | {manufacturer_url} 48 | {model_description} 49 | {model_name} 50 | {model_number} 51 | {model_url} 52 | {serial_number} 53 | uuid:{uuid} 54 | 55 | 56 | http://xxx.yyy.zzz.aaaa:5000 57 | urn:boucherie.example.com:service:Jambon:1 58 | urn:boucherie.example.com:serviceId:Jambon 59 | /jambon 60 | 61 | /boucherie_wsd.xml 62 | 63 | 64 | {presentation_url} 65 | 66 | """ 67 | return xml.format(friendly_name=self.server.friendly_name, 68 | manufacturer=self.server.manufacturer, 69 | manufacturer_url=self.server.manufacturer_url, 70 | model_description=self.server.model_description, 71 | model_name=self.server.model_name, 72 | model_number=self.server.model_number, 73 | model_url=self.server.model_url, 74 | serial_number=self.server.serial_number, 75 | uuid=self.server.uuid, 76 | presentation_url=self.server.presentation_url) 77 | 78 | @staticmethod 79 | def get_wsd_xml(): 80 | """ 81 | Get the device WSD file. 82 | """ 83 | return """ 84 | 85 | 1 86 | 0 87 | 88 | """ 89 | 90 | 91 | class UPNPHTTPServerBase(HTTPServer): 92 | """ 93 | A simple HTTP server that knows the information about a UPnP device. 94 | """ 95 | def __init__(self, server_address, request_handler_class): 96 | HTTPServer.__init__(self, server_address, request_handler_class) 97 | self.port = None 98 | self.friendly_name = None 99 | self.manufacturer = None 100 | self.manufacturer_url = None 101 | self.model_description = None 102 | self.model_name = None 103 | self.model_url = None 104 | self.serial_number = None 105 | self.uuid = None 106 | self.presentation_url = None 107 | 108 | 109 | class UPNPHTTPServer(threading.Thread): 110 | """ 111 | A thread that runs UPNPHTTPServerBase. 112 | """ 113 | 114 | def __init__(self, port, friendly_name, manufacturer, manufacturer_url, model_description, model_name, 115 | model_number, model_url, serial_number, uuid, presentation_url): 116 | threading.Thread.__init__(self, daemon=True) 117 | self.server = UPNPHTTPServerBase(('', port), UPNPHTTPServerHandler) 118 | self.server.port = port 119 | self.server.friendly_name = friendly_name 120 | self.server.manufacturer = manufacturer 121 | self.server.manufacturer_url = manufacturer_url 122 | self.server.model_description = model_description 123 | self.server.model_name = model_name 124 | self.server.model_number = model_number 125 | self.server.model_url = model_url 126 | self.server.serial_number = serial_number 127 | self.server.uuid = uuid 128 | self.server.presentation_url = presentation_url 129 | 130 | def run(self): 131 | self.server.serve_forever() 132 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | netifaces==0.10.5 --------------------------------------------------------------------------------