├── .gitignore ├── LICENSE ├── README.md ├── pydial ├── __init__.py ├── client.py ├── common.py └── server.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | *.pyc 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The following applies to the DIAL protocol: 2 | Copyright © 2012 Netflix, Inc. All rights reserved. 3 | 4 | Redistribution and use of the DIAL DIscovery And Launch protocol specification 5 | (the “DIAL Specification”), with or without modification, are permitted 6 | provided that the following conditions are met: 7 | 8 | ● Redistributions of the DIAL Specification must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | ● Redistributions of implementations of the DIAL Specification in source code 12 | form must retain the above copyright notice, this list of conditions and the 13 | following disclaimer. 14 | 15 | ● Redistributions of implementations of the DIAL Specification in binary form 16 | must include the above copyright notice. 17 | 18 | ● The DIAL mark, the NETFLIX mark and the names of contributors to the DIAL 19 | Specification may not be used to endorse or promote specifications, software, 20 | products, or any other materials derived from the DIAL Specification without 21 | specific prior written permission. The DIAL mark is owned by Netflix and 22 | information on licensing the DIAL mark is available at 23 | www.dial-multiscreen.org. 24 | 25 | THE DIAL SPECIFICATION IS PROVIDED BY NETFLIX, INC. "AS IS" AND ANY EXPRESS OR 26 | IMPLIED WARRANTIES, INCLUDING, BUT NOT 27 | 28 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 29 | PURPOSE AND NONINFRINGEMENT 30 | 31 | ARE DISCLAIMED. IN NO EVENT SHALL NETFLIX OR CONTRIBUTORS TO THE DIAL 32 | SPECIFICATION BE LIABLE FOR ANY DIRECT, 33 | 34 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 35 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 36 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 37 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 38 | OR OTHERWISE) 39 | 40 | ARISING IN ANY WAY OUT OF THE USE OF THE DIAL SPECIFICATION, EVEN IF ADVISED OF 41 | THE POSSIBILITY OF SUCH DAMAGES. 42 | 43 | The following applies to the PyDial project: 44 | The MIT License (MIT) 45 | 46 | Copyright (c) 2014 Clay Collier 47 | Copyright (c) 2013 Paulus Schoutsen 48 | 49 | Permission is hereby granted, free of charge, to any person obtaining a copy of 50 | this software and associated documentation files (the "Software"), to deal in 51 | the Software without restriction, including without limitation the rights to 52 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 53 | the Software, and to permit persons to whom the Software is furnished to do so, 54 | subject to the following conditions: 55 | ======= 56 | The MIT License (MIT) 57 | 58 | The above copyright notice and this permission notice shall be included in all 59 | copies or substantial portions of the Software. 60 | 61 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 62 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 63 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 64 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 65 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 66 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | NOTE: PyDial is currently pre-alpha, and in the middle of being ported from Python 2.7 to Python 3.x. The 2.x client branch basically works; the 2.x server can answer UPnP discovery requests, but doesn't implement the rest of DIAL. The 3.x client/server kit is mid-port and may not be working at any given moment. As of 4/19/14, server discovery was working but other client funcionts hadn't been tested and the server could respond to UPnP discovery requests. 2 | 3 | pydial 4 | ====== 5 | 6 | Simple Python client and server for the DIAL protocol. 7 | 8 | The DIAL (DIscovery And Launch) protocol enables devices to request display devices on the same LAN/WLAN segment (like smart TVs, Chromecast, and other multimedia devices) to play back media on their behalf. It is used by Google's Chromecast device to stream YouTube, Netflix, Hulu, and other services, allowing you to control playback from a smartphone, tablet, or other computer while displaying content on a large screen. 9 | 10 | DIAL is built on top of parts of the UPnP and HTTP protocols. UPnP is used for discovering DIAL-enabled devices on your network segment; HTTP is used for requesting and controlling playback. App developers can build their own protocols for controlling authentication, playback, etc. on top of the DIAL protocol. 11 | 12 | PyDial aims at being a simple implementation of the DIAL protocol, suitable for prototyping DIAL-enabled functions on first screen (display device) or second screen (control/client device) devices that understand Python. You can also use it to develop desktop apps that can interact with DIAL devices. 13 | 14 | DIAL was developed by Netflix and Google; complete details are available here: http://www.dial-multiscreen.org/ 15 | 16 | Wikipedia description of the DIAL protocol: http://en.wikipedia.org/wiki/DIscovery_And_Launch 17 | 18 | Resources 19 | --------- 20 | 21 | * The DIAL protocol specification: http://www.dial-multiscreen.org/dial-protocol-specification 22 | * The UPnP device architecture specification: http://www.upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0-20080424.pdf 23 | 24 | Acknowledgements 25 | ---------------- 26 | 27 | Code for the PyDial client code was derived from the PyChromecast project: https://github.com/balloob/pychromecast 28 | 29 | Licensing and Legal 30 | ------------------- 31 | 32 | See the LICENSE file for complete licensing information. 33 | 34 | PyDial is distributed under the MIT license. 35 | 36 | The following applies to the DIAL protocol: 37 | Copyright © 2012 Netflix, Inc. All rights reserved. 38 | 39 | Redistribution and use of the DIAL DIscovery And Launch protocol specification 40 | (the “DIAL Specification”), with or without modification, are permitted 41 | provided that the following conditions are met: 42 | 43 | ● Redistributions of the DIAL Specification must retain the above copyright 44 | notice, this list of conditions and the following disclaimer. 45 | 46 | ● Redistributions of implementations of the DIAL Specification in source code 47 | form must retain the above copyright notice, this list of conditions and the 48 | following disclaimer. 49 | 50 | ● Redistributions of implementations of the DIAL Specification in binary form 51 | must include the above copyright notice. 52 | 53 | ● The DIAL mark, the NETFLIX mark and the names of contributors to the DIAL 54 | Specification may not be used to endorse or promote specifications, software, 55 | products, or any other materials derived from the DIAL Specification without 56 | specific prior written permission. The DIAL mark is owned by Netflix and 57 | information on licensing the DIAL mark is available at 58 | www.dial-multiscreen.org. 59 | 60 | THE DIAL SPECIFICATION IS PROVIDED BY NETFLIX, INC. "AS IS" AND ANY EXPRESS OR 61 | IMPLIED WARRANTIES, INCLUDING, BUT NOT 62 | 63 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 64 | PURPOSE AND NONINFRINGEMENT 65 | 66 | ARE DISCLAIMED. IN NO EVENT SHALL NETFLIX OR CONTRIBUTORS TO THE DIAL 67 | SPECIFICATION BE LIABLE FOR ANY DIRECT, 68 | 69 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 70 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 71 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 72 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 73 | OR OTHERWISE) 74 | 75 | ARISING IN ANY WAY OUT OF THE USE OF THE DIAL SPECIFICATION, EVEN IF ADVISED OF 76 | THE POSSIBILITY OF SUCH DAMAGES. 77 | 78 | 79 | -------------------------------------------------------------------------------- /pydial/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import discover 2 | from .client import DialClient 3 | from .server import (DialServer, SSDPServer) 4 | 5 | __all__ = ['discover', 'DialClient', 'DialServer', 'SSDPServer'] 6 | 7 | 8 | -------------------------------------------------------------------------------- /pydial/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module that implements a basic DIAL protocol client. 3 | Usage: 4 | import pydial 5 | servers = pydial.discover() 6 | client = pydial.DialClient(servers[0]) 7 | device = client.get_device_description() 8 | client.launch_app('YouTube') 9 | 10 | Based on code from PyChromecast - https://github.com/balloob/pychromecast 11 | """ 12 | 13 | import select 14 | import socket 15 | from urllib.parse import urlparse 16 | import datetime as dt 17 | from contextlib import closing 18 | import xml.etree.ElementTree as ET 19 | from collections import namedtuple 20 | 21 | import requests 22 | 23 | from .common import (SSDP_ADDR, SSDP_PORT, SSDP_MX, SSDP_ST) 24 | 25 | # Wait at least one second past the SSDP_MX to give servers a chance to respond 26 | DISCOVER_TIMEOUT = SSDP_MX + 1 27 | 28 | SSDP_REQUEST = 'M-SEARCH * HTTP/1.1\r\n' + \ 29 | 'HOST: {}:{:d}\r\n'.format(SSDP_ADDR, SSDP_PORT) + \ 30 | 'MAN: "ssdp:discover"\r\n' + \ 31 | 'MX: {:d}\r\n'.format(SSDP_MX) + \ 32 | 'ST: {}\r\n'.format(SSDP_ST) + \ 33 | '\r\n' 34 | 35 | 36 | _BASE_URL = "http://{}:{}{}" 37 | DeviceStatus = namedtuple("DeviceStatus", 38 | ["friendly_name", "model_name", 39 | "manufacturer", "api_version"]) 40 | 41 | AppStatus = namedtuple("AppStatus", ["app_id", "description", "state", 42 | "options", "service_url", 43 | "service_protocols"]) 44 | 45 | # Device status XML constants 46 | XML_NS_UPNP_DEVICE = "{urn:schemas-upnp-org:device-1-0}" 47 | XML_NS_DIAL = "{urn:dial-multiscreen-org:schemas:dial}" 48 | XML_NS_CAST = "{urn:chrome.google.com:cast}" 49 | 50 | class DialClient(requests.Session): 51 | """Client for easily sending DIAL requests to a server.""" 52 | def __init__(self, device_url): 53 | requests.Session.__init__(self) 54 | url = urlparse(device_url) 55 | self.dev_host = url.hostname 56 | self.dev_port = url.port 57 | self.dev_descrip_path = url.path 58 | self.app_path = None 59 | self.app_host = None 60 | self.app_port = None 61 | 62 | def _craft_app_url(self, app_id=None): 63 | """ Helper method to create a ChromeCast url given 64 | a host and an optional app_id. """ 65 | url = _BASE_URL.format(self.app_host, self.app_port, self.app_path) 66 | if app_id is not None: 67 | return url + app_id 68 | else: 69 | return url 70 | def get_app_status(self, appid=None): 71 | """Returns the status of the requested app as a named tuple.""" 72 | if not self.app_path: 73 | raise AttributeError('Uninitialized application URL.') 74 | 75 | url = _BASE_URL.format(self.app_host, self.app_port, self.app_path) \ 76 | + appid 77 | req = requests.Request('GET', url).prepare() 78 | response = self.send(req) 79 | 80 | # FIXME: Raise an exception here? 81 | # TODO: Look for 404 in case app is not present 82 | if response.status_code == 204: 83 | return None 84 | 85 | status_el = ET.fromstring(response.text.encode("UTF-8")) 86 | options = status_el.find(XML_NS_DIAL + "options").attrib 87 | 88 | app_id = _read_xml_element(status_el, XML_NS_DIAL, 89 | "name", "Unknown application") 90 | 91 | state = _read_xml_element(status_el, XML_NS_DIAL, 92 | "state", "unknown") 93 | 94 | service_el = status_el.find(XML_NS_CAST + "servicedata") 95 | 96 | if service_el is not None: 97 | service_url = _read_xml_element(service_el, XML_NS_CAST, 98 | "connectionSvcURL", None) 99 | 100 | protocols_el = service_el.find(XML_NS_CAST + "protocols") 101 | 102 | if protocols_el is not None: 103 | protocols = [el.text for el in protocols_el] 104 | else: 105 | protocols = [] 106 | 107 | else: 108 | service_url = None 109 | protocols = [] 110 | 111 | activity_el = status_el.find(XML_NS_CAST + "activity-status") 112 | if activity_el is not None: 113 | description = _read_xml_element(activity_el, XML_NS_CAST, 114 | "description", app_id) 115 | else: 116 | description = app_id 117 | 118 | return AppStatus(app_id, description, state, options, service_url, protocols) 119 | 120 | 121 | def launch_app(self, appid, args=None): 122 | if not self.app_path: 123 | raise AttributeError('Uninitialized application URL.') 124 | 125 | url = _BASE_URL.format(self.app_host, self.app_port, self.app_path) \ 126 | + appid 127 | header = '' 128 | if not args: 129 | header = 'Content-Length: 0' 130 | req = requests.Request('POST', url, data=args, headers=header) 131 | prepped = req.prepare() 132 | response = self.send(prepped) 133 | print(response) 134 | 135 | def quit_app(self, app_id=None): 136 | """ Quits specified application if it is running. 137 | If no app_id specified will quit current running app. """ 138 | 139 | if not app_id: 140 | status = self.get_app_status(app_id) 141 | 142 | if status: 143 | app_id = status.app_id 144 | 145 | if app_id: 146 | self.delete(self._craft_app_url(app_id)) 147 | 148 | def get_device_description(self): 149 | """ Returns the device status as a named tuple. Initializes the 150 | app path if not initialized.""" 151 | # FIXME: Error handling? Probably should throw errors up. 152 | try: 153 | req = self.get(_BASE_URL.format(self.dev_host, self.dev_port, \ 154 | self.dev_descrip_path)) 155 | 156 | # TODO: Make sure this is case insensitive 157 | if not self.app_path: 158 | app_url = urlparse(req.headers['application-url']) 159 | self.app_host = app_url.hostname 160 | self.app_port = app_url.port 161 | self.app_path = app_url.path 162 | 163 | status_el = ET.fromstring(req.text.encode("UTF-8")) 164 | 165 | device_info_el = status_el.find(XML_NS_UPNP_DEVICE + "device") 166 | api_version_el = status_el.find(XML_NS_UPNP_DEVICE + "specVersion") 167 | 168 | friendly_name = _read_xml_element(device_info_el, XML_NS_UPNP_DEVICE, 169 | "friendlyName", "Unknown device") 170 | model_name = _read_xml_element(device_info_el, XML_NS_UPNP_DEVICE, 171 | "modelName", "Unknown model name") 172 | manufacturer = _read_xml_element(device_info_el, XML_NS_UPNP_DEVICE, 173 | "manufacturer", 174 | "Unknown manufacturer") 175 | 176 | api_version = (int(_read_xml_element(api_version_el, 177 | XML_NS_UPNP_DEVICE, "major", -1)), 178 | int(_read_xml_element(api_version_el, 179 | XML_NS_UPNP_DEVICE, "minor", -1))) 180 | 181 | return DeviceStatus(friendly_name, model_name, manufacturer, 182 | api_version) 183 | 184 | except (requests.exceptions.RequestException, ET.ParseError): 185 | return None 186 | 187 | 188 | def discover(max_devices=None, timeout=DISCOVER_TIMEOUT, verbose=False): 189 | """ 190 | Sends a message over the network to discover DIAL servers and returns 191 | a list of found IP addresses. 192 | 193 | Inspired by Crimsdings 194 | https://github.com/crimsdings/ChromeCast/blob/master/cc_discovery.py 195 | """ 196 | devices = [] 197 | 198 | start = dt.datetime.now() 199 | 200 | with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as sock: 201 | sock.sendto(bytes(SSDP_REQUEST, 'utf-8'), (SSDP_ADDR, SSDP_PORT)) 202 | sock.setblocking(0) 203 | 204 | while True: 205 | time_diff = dt.datetime.now() - start 206 | seconds_left = timeout - time_diff.seconds 207 | 208 | if seconds_left <= 0: 209 | return devices 210 | 211 | ready = select.select([sock], [], [], seconds_left)[0] 212 | 213 | if ready: 214 | response = str(sock.recv(1024), 'utf-8') 215 | if verbose: 216 | print(response) 217 | found_url = found_st = None 218 | headers = response.split("\r\n\r\n", 1)[0] 219 | 220 | for header in headers.split("\r\n"): 221 | try: 222 | key, value = header.split(": ", 1) 223 | except ValueError: 224 | # Skip since not key-value pair 225 | continue 226 | 227 | if key == "LOCATION": 228 | found_url = value 229 | 230 | elif key == "ST": 231 | found_st = value 232 | 233 | if found_st == SSDP_ST and found_url: 234 | devices.append(found_url) 235 | 236 | if max_devices and len(devices) == max_devices: 237 | return devices 238 | 239 | return devices 240 | 241 | 242 | def _read_xml_element(element, xml_ns, tag_name, default=""): 243 | """ Helper method to read text from an element. """ 244 | try: 245 | return element.find(xml_ns + tag_name).text 246 | 247 | except AttributeError: 248 | return default 249 | -------------------------------------------------------------------------------- /pydial/common.py: -------------------------------------------------------------------------------- 1 | # This multicast IP and port are specified by the DIAL standard 2 | SSDP_ADDR = "239.255.255.250" 3 | SSDP_PORT = 1900 4 | 5 | # This is used during SSDP discovery- servers will wait up to this 6 | # many seconds before replying to prevent flooding 7 | SSDP_MX = 3 8 | 9 | # The DIAL search target specified by the protocol 10 | SSDP_ST = "urn:dial-multiscreen-org:service:dial:1" 11 | -------------------------------------------------------------------------------- /pydial/server.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | import socket 3 | import struct 4 | import time 5 | import platform 6 | import random 7 | import uuid 8 | 9 | from .common import (SSDP_PORT, SSDP_ADDR, SSDP_ST) 10 | 11 | UPNP_SEARCH = 'M-SEARCH * HTTP/1.1' 12 | # If we get a M-SEARCH with no or invalid MX value, wait up 13 | # to this many seconds before responding to prevent flooding 14 | CACHE_DEFAULT = 1800 15 | DELAY_DEFAULT = 10 16 | PRODUCT = 'PyDial Server' 17 | VERSION = '0.01' 18 | 19 | SSDP_REPLY = 'HTTP/1.1 200 OK\r\n' + \ 20 | 'LOCATION: {}\r\n' + \ 21 | 'CACHE-CONTROL: max-age={}\r\n' + \ 22 | 'EXT:\r\n' + \ 23 | 'BOOTID.UPNP.ORG: 1\r\n' + \ 24 | 'SERVER: {}/{} UPnP/1.1 {}/{}\r\n' + \ 25 | 'ST: {}\r\n'.format(SSDP_ST) + \ 26 | 'DATE: {}\r\n' + \ 27 | 'USN: {}\r\n' + '\r\n' 28 | 29 | 30 | class SSDPHandler(socketserver.BaseRequestHandler): 31 | """ 32 | RequestHandler object to deal with DIAL UPnP search requests. 33 | 34 | Note that per the SSD protocol, the server will sleep for up 35 | to the number of seconds specified in the MX value of the 36 | search request- this may cause the system to not respond if 37 | you are not using the multi-thread or forking mixin. 38 | """ 39 | def __init__(self, request, client_address, server): 40 | socketserver.BaseRequestHandler.__init__(self, request, 41 | client_address, server) 42 | self.max_delay = DELAY_DEFAULT 43 | 44 | def handle(self): 45 | """ 46 | Reads data from the socket, checks for the correct 47 | search parameters and UPnP search target, and replies 48 | with the application URL that the server advertises. 49 | """ 50 | data = str(self.request[0], 'utf-8') 51 | data = data.strip().split('\r\n') 52 | if data[0] != UPNP_SEARCH: 53 | return 54 | else: 55 | dial_search = False 56 | for line in data[1:]: 57 | field, val = line.split(':', 1) 58 | if field.strip() == 'ST' and val.strip() == SSDP_ST: 59 | dial_search = True 60 | elif field.strip() == 'MX': 61 | try: 62 | self.max_delay = int(val.strip()) 63 | except ValueError: 64 | # Use default 65 | pass 66 | if dial_search: 67 | self._send_reply() 68 | 69 | def _send_reply(self): 70 | """Sends reply to SSDP search messages.""" 71 | time.sleep(random.randint(0, self.max_delay)) 72 | _socket = self.request[1] 73 | timestamp = time.strftime("%A, %d %B %Y %H:%M:%S GMT", 74 | time.gmtime()) 75 | reply_data = SSDP_REPLY.format(self.server.device_url, 76 | self.server.cache_expire, self.server.os_id, 77 | self.server.os_version, self.server.product_id, 78 | self.server.product_version, timestamp, "uuid:"+str(self.server.uuid)) 79 | 80 | sent = 0 81 | while sent < len(reply_data): 82 | sent += _socket.sendto(bytes(reply_data, 'utf-8'), self.client_address) 83 | 84 | class SSDPServer(socketserver.UDPServer): 85 | """ 86 | Inherits from SocketServer.UDPServer to implement the SSDP 87 | portions of the DIAL protocol- listening for search requests 88 | on port 1900 for messages to the DIAL multicast group and 89 | replying with information on the URL used to request app 90 | actions from the server. 91 | 92 | Parameters: 93 | -device_url: Absolute URL of the device being advertised. 94 | -host: host/IP address to listen on 95 | 96 | The following attributes are set by default, but should be 97 | changed if you want to use this class as the basis for a 98 | more complete server: 99 | product_id - Name of the server/product. Defaults to PyDial Server. 100 | product_version - Product version. Defaults to whatever version 101 | number PyDial was given during the last release. 102 | os_id - Operating system name. Default: platform.system() 103 | os_version - Operating system version. Default: platform.release(). 104 | cache_expire - Time (in seconds) before a reply/advertisement expires. 105 | Defaults to 1800. 106 | uuid - UUID. By default created from the NIC via uuid.uuid1() 107 | """ 108 | def __init__(self, device_url, host=''): 109 | socketserver.UDPServer.__init__(self, (host, SSDP_PORT), 110 | SSDPHandler, False) 111 | self.allow_reuse_address = True 112 | self.server_bind() 113 | mreq = struct.pack("=4sl", socket.inet_aton(SSDP_ADDR), 114 | socket.INADDR_ANY) 115 | self.socket.setsockopt(socket.IPPROTO_IP, 116 | socket.IP_ADD_MEMBERSHIP, mreq) 117 | self.device_url = device_url 118 | self.product_id = PRODUCT 119 | self.product_version = VERSION 120 | self.os_id = platform.system() 121 | self.os_version = platform.release() 122 | self.cache_expire = CACHE_DEFAULT 123 | self.uuid = uuid.uuid1() 124 | 125 | def start(self): 126 | self.serve_forever() 127 | 128 | class DialServer(object): 129 | def __init__(self): 130 | pass 131 | 132 | def add_app(self, app_id, app_path): 133 | pass 134 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.20.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='PyDial', 6 | version='0.0.1', 7 | license='MIT', 8 | url='https://github.com/claycollier/pydial', 9 | author='Clay Collier', 10 | author_email='clay.collier@gmail.com', 11 | description='Simple DIAL protocol client and server for Python.', 12 | packages=['pydial'], 13 | zip_safe=False, 14 | include_package_data=True, 15 | platforms='any', 16 | install_requires=['requests'], 17 | classifiers=[ 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Operating System :: OS Independent', 21 | 'Programming Language :: Python', 22 | 'Topic :: Software Development :: Libraries :: Python Modules' 23 | ] 24 | ) 25 | --------------------------------------------------------------------------------