diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c07fbf --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2011, Per Rovegård +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the authors nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README b/README new file mode 100644 index 0000000..9c54ed7 --- /dev/null +++ b/README @@ -0,0 +1,68 @@ +airpnp 0.1 +18 Jul 2011 +README +================= + +Airpnp is a simple server that acts as a bridge between AirPlay devices (such as +the iPhone or the iPad) and regular UPnP media renderers. Its mode of operation +can be summarized as follows: + +* When running, detects UPnP devices on the network through M-SEARCH discovery + and notification monitoring. +* For each MediaRenderer found, publishes an AirPlay service with the + corresponding name. +* Converts incoming AirPlay commands to UPnP control messages which are sent to + the media renderer. + +The software is based on totem-plugin-airplay version 1.0.2, which is included. +The server parts have been rewritten to use Twisted-based networking. The +official home for the plugin is: + + http://cgit.sukimashita.com/totem-plugin-airplay.git/. + +The software also uses UPnP code from pyupnp, which is included. The official +home for pyupnp is: + + http://code.google.com/p/pyupnp/ + + +Dependencies +------------ +The code has been tested with Python 2.7, but probably works with some earlier +2.x versions as well. + +External dependencies include Twisted and ElementTree. + + +Installation +------------ +There is currently nothing to install. Simply change to the airpnp directory +and run: + + python airpnp.py + +To run unit tests, run the following from the top-level directory: + + python run_tests.py + + +Contact Information +------------------- +Author: Per Rovegård +Internet: http://airpnp.finkod.se +E-mail: airpnp@finkod.se + + +Copyright and Licensing +----------------------- +Pyupnp is licensed using the 3-clause BSD license. Totem-plugin-airplay is +licensed using the MIT license. Airpnp as a whole is licensed using the +3-clause BSD license. + +See the file LICENSE for the full license text. + +Changelog +--------- +Version 0.1: +First public release + diff --git a/airpnp/AirPlayService.py b/airpnp/AirPlayService.py new file mode 100644 index 0000000..c144d7a --- /dev/null +++ b/airpnp/AirPlayService.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Martin S. +# +# 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: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# 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. + +import platform +import socket +import threading +import time +import uuid +import logging + +from datetime import datetime, date +from urlparse import urlparse, parse_qsl +from ZeroconfService import ZeroconfService + +from twisted.internet.protocol import Protocol, Factory +from httplib import HTTPMessage +from cStringIO import StringIO + +log = logging.getLogger('airpnp.airplayservice') + +__all__ = ["BaseAirPlayRequest", "AirPlayService", "AirPlayProtocolHandler"] + +class Request(object): + "Request class used by AirPlayProtocolBase." + + # buffer for holding received data + buffer = "" + + # header dictionary, parsed from data + headers = None + + +class AirPlayProtocolBase(Protocol): + + request = None + + def connectionMade(self): + log.info('AirPlay connection from %r', (self.transport.getPeer(), )) + + def dataReceived(self, data): + if self.request is None: + self.request = Request() + + r = self.request + r.buffer += data + + if r.headers is None and r.buffer.find("\r\n\r\n") != -1: + # decode the header + # we split the message into HTTP headers and content body + header, body = r.buffer.split("\r\n\r\n", 1) + + # separate the request line + reqline, headers = header.split("\r\n", 1) + + # read request parameters + r.type_, r.uri, version = reqline.split() + + # parse the HTTP headers + r.headers = HTTPMessage(StringIO(headers)) + + # parse any uri query parameters + r.params = None + if (r.uri.find('?')): + url = urlparse(r.uri) + if (url[4] is not ""): + r.params = dict(parse_qsl(url[4])) + r.uri = url[2] + + # find out the size of the body + r.content_length = int(r.headers['Content-Length']) + + # reset the buffer to only contain the body part + r.buffer = body + + log.debug('Received AirPlay headers, uri = %s, content-length = %d' + % (r.uri, r.content_length)) + + if not r.headers is None and len(r.buffer) == r.content_length: + r.body = r.buffer + log.debug('Received entire AirPlay message, body length = %d, processing...' + % (len(r.body), )) + self.process_message(r) + + self.request = None + + def process_message(self, request): + pass + + +class AirPlayProtocolHandler(AirPlayProtocolBase): + + def process_message(self, request): + try: + return self._process(request) + except: + answer = self.create_request(503) + return answer + + def _process(self, request): + answer = "" + service = self.factory.service + + # process the request and run the appropriate callback + if (request.uri.find('/playback-info')>-1): + self.playback_info() + content = '\ +\ +\ +\ +duration\ +%f\ +position\ +%f\ +rate\ +%f\ +playbackBufferEmpty\ +<%s/>\ +playbackBufferFull\ +\ +playbackLikelyToKeepUp\ +\ +readyToPlay\ +<%s/>\ +loadedTimeRanges\ +\ + \ + duration\ + %f\ + start\ + 0.000000\ + \ +\ +seekableTimeRanges\ +\ + \ + duration\ + %f\ + start\ + 0.000000\ + \ +\ +\ +' + d, p = service.get_scrub() + if (d+p == 0): + playbackBufferEmpty = 'true' + readyToPlay = 'false' + else: + playbackBufferEmpty = 'false' + readyToPlay = 'true' + + content = content % (float(d), float(p), int(service.is_playing()), playbackBufferEmpty, readyToPlay, float(d), float(d)) + answer = self.create_request(200, "Content-Type: text/x-apple-plist+xml", content) + elif (request.uri.find('/play')>-1): + parsedbody = HTTPMessage(StringIO(request.body)) + service.play(parsedbody['Content-Location'], float(parsedbody['Start-Position'])) + answer = self.create_request() + elif (request.uri.find('/stop')>-1): + service.stop(request.headers) + answer = self.create_request() + elif (request.uri.find('/scrub')>-1): + if request.type_ == 'GET': + d, p = service.get_scrub() + content = "duration: " + str(float(d)) + content += "\nposition: " + str(float(p)) + answer = self.create_request(200, "", content) + elif request.type_ == 'POST': + service.set_scrub(float(request.params['position'])) + answer = self.create_request() + elif (request.uri.find('/reverse')>-1): + service.reverse(request.headers) + answer = self.create_request(101) + elif (request.type_ == 'POST' and request.uri.find('/rate')>-1): + service.rate(float(request.params['value'])) + answer = self.create_request() + elif (request.type_ == 'PUT' and self.uri.find('/photo')>-1): + self.photo(request.body, request.headers['X-Apple-Transition']) + answer = self.create_request() + elif (request.uri.find('/slideshow-features')>-1): + answer = self.create_request(404) + elif (request.type_ == 'GET' and request.uri.find('/server-info')>-1): + self.server_info() + content = '\ +\ +\ +\ +deviceid\ +%s\ +features\ +%d\ +model\ +%s\ +protovers\ +1.0\ +srcvers\ +101.10\ +\ +' + content = content % (service.deviceid, service.features, service.model) + answer = self.create_request(200, "Content-Type: text/x-apple-plist+xml", content) + else: + log.error("ERROR: AirPlay - Unable to handle request \"%s\"" % + (self.uri)) + answer = self.create_request(404) + + if(answer is not ""): + self.transport.write(answer) + + def get_datetime(self): + today = datetime.now() + datestr = today.strftime("%a, %d %b %Y %H:%M:%S") + return datestr+" GMT" + + def create_request(self, status = 200, header = "", body = ""): + clength = len(bytes(body)) + if (status == 200): + answer = "HTTP/1.1 200 OK" + elif (status == 404): + answer = "HTTP/1.1 404 Not Found" + elif (status == 503): + answer = "HTTP/1.1 503 Service Unavailable" + elif (status == 101): + answer = "HTTP/1.1 101 Switching Protocols" + answer += "\nUpgrade: PTTH/1.0" + answer += "\nConnection: Upgrade" + answer += "\nDate: " + self.get_datetime() + answer += "\nContent-Length: " + str(clength) + if (header != ""): + answer += "\n" + header + answer +="\n\n" + answer += body + return answer + + def get_scrub(self): + return False + + def set_scrub(self, position): + return False + + def server_info(self): + return False + + def playback_info(self): + return False + + def play(self, location, position): + return False + + def stop(self, info): + return False + + def reverse(self, info): + return True + + def slideshow_features(self): + return False + + def photo(self, data, transition): + return False + + def rate(self, speed): + return False + + def volume(self, info): + return False + + def authorize(self, info): + return False + + def event(self, info): + return False + + +class AirPlayFactory(Factory): + + protocol = AirPlayProtocolHandler + + def __init__(self, service): + self.service = service + + +class AirPlayService(object): + + def __init__(self, reactor, name=None, host="0.0.0.0", port=22555): + macstr = "%012X" % uuid.getnode() + self.deviceid = ''.join("%s:" % macstr[i:i+2] for i in range(0, len(macstr), 2))[:-1] + self.features = 0x07 # 0x77 on iOS 4.3.1 + self.model = "AppleTV2,1" + + # create TCP server + self.tcp = reactor.listenTCP(port, AirPlayFactory(self), 5) + + # create avahi service + if (name is None): + name = "Airplay Service on " + platform.node() + self.zeroconf_service = ZeroconfService(name, port=port, stype="_airplay._tcp", text=["deviceid="+self.deviceid,"features="+hex(self.features),"model="+self.model]) + + # publish avahi service + self.zeroconf_service.publish() + + log.info("AirPlayService '%s' running at %s:%d" % (name, host, port)) + + def release(self): + # unpublish avahi service + self.zeroconf_service.unpublish() + + # stop listening for requests + self.tcp.stopListening() diff --git a/airpnp/ZeroconfService.py b/airpnp/ZeroconfService.py new file mode 100644 index 0000000..824034e --- /dev/null +++ b/airpnp/ZeroconfService.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2010 Martin S. +# +# 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: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# 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. + +import avahi +import dbus + +__all__ = ["ZeroconfService"] + +class ZeroconfService(object): + def __init__(self, name, port, stype="_http._tcp", domain="", host="", text=""): + self.name = name + self.stype = stype + self.domain = domain + self.host = host + self.port = port + self.text = text + + def publish(self): + bus = dbus.SystemBus() + server = dbus.Interface(bus.get_object(avahi.DBUS_NAME, avahi.DBUS_PATH_SERVER), avahi.DBUS_INTERFACE_SERVER) + + g = dbus.Interface(bus.get_object(avahi.DBUS_NAME, server.EntryGroupNew()), avahi.DBUS_INTERFACE_ENTRY_GROUP) + g.AddService(avahi.IF_UNSPEC, avahi.PROTO_UNSPEC, dbus.UInt32(0), self.name, self.stype, self.domain, self.host, dbus.UInt16(self.port), avahi.string_array_to_txt_array(self.text)) + + g.Commit() + self.group = g + + def unpublish(self): + if self.group is not None: + self.group.Reset() + diff --git a/airpnp/__init__.py b/airpnp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/airpnp/__init__.pyc b/airpnp/__init__.pyc new file mode 100644 index 0000000..cfc37ca Binary files /dev/null and b/airpnp/__init__.pyc differ diff --git a/airpnp/airpnp.py b/airpnp/airpnp.py new file mode 100644 index 0000000..b32bec3 --- /dev/null +++ b/airpnp/airpnp.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011, Per Rovegård +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +import logging.config +import os.path + +RCFILE = os.path.expanduser('~/.airpnprc') + +# Must configure logging before importing other modules +if os.path.isfile(RCFILE): + logging.config.fileConfig(RCFILE) +else: + logging.basicConfig(level='INFO') + +from twisted.internet import reactor +from device_discovery import DeviceDiscoveryService +from AirPlayService import AirPlayService +from util import hms_to_sec, sec_to_hms + + +MEDIA_RENDERER_DEVICE_TYPE = 'urn:schemas-upnp-org:device:MediaRenderer:1' +MEDIA_RENDERER_TYPES = [MEDIA_RENDERER_DEVICE_TYPE, + 'urn:schemas-upnp-org:service:AVTransport:1', + 'urn:schemas-upnp-org:service:ConnectionManager:1', + 'urn:schemas-upnp-org:service:RenderingControl:1'] + +CN_MGR_SERVICE = 'urn:upnp-org:serviceId:ConnectionManager' +AVT_SERVICE = 'urn:upnp-org:serviceId:AVTransport' + +log = logging.getLogger("airpnp.bridge-server") + + +class BridgeServer(DeviceDiscoveryService): + + _ports = [] + + def __init__(self): + DeviceDiscoveryService.__init__(self, MEDIA_RENDERER_TYPES, + [MEDIA_RENDERER_DEVICE_TYPE]) + self._cpts = {} + + def stop(self): + DeviceDiscoveryService.stop(self) + while len(self._cpts) > 0: + cpoint = self._cpts.popitem()[1] + del cpoint + + def on_device_found(self, device): + log.info('Found device %s with base URL %s' % (device, + device.get_base_url())) + self._cpts[device.UDN] = AVControlPoint(device, port=self._find_port()) + + def on_device_removed(self, device): + log.info('Lost device %s' % (device, )) + cpoint = self._cpts.pop(device.UDN) + self._ports.remove(cpoint.port) + cpoint.release() + + def _find_port(self): + port = 22555 + while port in self._ports: + port += 1 + self._ports.append(port) + return port + + +class AVControlPoint(AirPlayService): + + _uri = None + _pre_scrub = None + _position_pct = None + + def __init__(self, device, host="0.0.0.0", port=22555): + AirPlayService.__init__(self, reactor, device.friendlyName, host, port) + self._connmgr = device.get_service_by_id(CN_MGR_SERVICE) + self._avtransport = device.get_service_by_id(AVT_SERVICE) + self._instance_id = self._allocate_instance_id() + self.port = port + + def release(self): + self._release_instance_id(self._instance_id) + AirPlayService.release(self) + + def get_scrub(self): + posinfo = self._avtransport.GetPositionInfo( + InstanceID=self._instance_id) + if not self._uri is None: + duration = hms_to_sec(posinfo['TrackDuration']) + position = hms_to_sec(posinfo['RelTime']) + log.debug(('get_scrub -> GetPositionInfo -> %s, %s -> ' + + 'returning %f, %f') % (posinfo['TrackDuration'], + posinfo['RelTime'], duration, + position)) + + if not self._position_pct is None: + self._try_seek_pct(duration, position) + + return duration, position + else: + log.debug('get_scrub -> (no URI) -> returning 0.0, 0.0') + return 0.0, 0.0 + + def is_playing(self): + if self._uri is not None: + state = self._get_current_transport_state() + playing = state == 'PLAYING' + log.debug('is_playing -> GetTransportInfo -> %s -> returning %r' % + (state, playing)) + return playing + else: + log.debug('is_playing -> (no URI) -> returning False') + return False + + def _get_current_transport_state(self): + stateinfo = self._avtransport.GetTransportInfo( + InstanceID=self._instance_id) + return stateinfo['CurrentTransportState'] + + def set_scrub(self, position): + if self._uri is not None: + hms = sec_to_hms(position) + log.debug('set_scrub (%f) -> Seek (%s)' % (position, hms)) + self._avtransport.Seek(InstanceID=self._instance_id, + Unit='REL_TIME', Target=hms) + else: + log.debug('set_scrub (%f) -> (no URI) -> saved for later' % + (position, )) + + # save the position so that we can user it later to seek + self._pre_scrub = position + + def play(self, location, position): + log.debug('play (%s, %f) -> SetAVTransportURI + Play' % (location, + position)) + + # start loading of media, also set the URI to indicate that + # we're playing + self._avtransport.SetAVTransportURI(InstanceID=self._instance_id, + CurrentURI=location, + CurrentURIMetaData='') + self._uri = location + + # start playing also + self._avtransport.Play(InstanceID=self._instance_id, Speed='1') + + # if we have a saved scrub position, seek now + if not self._pre_scrub is None: + log.debug('Seeking based on saved scrub position') + self.set_scrub(self._pre_scrub) + + # clear it because we have used it + self._pre_scrub = None + else: + # no saved scrub position, so save the percentage position, + # which we can use to seek once we have a duration + self._position_pct = float(position) + + def stop(self, info): + if self._uri is not None: + log.debug('stop -> Stop') + self._avtransport.Stop(InstanceID=self._instance_id) + + # clear the URI to indicate that we don't play anymore + self._uri = None + else: + log.debug('stop -> (no URI) -> ignored') + + def reverse(self, info): + pass + + def rate(self, speed): + if self._uri is not None: + if (int(float(speed)) >= 1): + state = self._get_current_transport_state() + if not state == 'PLAYING' and not state == 'TRANSITIONING': + log.debug('rate(%r) -> Play' % (speed, )) + self._avtransport.Play(InstanceID=self._instance_id, + Speed='1') + else: + log.debug('rate(%r) -> ignored due to state %s' % (speed, + state)) + + if not self._position_pct is None: + duration, pos = self.get_scrub() + self._try_seek_pct(duration, pos) + else: + log.debug('rate(%r) -> Pause' % (speed, )) + self._avtransport.Pause(InstanceID=self._instance_id) + + def _try_seek_pct(self, duration, position): + if duration > 0: + log.debug(('Has duration %f, can calculate position from ' + + 'percentage %f') % (duration, self._position_pct)) + targetoffset = duration * self._position_pct + + # clear the position percentage now that we've used it + self._position_pct = None + + # do the actual seeking + if targetoffset > position: # TODO: necessary? + self.set_scrub(targetoffset) + + def _allocate_instance_id(self): + iid = '0' + if hasattr(self._connmgr, 'PrepareForConnection'): + log.warn('ConnectionManager::PrepareForConnection not implemented') + return iid + + def _release_instance_id(self, instance_id): + if hasattr(self._connmgr, 'ConnectionComplete'): + log.warn('ConnectionManager::ConnectionComplete not implemented') + + +def main(): + server = BridgeServer() + server.start(reactor) + reactor.addSystemEventTrigger("before", "shutdown", server.stop) + + +if __name__ == "__main__": + reactor.callWhenRunning(main) + reactor.run() diff --git a/airpnp/device.py b/airpnp/device.py new file mode 100644 index 0000000..39b072f --- /dev/null +++ b/airpnp/device.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011, Per Rovegård +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +import new +from upnp import SoapMessage, SoapError, ns +from urlparse import urljoin + +__all__ = [ + 'Device', + 'Service', +] + +log = logging.getLogger("airpnp.device") + +# Mandatory XML attributes for a device +DEVICE_ATTRS = ['deviceType', 'friendlyName', 'manufacturer', + 'modelName', 'modelNumber', 'UDN'] +#TODO, optional: manufacturerURL, modelDescription, modelNumber, modelURL + +# Mandatory XML attributes for a service +SERVICE_ATTRS = ['serviceType', 'serviceId', 'SCPDURL', + 'controlURL', 'eventSubURL'] + +log = logging.getLogger('airpnp.device') + + +def find_elements(element, namespace, path): + """Find elements based on path relative to an element. + + Arguments: + element -- the element the path is relative to + namespace -- the namespace of all elements in the path + path -- the relative path + + Return an iterator with the elements found. + + """ + parts = ['{%s}%s' % (namespace, part) for part in path.split('/')] + newpath = '/'.join(parts) + return element.findall(newpath) + + +def add_xml_attrs(obj, element, namespace, attrs): + """Add attributes to an object based on XML elements. + + Given a list of XML element names, adds corresponding object attributes and + values to an object. + + Arguments: + obj -- object to add attributes to + element -- the element whose child elements are sought based on the + attribute names + namespace -- XML namespace of elements + attrs -- list of attribute names, each of which is expected to match + the tag name of a child element of the given element + + """ + for attr in attrs: + val = element.findtext('{%s}%s' % (namespace, attr)) + if val is None: + raise ValueError('Missing attribute: %s' % (attr, )) + else: + val = val.strip() + log.debug('Setting attribute %s with value %s' % (attr, val)) + setattr(obj, attr, val) + + +class CommandError(Exception): + pass + + +class Device(object): + + """Class that represents a UPnP device.""" + + def __init__(self, element, base_url): + """Initialize this Device object. + + When a Device object has been created, its Service objects are not + fully initialized. The reason for this is that the Device object should + be possible to inspect for relevance before services are initialized. + + Mandatory child elements of the tag in the device + configuration are added as object attributes to the newly created + object. + + Arguments: + element -- element that contains device configuration + base_url -- the URL where the device configuration resides; used to + resolve relative URLs in the configuration + + """ + self._base_url = base_url + self._services = {} + for deviceElement in find_elements(element, ns.device, 'device'): + add_xml_attrs(self, deviceElement, ns.device, DEVICE_ATTRS) + self._read_services(deviceElement) + + def _read_services(self, element): + for service in find_elements(element, ns.device, + 'serviceList/service'): + self._add_service(Service(self, service, self._base_url)) + + def _add_service(self, service): + self._services[service.serviceId] = service + + def get_services(self): + """Return an immutable list of services for this device.""" + return self._services.viewvalues() + + def get_service_by_id(self, sid): + """Return a service based on its ID.""" + return self._services[sid] + + def get_base_url(self): + """Return the base URL of the device configuration.""" + return self._base_url + + def __str__(self): + return '%s [UDN=%s]' % (self.friendlyName, self.UDN) + + +class Service(object): + + """Class that represents a UPnP service. + + Once initialized, service actions can be invoked as regular methods, + although input arguments must be given as a keyword dictionary. Output + arguments are likewise returned as a dictionary. + + """ + + def __init__(self, device, element, base_url): + """Initialize this Service object partly. + + Initialization of a Service object is done in two steps. Creating + an object only ensures that mandatory child elements of the + tag in the device configuration are added as object attributes to the + newly created object. The initialize method must be called to also + add service actions as object methods. + + Arguments: + device -- the Device object that owns this service + element -- element within the device configuration that contains + basic service configuration + base_url -- URL of the device configuration; used to resolve relative + URLs found in the service configuration + + """ + add_xml_attrs(self, element, ns.device, SERVICE_ATTRS) + self._base_url = base_url + self._resolve_urls([attr for attr in SERVICE_ATTRS if + attr.endswith('URL')], base_url) + self._device = device + + def initialize(self, scpd_element, soap_sender): + """Initialize this service object with service actions. + + Each service action is added as a method on this object. + + Arguments: + scpd_element -- service configuration retrieved from the SCPD URL + soap_sender -- callable used to send SOAP messages, receives the + device, the control URL and the SoapMessage object + + """ + #TODO: better name + self._add_actions(scpd_element, soap_sender) + + def _add_actions(self, element, soap_sender): + for action in find_elements(element, ns.service, 'actionList/action'): + act = Action(self._device, action, soap_sender) + log.debug('Adding action with name %s' % (act.name, )) + method = new.instancemethod(act, self, self.__class__) + setattr(self, act.name, method) + + def _resolve_urls(self, attrs, base_url): + for attr in attrs: + val = getattr(self, attr) + newval = urljoin(base_url, val) + setattr(self, attr, newval) + + +class Action(object): + + def __init__(self, device, element, soap_sender): + add_xml_attrs(self, element, ns.service, ['name']) + self._arguments = [] + self._add_arguments(element) + self._soap_sender = soap_sender + self._device = device + + def _add_arguments(self, element): + for argument in find_elements(element, ns.service, + 'argumentList/argument'): + self._arguments.append(Argument(argument)) + + def __call__(self, service, **kwargs): + log.debug('Sending SOAP message for action %s, args = %r' % + (self.name, kwargs)) + msg = SoapMessage(service.serviceType, self.name) + + # arrange the arguments by direction + inargs = [arg for arg in self._arguments if arg.direction == 'in'] + outargs = [arg for arg in self._arguments if arg.direction == 'out'] + + # update the message with input argument values + for arg in inargs: + val = kwargs.get(arg.name) + if val is None: + raise KeyError('Missing IN argument: %s' % (arg.name, )) + msg.set_arg(arg.name, val) + + # send the message + response = self._soap_sender(self._device, service.controlURL, msg) + if isinstance(response, SoapError): + raise CommandError('Command error: %s/%s' % (response.code, + response.desc)) + # populate the output dictionary + ret = {} + for arg in outargs: + ret[arg.name] = response.get_arg(arg.name) + return ret + + +class Argument(object): + + def __init__(self, element): + add_xml_attrs(self, element, ns.service, ['name', 'direction', + 'relatedStateVariable']) diff --git a/airpnp/device.pyc b/airpnp/device.pyc new file mode 100644 index 0000000..b654226 Binary files /dev/null and b/airpnp/device.pyc differ diff --git a/airpnp/device_builder.py b/airpnp/device_builder.py new file mode 100644 index 0000000..638f85c --- /dev/null +++ b/airpnp/device_builder.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011, Per Rovegård +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +from util import split_usn, fetch_url +from xml.etree import ElementTree +from device import Device + +__all__ = [ + 'AsyncDeviceBuilder', + 'DeviceEvent', + 'DeviceContainer', +] + +log = logging.getLogger("airpnp.device-builder") + +class DeviceContainer(object): + + """Container for a Device object. + + An instance is initialized from a dictionary of HTTP headers, from which + the USN is extracted and split into UDN and service/device type. A Device + object can be attached using the set_device(device) method. + + """ + + # Attribute for the attached Device object. + _device = None + + def __init__(self, headers): + """Initialize this object from a dictionary of HTTP headers. + + The "USN" header must be present in the dictionary, or a KeyError will + be raised. The headers are stored for later retrieval. + + """ + usn = headers['USN'] + self._udn, self._type = split_usn(usn) + self._headers = headers + + def get_udn(self): + """Return the UDN extracted during object initialization. + + The UDN is extracted from the USN header. + + """ + return self._udn + + def get_type(self): + """Return the device/service type extracted during initialization. + + The typs is extracted from the USN header. If the USN header does not + contain a type, the return string is the empty string. + + """ + return self._type + + def get_headers(self): + """Return the dictionary of HTTP headers passed to the constructor.""" + return self._headers + + def set_device(self, device): + """Attach a Device object to this container.""" + self._device = device + + def get_device(self): + """Return the Device object from this container. + + If no Device object has been attached, return None. + + """ + return self._device + + def has_device(self): + """Determine if this container has an attached Device object.""" + return not self._device is None + + +class DeviceEvent(object): + + """Event class for events fired from an AsyncDeviceBuilder.""" + + def __init__(self, source, device_container, error=None): + """Initialize a new event object. + + Arguments: + source -- the originator of the event (typically the builder) + device_container -- the DeviceContainer instance passed to the builder + error -- an error object if an error was raised + + """ + self._source = source + self._device_container = device_container + self._error = error + + def get_udn(self): + """Return the UDN of the Device associated with this event.""" + return self._device_container.get_udn() + + def get_device(self): + """Return the Device object associated with this event.""" + return self._device_container.get_device() + + def get_source(self): + """Return the originator of this event.""" + return self._source + + def get_error(self): + """Return the error, if any, associated with this event. + + If no error occurred during Device building, return None. + + """ + return self._error + + +class AsyncDeviceBuilder(object): + + """Device builder that builds a Device object from UPnP HTTP headers. + + The device builder extracts the location of the root device from the + "LOCATION" header, and downloads device information from there. If the + device type passes a predefined filter, the builder continues to download + and initialize device services. + + The device building is asynchronous, and executes in a separate thread. + + Upon completion, filter rejection or error, an event is fired to registered + listeners (each of which must be a callable). The listener received a + single DeviceEvent object. + + """ + + # List of listeners for 'finished' events. + _finished_listeners = [] + + # List of listeners for 'rejected' events. + _rejected_listeners = [] + + # List of listeners for 'error' events. + _error_listeners = [] + + def __init__(self, reactor, soap_sender, filter_=None): + """Initialize a device builder. + + Arguments: + reactor -- Twisted reactor used for asynchronous operation. + soap_sender -- passed to the created Device object + filter_ -- optional callable that receives the created device to + determine if the builder should continue with service + initialization + + If the filter returns False for a device, a 'rejected' event will be + fired to registered listeners. + + """ + self.reactor = reactor + self._filter = filter_ + self._soap_sender = soap_sender + + def add_finished_listener(self, listener): + """Add a listener for 'finished' events. + + A 'finished' event is fired when a device has been built and its + services have been initialized. The listener must be a callable, and + will receive a DeviceEvent object. + + """ + self._finished_listeners.append(listener) + + def add_rejected_listener(self, listener): + """Add a listener for 'rejected' events. + + A 'rejected' event is fired when a device has been built, but the + filter passed to the constructor has rejected the device by returning + False. The services of the Device object are not initialized. The + listener must be a callable, and will receive a DeviceEvent object. + + """ + self._rejected_listeners.append(listener) + + def add_error_listener(self, listener): + """Add a listener for 'error' events. + + An 'error' event is fired if an error is raised during device building. + The listener must be a callable, and will receive a DeviceEvent object. + + """ + self._error_listeners.append(listener) + + def build(self, container): + """Build a Device object asynchronously. + + Arguments: + container -- DeviceContainer instance that contains UPnP HTTP headers + that point to required device resources + + """ + self.reactor.callInThread(self._create_device, container) + + def _create_device(self, container): + try: + # create a new Device and attach it to the container + device = self._new_device(container) + container.set_device(device) + + # determine if the device is accepted + accepted = self._filter is None or self._filter(device) + + # if so, continue with services, otherwise we're done + if accepted: + # init each service + for service in device.get_services(): + self._init_service(service) + + # finished, back to main thread + self.reactor.callFromThread(self._device_finished, container) + else: + # rejected device, back to main thread + self.reactor.callFromThread(self._device_rejected, container) + except BaseException, err: + log.error('An error occurred while creating a Device object: %s' % + (err, )) + # error, back to main thread + self.reactor.callFromThread(self._device_error, err, container) + + def _init_service(self, service): + scpd_handle = fetch_url(service.SCPDURL) + scpd_element = ElementTree.parse(scpd_handle) + service.initialize(scpd_element, self._soap_sender) + + def _new_device(self, container): + location = self._get_location(container) + handle = fetch_url(location) + element = ElementTree.parse(handle) + return Device(element, location) + + def _get_location(self, container): + headers = container.get_headers() + return headers['LOCATION'] + + def _device_finished(self, container): + event = DeviceEvent(self, container) + fire_event(event, self._finished_listeners) + + def _device_rejected(self, container): + event = DeviceEvent(self, container) + fire_event(event, self._rejected_listeners) + + def _device_error(self, error, container): + event = DeviceEvent(self, container, error) + fire_event(event, self._error_listeners) + + +def fire_event(event, listener_list): + for listener in listener_list: + listener(event) diff --git a/airpnp/device_discovery.py b/airpnp/device_discovery.py new file mode 100644 index 0000000..78e0da3 --- /dev/null +++ b/airpnp/device_discovery.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011, Per Rovegård +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +import sys +from upnp import UpnpBase, MSearchRequest, SoapError +from cStringIO import StringIO +from httplib import HTTPMessage +from twisted.internet import reactor +from twisted.internet.task import LoopingCall +from util import send_soap_message, split_usn, get_max_age +from device_builder import AsyncDeviceBuilder, DeviceContainer + +log = logging.getLogger("airpnp.device-discovery") + +# Seconds between m-search discoveries +DISCOVERY_INTERVAL = 300 + + +class ActiveDeviceContainer(DeviceContainer): + + """DeviceContainer sub class that adds the notion of an active device. + + The first time a client calls the touch(headers) method, a timer is started + based on the "max-age" directive in the "CACHE-CONTROL" HTTP header. + Subsequent calls to the touch(headers) method renew (reset) the timer. When + the timer has reached zero, an event is fired to all 'expire' listeners. + + """ + + # Expire timer, initially not created + _expire_timer = None + + # List of 'expire' listeners + _expire_listeners = [] + + def add_expire_listener(self, listener): + """Add an 'expire' listener. + + The listener must be a callable and will receive the UDN of the device + whose timer has expired. + + """ + self._expire_listeners.append(listener) + + def touch(self, headers): + """Start or reset the device timer based on UPnP HTTP headers. + + If the expire timer hasn't been started before, it will be when this + method is called. Otherwise, the timer will be reset. The timer time + is taken from the "max-age" directive of the "CACHE-CONTROL" header. + + Arguments: + headers -- dictionary of UPnP HTTP headers + + """ + device = self.get_device() + seconds = get_max_age(headers) + if not seconds is None: + udn = device.UDN + timer = self._expire_timer + if timer is None or not timer.active(): + log.debug('Creating expire timer for %d seconds for device %s' + % (seconds, device)) + newtimer = reactor.callLater(seconds, self._device_expired, + udn) + self._expire_timer = newtimer + else: + log.debug(('Resetting expire timer for %d seconds for ' + + 'device %s') % (seconds, device)) + timer.reset(seconds) + + def stop(self): + """Stop the device timer if it is running.""" + if not self._expire_timer is None and self._expire_timer.active(): + self._expire_timer.cancel() + + def _device_expired(self, udn): + """Handle the case when a device hasn't renewed itself.""" + for listener in self._expire_listeners: + listener(udn) + + +class DeviceDiscoveryService(object): + + """Service that discovers and tracks UPnP devices. + + Once started, this service will monitor the network for UPnP devices of a + specific type. If a device is found, the on_device_found(device) method is + called. When a device disappears, the on_device_removed(device) method is + called. A client should subclass this class and implement those methods. + + """ + + # Dictionary of DeviceContainer objects, keyed by UDN + _devices = {} + + # List of UDNs of devices that are being ignored + _ignored = [] + + # Device/service types to look for + _sn_types = ['upnp:rootdevice'] + + def __init__(self, sn_types=[], device_types=[]): # pylint: disable-msg=W0102 + """Initialize the service. + + Arguments: + sn_types -- list of device and/or service types to look for; other + types will be ignored. "upnp:rootdevice" is + automatically tracked, and should not be in this list. + device_types -- list of interesting device types, used to filter out + devices based on their "deviceType" attribute + + """ + self._sn_types.extend(sn_types) + self._dev_types = device_types + + def on_device_found(self, device): + """Called when a device has been found.""" + pass + + def on_device_removed(self, device): + """Called when a device has disappeared.""" + pass + + def start(self, reactor): + """Start device discovery and tracking. + + Start a listener for UPnP notifications, as well as a periodic + distribution of M-SEARCH messages. + + Arguments: + reactor -- the Twisted reactor to use for network communication + + """ + log.info('Starting device discovery') + + def ssend(device, url, msg): + return self._send_soap_message(device, url, msg, reactor) + + # Create a device builder + self._builder = AsyncDeviceBuilder(reactor, ssend, + lambda device: device.deviceType + in self._dev_types) + self._builder.add_finished_listener(self._device_finished) + self._builder.add_rejected_listener(self._device_rejected) + self._builder.add_error_listener(self._device_error) + + # Start listening for UPnP notifications + self._ul = UpnpListener(self._datagram_handler) + self._ul.start(reactor) + + # Send M-SEARCH requests periodically, starting now + msearch = MSearchRequest(self._datagram_handler) + self._loop = LoopingCall(msearch_discover, msearch, reactor) + self._loop.start(DISCOVERY_INTERVAL, True) + + def stop(self): + """Stop device discovery and tracking.""" + log.info('Stopping device discovery') + + # Stop periodic M-SEARCH requests + self._loop.stop() + + # Stop listening for UPnP notifications + self._ul.stop() + + # Kill device containers (timers) + while len(self._devices) > 0: + holder = self._devices.popitem()[1] + holder.stop() + + def _datagram_handler(self, datagram, address): + """Process incoming datagram, either response or notification.""" + req_line, data = datagram.split('\r\n', 1) + headers = HTTPMessage(StringIO(data)) + method = req_line.split(' ')[0] + if method == 'NOTIFY': + self._handle_notify(headers) + else: + self._handle_response(headers) + + def _handle_notify(self, headers): + """Handle a notification message from a device.""" + nts = headers['NTS'] + udn = split_usn(headers['USN'])[0] + if not udn in self._ignored: + log.debug('Got NOTIFY from device with UDN %s, NTS = %s' % (udn, + nts)) + if nts == 'ssdp:alive': + self._handle_response(headers) + elif nts == 'ssdp:byebye': + log.debug('Got bye-bye from device with UDN %s' % (udn, )) + self._device_expired(udn) + + def _device_expired(self, udn): + """Handle a bye-bye message from a device, or lack of renewal..""" + if udn in self._devices: + log.debug('Device with UDN %s expired, cleaning up...' % (udn, )) + device = self._devices.pop(udn) + device.stop() + self.on_device_removed(device) + + def _handle_response(self, headers): + """Handle response to M-SEARCH message.""" + usn = headers.get('USN') + if not usn is None: + udn = split_usn(usn)[0] + if not udn in self._ignored: + adc = self._devices.get(udn) + if adc is None: + self._new_device(headers) + elif adc.has_device(): + adc.touch(headers) + + def _new_device(self, headers): + """Start building a device if it seems to be a proper one.""" + adc = ActiveDeviceContainer(headers) + if adc.get_type() in self._sn_types: + log.debug(('Found potential device candidate with type = %s, ' + + 'location = %s') % (adc.get_type(), headers['LOCATION'])) + + # Put the device container in our dictionary before starting the + # asyncrhonous build, as a guard so that we won't try multiple + # builds for the same device. + self._devices[adc.get_udn()] = adc + self._builder.build(adc) + + def _send_soap_message(self, device, url, msg, reactor): + """Send a SOAP message and do error handling.""" + err = False + try: + answer = send_soap_message(url, msg) + if isinstance(answer, SoapError): + log.error('Error response for %s command to device %s: %s/%s' % + (msg.get_name(), device, answer.code, answer.desc)) + err = True + return answer + except: + error = sys.exc_info()[0] + log.error('Error for %s command to device %s: %s' % + (msg.get_name(), device, error)) + err = True + raise error + finally: + if err: + reactor.callLater(0, self._flip, device, reactor) + + def _flip(self, device, reactor): + """Simulate a temporary device removal.""" + self.on_device_removed(device) + reactor.callLater(1, self.on_device_found, device) + + def _device_error(self, event): + """Handle error that occurred when building a device.""" + # Remove the device so that we retry it on the next notify + # or m-search result. + device = self._devices.pop(event.get_udn()) + device.stop() + + def _device_rejected(self, event): + """Handle device reject, mismatch against desired device type.""" + udn = event.get_udn() + device = self._devices.pop(udn) + device.stop() + log.debug('Ignoring device with UDN %s' % (udn, )) + self._ignored.append(udn) + + def _device_finished(self, event): + """Handle completion of device building.""" + device = event.get_device() + log.debug('Found matching device type: %s (%s)' % + (device.deviceType, device)) + + # Start the device container timer + adc = self._devices[event.get_udn()] + adc.add_expire_listener(self._device_expired) + adc.touch(adc.get_headers()) + + # Publish the device + self.on_device_found(device) + + +class UpnpListener(UpnpBase): + + def __init__(self, handler): + UpnpBase.__init__(self) + self.handler = handler + + def datagramReceived(self, datagram, address, outip): + self.handler(datagram, address) + + +def msearch_discover(msearch, reactor): + """Send M-SEARCH device discovery requests.""" + reactor.callLater(0, msearch.send, reactor, 'ssdp:all', 5) + reactor.callLater(1, msearch.send, reactor, 'ssdp:all', 5) diff --git a/airpnp/upnp.py b/airpnp/upnp.py new file mode 100644 index 0000000..f54fb7d --- /dev/null +++ b/airpnp/upnp.py @@ -0,0 +1,1131 @@ +# Copyright (c) 2009, Takashi Ito +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import os +import socket +import time +import re +from cStringIO import StringIO +from xml.etree import ElementTree as ET +from httplib import HTTPMessage +from random import random + +from zope.interface import Interface, implements +from twisted.internet import error +from twisted.internet.udp import MulticastPort +from twisted.internet.protocol import DatagramProtocol +from twisted.internet.threads import blockingCallFromThread +from twisted.python.threadpool import ThreadPool +from twisted.python.threadable import isInIOThread +from twisted.web import server, resource, wsgi, static +from routes import Mapper +from routes.middleware import RoutesMiddleware +import webob + + +__all__ = [ + 'UpnpNamespace', + 'UpnpDevice', + 'UpnpBase', + 'SoapMessage', + 'SoapError', + 'IContent', + 'FileContent', + 'xml_tostring', + 'make_gmt', + 'to_gmt', + 'not_found', + 'ns', + 'nsmap', + 'toxpath', + 'mkxp', + 'StreamingServer', + 'MSearchRequest', + 'ByteSeekMixin', + 'TimeSeekMixin', + 'parse_npt', + 'to_npt', + 'parse_duration', + 'to_duration', +] + + +nsmap = { + 'device': 'urn:schemas-upnp-org:device-1-0', + 'service': 'urn:schemas-upnp-org:service-1-0', + 'control': 'urn:schemas-upnp-org:control-1-0', + 'dlna': 'urn:schemas-dlna-org:device-1-0', + 's': 'http://schemas.xmlsoap.org/soap/envelope/', + 'dc': 'http://purl.org/dc/elements/1.1/', + 'upnp': 'urn:schemas-upnp-org:metadata-1-0/upnp/', + 'didl': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/', +} + +class UpnpNamespaceMeta(type): + def __new__(cls, name, bases, d): + for prefix, uri in nsmap.items(): + d[prefix] = uri + return type.__new__(cls, name, bases, d) + +class UpnpNamespace(object): + __metaclass__ = UpnpNamespaceMeta + +ns = UpnpNamespace + +def is_new_etree(et): + return hasattr(et, 'register_namespace') + +def register_namespace(et, prefix, uri): + if hasattr(et, 'register_namespace'): + et.register_namespace(prefix, uri) + else: + et._namespace_map[uri] = prefix + +# register upnp/dlna namespaces +register_namespace(ET, 's', ns.s) +register_namespace(ET, 'dc', ns.dc) +register_namespace(ET, 'upnp', ns.upnp) +register_namespace(ET, 'dlna', ns.dlna) + +def toxpath(path, default_ns=None, nsmap=nsmap): + nodes = [] + pref = '{%s}' % default_ns if default_ns else '' + for node in [x.split(':', 1) for x in path.split('/')]: + if len(node) == 1: + nodes.append(pref + node[0]) + else: + if node[0] in nsmap: + nodes.append('{%s}%s' % (nsmap[node[0]], node[1])) + else: + nodes.append(':'.join(node)) + return '/'.join(nodes) + +def mkxp(default_ns=None, nsmap=nsmap): + def _mkxp(path, default_ns=default_ns, nsmap=nsmap): + return toxpath(path, default_ns, nsmap) + return _mkxp + +def get_outip(remote_host): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.connect((remote_host, 80)) + return sock.getsockname()[0] + +def make_gmt(): + return to_gmt(time.gmtime()) + +def to_gmt(t): + return time.strftime('%a, %d %b %Y %H:%M:%S GMT', t) + +def not_found(environ, start_response): + headers = [ + ('Content-type', 'text/plain'), + ('Connection', 'close'), + ] + start_response('404 Not Found', headers) + return ['Not Found'] + +def build_packet(first_line, packet): + lines = [first_line] + lines += [': '.join(t) for t in packet] + lines += ['', ''] + return '\r\n'.join(lines) + +def xml_tostring(elem, encoding='utf-8', xml_decl=None, default_ns=None): + class dummy(object): + pass + data = [] + fileobj = dummy() + + if is_new_etree(ET): + fileobj.write = data.append + ET.ElementTree(elem).write(fileobj, encoding, xml_decl, default_ns) + else: + def _write(o): + # workaround + l = (o, ('\n' % encoding) + register_namespace(ET, 'tmp', default_ns) + ET.ElementTree(elem).write(fileobj, encoding) + + return "".join(data) + + +class SoapMessage(object): + + TEMPLATE = """ + + + + + +""" + + def __init__(self, serviceType, name, doc=None): + if doc == None: + xml = self.TEMPLATE % (name, serviceType) + doc = ET.parse(StringIO(xml)) + + self.doc = doc.getroot() + body = self.doc.find('{%s}Body' % ns.s) + + if name == None or serviceType == None: + tag = body[0].tag + if tag[0] == '{': + serviceType, name = tag[1:].split('}', 1) + else: + serviceType, name = '', tag + + self.u = serviceType + self.action = body.find('{%s}%s' % (self.u, name)) + + def get_name(self): + return self.action.tag.split('}')[1] + + def get_header(self): + return '"%s#%s"' % (self.u, self.get_name()) + + @classmethod + def parse(cls, fileobj, serviceType=None, name=None): + return cls(serviceType, name, ET.parse(fileobj)) + + def set_arg(self, name, value): + elem = self.action.find(name) + if elem == None: + elem = ET.SubElement(self.action, name) + elem.text = value + + def set_args(self, args): + for name, value in args: + self.set_arg(name, value) + + def get_arg(self, name, default=''): + return self.action.findtext(name, default) + + def get_args(self): + args = [] + for elem in self.action: + args.append((elem.tag, elem.text)) + return args + + def del_arg(self, name): + elem = self.action.find(name) + if elem != None: + self.action.remove(elem) + + def tostring(self, encoding='utf-8', xml_decl=True): + register_namespace(ET, 'u', self.u) + return xml_tostring(self.doc, encoding, xml_decl) + + +class SoapError(object): + + TEMPLATE = """ + + + + s:Client + UPnPError + + + %s + %s + + + + + +""" + + def __init__(self, code=501, desc='Action Failed'): + self.code = str(code) + self.desc = desc + + def tostring(self): + return self.TEMPLATE % (self.code, self.desc) + + @classmethod + def parse(cls, text): + doc = ET.XML(text) + elem = doc.find(toxpath('s:Body/s:Fault/detail/control:UPnPError')) + code = int(elem.findtext(toxpath('control:errorCode'))) + desc = elem.findtext(toxpath('control:errorDescription'), '') + return SoapError(code, desc) + + +class SoapMiddleware(object): + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + soapaction = environ.get('HTTP_SOAPACTION', '').strip('"').split('#', 1) + if len(soapaction) == 2: + environ['upnp.soap.serviceType'] = soapaction[0] + environ['upnp.soap.action'] = soapaction[1] + return self.app(environ, start_response) + + +class SSDPServer(DatagramProtocol): + def __init__(self, owner): + self.owner = owner; + + def datagramReceived(self, data, addr): + self.owner.datagramReceived(data, addr, get_outip(addr[0])) + + +xp = mkxp(ns.device) + + +class UpnpDevice(object): + + max_age = 1800 + SERVER_NAME = 'OS/x.x UPnP/1.0 py/1.0' + OK = ('200 OK', 'text/xml; charset="utf-8"') + NOT_FOUND = ('404 Not Found', 'text/plain') + SERVER_ERROR = ('500 Internal Server Error', 'text/plain') + + def __init__(self, udn, dd, soap_app, server_name=SERVER_NAME, mapper=None): + self.udn = udn + self.ssdp = None + self.http = None + self.port = 0 + self.server_name = server_name + self.soap_app = SoapMiddleware(soap_app) if soap_app else None + + # mapper + if mapper == None: + mapper = UpnpBase.make_mapper() + self.mapper = mapper + + # load DD + self.dd = ET.parse(dd) + xml_dir = os.path.dirname(dd) + + # set UDN + self.dd.find(xp('device/UDN')).text = udn + + # get deviceType + self.deviceType = self.dd.findtext(xp('device/deviceType')) + + self.services = {} + self.serviceTypes = [] + for service in self.dd.find(xp('device/serviceList')): + sid = service.findtext(xp('serviceId'), '') + + # SCPDURL + scpdurl = service.find(xp('SCPDURL')) + self.services[sid] = ET.parse(os.path.join(xml_dir, scpdurl.text)) + scpdurl.text = self.make_upnp_path(sid) + + # controlURL + service.find(xp('controlURL')).text = self.make_upnp_path(sid, 'soap') + + # eventSubURL + service.find(xp('eventSubURL')).text = self.make_upnp_path(sid, 'sub') + + # append serviceType + serviceType = service.findtext('{%s}serviceType' % ns.device, '') + self.serviceTypes.append(serviceType) + + def make_upnp_path(self, sid=None, action='desc'): + kwargs = {'controller': 'upnp', 'action': action, 'udn': self.udn} + if sid != None: + kwargs['sid'] = sid + return self.mapper.generate(**kwargs) + + def make_location(self, ip, port_num): + return 'http://%s:%i%s' % (ip, port_num, self.make_upnp_path()) + + def make_notify_packets(self, host, ip, port_num, nts): + types = ['upnp:rootdevice', self.udn, self.deviceType] + types += self.serviceTypes + packets = [] + + if nts == 'ssdp:alive': + for nt in types: + packet = [ + ('HOST', host), + ('CACHE-CONTROL', 'max-age=%i' % self.max_age), + ('LOCATION', self.make_location(ip, port_num)), + ('NT', nt), + ('NTS', nts), + ('SERVER', self.server_name), + ('USN', self.udn + ('' if nt == self.udn else '::' + nt)), + ] + packets.append(packet) + else: + for nt in types: + packet = [ + ('HOST', host), + ('NT', nt), + ('NTS', nts), + ('USN', self.udn + ('' if nt == self.udn else '::' + nt)), + ] + packets.append(packet) + + return packets + + def make_msearch_response(self, headers, (addr, port), dest): + st = headers.getheader('ST') + sts = ['ssdp:all', 'upnp:rootdevice', self.udn, self.deviceType] + sts += self.serviceTypes + if st not in sts: + return [] + + if st == self.udn: + usns = [''] + elif st == 'ssdp:all': + usns = ['upnp:rootdevice', '', self.deviceType] + self.serviceTypes + else: + usns = [st] + + packets = [] + for usn in usns: + if usn != '': + usn = '::' + usn + packet = [ + ('CACHE-CONTROL', 'max-age=%i' % self.max_age), + ('EXT', ''), + ('LOCATION', self.make_location(addr, port)), + ('SERVER', self.server_name), + ('ST', st), + ('USN', self.udn + usn) + ] + packets.append(packet) + + return packets + + def __call__(self, environ, start_response): + print environ + rargs = environ['wsgiorg.routing_args'][1] + udn = rargs.get('udn', None) + action = rargs.get('action', None) + sid = rargs.get('sid', None) + method = environ['REQUEST_METHOD'] + + body = 'Not Found' + code = self.NOT_FOUND + + if method == 'GET' and action == 'desc': + if sid == None: + code, body = self._get_dd() + elif sid in self.services: + code, body = self._get_scpd(sid) + + elif method == 'POST' and action == 'soap': + if self.soap_app: + return self.soap_app(environ, start_response) + + elif method == 'SUBSCRIBE' or method == 'UNSUBSCRIBE': + # TODO: impl + pass + + headers = [ + ('Content-type', code[1]), + ('Connection', 'close'), + ] + + start_response(code[0], headers) + return [body] + + def _get_dd(self): + body = xml_tostring(self.dd.getroot(), 'utf-8', True, ns.device) + code = self.OK + return code, body + + def _get_scpd(self, sid): + body = xml_tostring(self.services[sid].getroot(), 'utf-8', True, ns.service) + code = self.OK + return code, body + + +class _WSGIResponse(wsgi._WSGIResponse): + def __init__(self, reactor, threadpool, application, request): + wsgi._WSGIResponse.__init__(self, reactor, threadpool, application, request) + self.environ['REMOTE_ADDR'] = request.getClientIP() + self.request.responseHeaders.removeHeader('content-type') + + def run(self): + appIterator = self.application(self.environ, self.startResponse) + + if isinstance(appIterator, FileContent): + def transferFile(): + self._sendResponseHeaders() + static.FileTransfer(appIterator.f, + appIterator.last + 1, + self.request) + self.reactor.callFromThread(transferFile) + return + + for elem in appIterator: + if elem: + self.write(elem) + close = getattr(appIterator, 'close', None) + if close is not None: + close() + if self.started: + def wsgiFinish(): + self.request.finish() + self.reactor.callFromThread(wsgiFinish) + else: + def wsgiSendResponseHeadersAndFinish(): + self._sendResponseHeaders() + self.request.finish() + self.started = True + self.reactor.callFromThread(wsgiSendResponseHeadersAndFinish) + + +class WSGIResource(wsgi.WSGIResource): + def render(self, request): + response = _WSGIResponse(self._reactor, self._threadpool, self._application, request) + response.start() + return server.NOT_DONE_YET + + +class UpnpBase(object): + + SSDP_ADDR = '239.255.255.250' + SSDP_PORT = 1900 + INADDR_ANY = '0.0.0.0' + SOAP_BODY_MAX = 200 * 1024 + _addr = (SSDP_ADDR, SSDP_PORT) + SSDP_INTERVAL = 0.020 + + def __init__(self, mapper=None): + self.started = False + self.reactor = None + self.interfaces = [] + self.tpool = ThreadPool(name=self.__class__.__name__) + self.devices = {} + self.mts = {} + self.deferreds = {} + + # setup route map + if mapper == None: + mapper = self.make_mapper() + self.mapper = mapper + self.app = RoutesMiddleware(self, self.mapper) + + @staticmethod + def make_mapper(): + m = Mapper() + m.connect(None, '/mt/{name}/{id:.*?}', controller='mt', action='get') + m.connect(None, '/upnp/{udn}/{sid}/{action}', controller='upnp', action='desc') + m.connect(None, '/upnp/{udn}/desc', controller='upnp', action='desc') + return m + + def make_mt_path(self, name, id): + return self.mapper.generate(controller='mt', action='get', name=name, id=id) + + def append_device(self, devices, interval=SSDP_INTERVAL): + for device in devices: + delay = 0 + if device.udn in self.devices: + self.remove_device(device.udn) + if interval: + delay = 0.3 + self.devices[device.udn] = device + self._notify(device, 'ssdp:alive', delay, interval) + + def remove_device(self, udn, interval=SSDP_INTERVAL): + try: + device = self.devices[udn] + self._notify(device, 'ssdp:byebye', interval=interval) + del self.devices[udn] + # cancel alive reservation + d = self.deferreds.get(udn) + if d: + d.cancel() + del self.deferreds[udn] + except KeyError: + pass + + def append_mt(self, mt): + if mt.name in self.mts: + self.remove_mt(mt.name) + self.mts[mt.name] = mt + + def remove_mt(self, name): + del self.mts[name] + + def _notify_all(self, nts, interval=SSDP_INTERVAL): + if not self.started: + return + for udn in self.devices: + self._notify(self.devices[udn], nts, interval=interval) + + def _notify(self, device, nts, delay=0, interval=SSDP_INTERVAL): + if not self.started or device.udn not in self.devices: + return + + for ip in self.interfaces: + # create send port + port = MulticastPort(0, None, interface=ip, reactor=self.reactor) + try: + port._bindSocket() + except error.CannotListenError, e: + # in case the ip address changes + continue + + # get real ip + if ip == self.INADDR_ANY: + ip = get_outip(self.SSDP_ADDR) + + # send notify packets + host = self.SSDP_ADDR + ':' + str(self.SSDP_PORT) + for packet in device.make_notify_packets(host, ip, self.port, nts): + buff = build_packet('NOTIFY * HTTP/1.1', packet) + if interval: + self.reactor.callLater(delay, self._send_packet, port, buff, self._addr) + delay += interval + else: + self._send_packet(port, buff, self._addr) + + # reserve the next alive + if nts == 'ssdp:alive': + d = self.deferreds.get(device.udn) + if d and not d.called: + d.cancel() + d = self.reactor.callLater(device.max_age / 2, + self._notify, + device, + nts) + self.deferreds[device.udn] = d + + def _send_packet(self, port, buff, addr): + if self.started: + port.write(buff, addr) + + def datagramReceived(self, data, addr, outip): + if outip not in self.interfaces: + if self.INADDR_ANY not in self.interfaces: + return + + req_line, data = data.split('\r\n', 1) + method, path, version = req_line.split(None, 3) + + # check method + if method != 'M-SEARCH' or path != '*': + return + + # parse header + headers = HTTPMessage(StringIO(data)) + mx = int(headers.getheader('MX')) + + # send M-SEARCH response + for udn in self.devices: + device = self.devices[udn] + delay = random() * mx + for packet in device.make_msearch_response(headers, (outip, self.port), addr): + buff = build_packet('HTTP/1.1 200 OK', packet) + self.reactor.callLater(delay, self._send_packet, self.ssdp, buff, addr) + delay += self.SSDP_INTERVAL + + def __call__(self, environ, start_response): + """ + This function have to be called in a worker thread, not the IO thread. + """ + rargs = environ['wsgiorg.routing_args'][1] + controller = rargs['controller'] + + # Media Transport + if controller == 'mt': + name = rargs['name'] + if name in self.mts: + return self.mts[name](environ, start_response) + else: + return not_found(environ, start_response) + + if controller != 'upnp': + return not_found(environ, start_response) + + try: + udn = rargs['udn'] + if isInIOThread(): + # TODO: read request body + return self.devices[udn](environ, start_response) + else: + # read request body + input = environ['wsgi.input'] + environ['upnp.body'] = input.read(self.SOAP_BODY_MAX) + # call the app in IO thread + args = [udn, environ, start_response] + blockingCallFromThread(self.reactor, self._call_handler, args) + return args[3] + except Exception, e: + #print e + #print 'Unknown access: ' + environ['PATH_INFO'] + return not_found(environ, start_response) + + def _call_handler(self, args): + ret = self.devices[args[0]](args[1], args[2]) + args.append(ret) + + def start(self, reactor, interfaces=[INADDR_ANY], http_port=0): + if self.started: + return + + self.reactor = reactor + self.interfaces = interfaces + if len(self.interfaces) == 0: + self.interfaces.append(self.INADDR_ANY) + + # http server address + if len(self.interfaces) == 1: + interface = self.interfaces[0] + else: + interface = self.INADDR_ANY + + # start http server + self.tpool.start() + resource = WSGIResource(self.reactor, self.tpool, self.app) + self.http = self.reactor.listenTCP(http_port, server.Site(resource)) + self.port = self.http.socket.getsockname()[1] + + # start ssdp server + self.ssdp = self.reactor.listenMulticast(self.SSDP_PORT, + SSDPServer(self), + interface=interface, + listenMultiple=True) + self.ssdp.setLoopbackMode(1) + for ip in self.interfaces: + self.ssdp.joinGroup(self.SSDP_ADDR, interface=ip) + + self.started = True + self._notify_all('ssdp:alive') + + def stop(self): + if not self.started: + return + + self._notify_all('ssdp:byebye', interval=0) + + # stop ssdp server + for ip in self.interfaces: + self.ssdp.leaveGroup(self.SSDP_ADDR, interface=ip) + self.ssdp.stopListening() + + # stop http server + self.tpool.stop() + self.http.stopListening() + + self.started = False + self.interfaces = [] + + +class _dp(DatagramProtocol): + def __init__(self, owner): + self.owner = owner + + def datagramReceived(self, datagram, address): + self.owner(datagram, address) + + +class MSearchRequest(object): + + SSDP_ADDR = '239.255.255.250' + SSDP_PORT = 1900 + INADDR_ANY = '0.0.0.0' + _addr = (SSDP_ADDR, SSDP_PORT) + WAIT_MARGIN = 0.5 + + def __init__(self, owner=None): + self.ports = [] + if owner == None: + owner = self.datagramReceived + self.owner = owner + + def __del__(self): + for port in self.ports: + port.stopListening() + + def datagramReceived(self, datagram, address): + pass + + def send(self, reactor, st, mx=2, interfaces=[]): + if len(interfaces) == 0 or self.INADDR_ANY in interfaces: + outip = get_outip(self.SSDP_ADDR) + if outip not in interfaces: + interfaces.append(outip) + while self.INADDR_ANY in interfaces: + interfaces.remove(self.INADDR_ANY) + + packet = [ + ('HOST', self.SSDP_ADDR + ':' + str(self.SSDP_PORT)), + ('MAN', '"ssdp:discover"'), + ('MX', str(mx)), + ('ST', st), + ] + buff = build_packet('M-SEARCH * HTTP/1.1', packet) + + new_ports = [] + for ip in interfaces: + port = reactor.listenUDP(0, _dp(self.owner), interface=ip) + new_ports.append(port) + port.write(buff, self._addr) + self.ports += new_ports + + return reactor.callLater(mx + self.WAIT_MARGIN, self._stop, new_ports) + + def _stop(self, ports): + for port in ports: + port.stopListening() + self.ports.remove(port) + + +class IContent(Interface): + + def __iter__(): + """Returns the content stream""" + + def length(whence=1): + """Returns the content length.""" + + def set_range(first, last=-1): + """Sets content range in byte.""" + + def get_type(): + """Returns Content-Type header value.""" + + def get_features(): + """Returns contentFeatures.dlna.org header value.""" + + +class FileContent(object): + + implements(IContent) + readsize = 32 * 1024 + + def __init__(self, filename): + self.filename = filename + self.f = open(filename, 'rb') + self.pos = 0 + self.last = self.length(0) - 1 + + def __del__(self): + #self.f.close() + pass + + def __iter__(self): + while True: + size = self.readsize + if self.last >= 0: + remain = self.last - self.pos + 1 + if remain < size: + size = remain + if size == 0: + break + buff = self.f.read(size) + x = len(buff) + if x <= 0: + break + self.pos += x + yield buff + raise StopIteration() + + def seek(self, pos, whence=0): + self.f.seek(pos, whence) + self.pos = pos + + def length(self, whence=1): + pos = start = self.f.tell() + if whence == 0: + start = 0 + self.f.seek(0, 2) + ret = self.f.tell() + self.f.seek(pos) + return ret - start + + def set_range(self, first, last=-1): + length = self.length(0) + if first < 0: + raise ValueError('invalid range: first(%d) < 0' % first) + if last >= 0 and first > last: + raise ValueError('invalid range: first(%d) > last(%d)' % (first, last)) + if last < 0 or length <= last: + last = length - 1 + self.seek(first) + self.last = last + return '%i-%i/%i' % (first, last, length) + + def get_type(self): + return 'application/octet-stream' + + def get_features(self): + return None + + def get_mtime(self): + return to_gmt(time.gmtime(os.path.getmtime(self.filename))) + + +class StreamingServer(object): + def __init__(self, name): + self.name = name + + def byte_seek(self, environ, headers, content): + return '200 OK' + + def time_seek(self, environ, headers, content): + return '200 OK' + + def __call__(self, environ, start_response): + # response values + code = '405 Method Not Allowed' + headers = [] + body = [] + + # params + method = environ['REQUEST_METHOD'] + id = environ['wsgiorg.routing_args'][1]['id'] + + if method == 'HEAD' or method == 'GET': + # check if the file exists + content = self.get_content(id, environ) + if content != None: + code = '200 OK' + headers.append(('Content-type', content.get_type())) + + headers.append(('contentFeatures.dlna.org', content.get_features())) + + # get content body + if method == 'GET': + body = content + + # seek + try: + if 'HTTP_RANGE' in environ: + code = self.byte_seek(environ, headers, content) + elif 'HTTP_TIMESEEKRANGE.DLNA.ORG' in environ: + code = self.time_seek(environ, headers, content) + except (IOError, ValueError): + code = '416 Requested Range Not Satisfiable' + body = [] + + start_response(code, headers) + return body + + def get_content(self, id, environ): + return FileContent(id) + + +class ByteSeekMixin(object): + def byte_seek(self, environ, headers, content): + fbp, lbp = environ['HTTP_RANGE'].split()[0].split('=')[1].split('-') + lbp = -1 if lbp == '' else int(lbp) + content_range = content.set_range(int(fbp), lbp) + + # append response headers + headers.append(('Content-Range', 'bytes %s' % content_range)) + + return '206 Partial Content' + + +class TimeSeekMixin(object): + def time_seek(self, environ, headers, content): + npt_time = environ.get('HTTP_TIMESEEKRANGE.DLNA.ORG', '') + if not npt_time.startswith('npt='): + return '200 OK' + + # first and last npt-time + first, last = npt_time[4:].split('-', 1) + + # retrieve the duration + req = webob.Request(environ) + duration = req.GET['duration'] + del req + + # this mixin requires a duration in the query string + if not duration: + return '200 OK' + duration = parse_npt(duration) + + # calculate each position + first = parse_npt(first) + last = parse_npt(last) if last else duration + length = content.length(0) + fbp = int(length * first / duration) + lbp = int(length * last / duration) if last else -1 + + bytes_range = content.set_range(fbp, lbp) + npt_range = '%s-%s/%s' % tuple(map(to_npt, [first, last, duration])) + + # append response headers + headers.append( + ('TimeSeekRange.dlna.org', 'npt=%s bytes=%s' % (npt_range, bytes_range)), + ) + + return '206 Partial Content' + + +nptsecref = re.compile('^(?:\d+)(?:.(?:\d{1,3}))?$') +npthmsref = re.compile('^(?P\d+):(?P\d{2,2}):(?P\d{2,2})(?:.(?P\d{1,3}))?$') + + +def parse_npt(npt_time): + """ Parse npt time formatted string and return in second. + S+(.sss) + H+:MM:SS(.sss) + """ + # S+(.sss) + m = nptsecref.match(npt_time) + if m: + return float(npt_time) + + # H+:MM:SS(.sss) + m = npthmsref.match(npt_time) + if not m: + raise ValueError('invalid npt-time: %s' % npt_time) + + hour = int(m.group('hour')) + min = int(m.group('min')) + sec = int(m.group('sec')) + + if not ((0 <= min <= 59) and (0 <= sec <= 59)): + raise ValueError('invalid npt-time: %s' % npt_time) + + sec = float((hour * 60 + min) * 60) + sec + + msec = m.group('msec') + if msec: + sec += float('0.' + msec) + + return sec + + +def to_npt(sec): + hour = int(sec / 3600) + min = int((int(sec) % 3600) / 60) + sec = (int(sec) % 60) + (sec - int(sec)) + return '%i:%02i:%06.3f' % (hour, min, sec) + + +durationref = re.compile("""^[+-]? + (?P\d+): + (?P\d{2,2}): + (?P\d{2,2}) + (.(?P\d+)|.(?P\d+)/(?P\d+))?$""", + re.VERBOSE) + + +def parse_duration(text): + """ Parse duration formatted string and return in second. + ['+'|'-']H+:MM:SS[.F0+|.F0/F1] + """ + m = durationref.match(text) + if m == None: + raise ValueError('invalid format') + + hour = int(m.group('hour')) + minute = int(m.group('minute')) + second = int(m.group('second')) + if minute >= 60 or second >= 60: + raise ValueError('invalid format') + + msec = m.group('msec') + if msec: + msec = float('0.' + msec) + else: + F1 = m.group('F1') + if F1: + F1 = float(F1) + if F1 == 0: + raise ValueError('invalid format') + F0 = float(m.group('F0')) + if F0 >= F1: + raise ValueError('invalid format') + msec = F0 / F1 + else: + msec = 0 + + a = text[0] == '-' and -1 or 1 + return a * (((hour * 60 + minute) * 60) + second + msec) + + +def to_duration(sec): + if sec < 0.0: + return '-' + to_npt(abs(sec)) + return to_npt(sec) + + +def _test(): + import doctest + doctest.testmod() + + +if __name__ == '__main__': + _test() + + from sys import argv + from uuid import uuid1 + from optparse import OptionParser + from twisted.internet import reactor + from pkg_resources import resource_filename + + def soap_app(environ, start_response): + sid = environ['wsgiorg.routing_args'][1]['sid'] + serviceType = environ['upnp.soap.serviceType'] + action = environ['upnp.soap.action'] + req = SoapMessage.parse(StringIO(environ['upnp.body']), serviceType, action) + + print action + ' from ' + environ['REMOTE_ADDR'] + print '\t' + sid + print '\t' + serviceType + print '\t' + str(req.get_args()) + + return not_found(environ, start_response) + + resource_filename(__name__, 'xml/cds.xml') + resource_filename(__name__, 'xml/cms.xml') + + # parse options + parser = OptionParser(usage='%prog [options]') + default_udn = 'uuid:00000000-0000-0000-001122334455' + #default_udn = 'uuid:' + str(uuid1()) + parser.add_option('-u', '--udn', dest='udn', default=default_udn) + parser.add_option('-d', '--desc', dest='desc', default='xml/ms.xml') + options, args = parser.parse_args(argv) + + dd = resource_filename(__name__, options.desc) + device = UpnpDevice(options.udn, dd, soap_app) + base = UpnpBase() + base.append_device([device]) + base.start(reactor) + + def stop(): + base.remove_device(device.udn) + base.stop() + reactor.stop() + + reactor.callLater(15, stop) + reactor.run() + diff --git a/airpnp/upnp.pyc b/airpnp/upnp.pyc new file mode 100644 index 0000000..365564b Binary files /dev/null and b/airpnp/upnp.pyc differ diff --git a/airpnp/util.py b/airpnp/util.py new file mode 100644 index 0000000..c80ebd4 --- /dev/null +++ b/airpnp/util.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2011, Per Rovegård +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the authors nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +import urllib2 +import re +from upnp import SoapMessage, SoapError + +__all__ = [ + 'fetch_url', + 'send_soap_message', + 'hms_to_sec', + 'sec_to_hms', + 'split_usn', + 'get_max_age', +] + +log = logging.getLogger("airpnp.util") + + +def fetch_url(url): + """ + Download data from the specified URL, and return a file-like object. + + Wrapper around urllib2.urlopen with no additional logic but logging. + Any error raised by urllib2.urlopen is re-raised by this function. + + """ + req = urllib2.Request(url) + + log.debug('Fetching URL: %s' % (url, )) + try: + handle = urllib2.urlopen(req) + except urllib2.URLError, err: + log.error('Failed to fetch URL %s because: %s' % (url, err)) + raise err + + return handle + + +class MPOSTRequest(urllib2.Request): + + """ + Internal Request sub class used by send_soap_message. + + The HTTP method is set to 'M-POST' unconditionally (i.e., regardless + of the presence of data to post). + + """ + + def __init__(self, url, data=None, headers={}): + urllib2.Request.__init__(self, url, data, headers) + + def get_method(self): + return "M-POST" + + +def send_soap_message(url, msg, mpost=False): + """ + Send a SOAP message to the given URL. + + The HTTP headers mandated by the UPnP specification are added. Also, if + posting fails with a 405 error, another attempt is made with slightly + different headers and method set to M-POST. + + Return a SoapMessage or a SoapError, depending on the outcome of the call. + + Raise a urllib2.URLError or a urllib2.HTTPError if something goes wrong. + + """ + req = MPOSTRequest(url) if mpost else urllib2.Request(url) + + # add headers to the request + req.add_header('CONTENT-TYPE', 'text/xml; charset="utf-8"') + req.add_header('USER-AGENT', 'OS/1.0 UPnP/1.0 airpnp/1.0') + if mpost: + req.add_header('MAN', + '"http://schemas.xmlsoap.org/soap/envelope/"; ns=01') + req.add_header('01-SOAPACTION', msg.get_header()) + else: + req.add_header('SOAPACTION', msg.get_header()) + + # add the SOAP message as data + req.add_data(msg.tostring().encode("utf-8")) + + try: + handle = urllib2.urlopen(req) + response = SoapMessage.parse(handle) + except urllib2.HTTPError, err: + if err.code == 405 and not mpost: + log.debug('Got 405 response in response to SOAP message, trying' + + 'the M-POST way') + return send_soap_message(url, msg, True) + elif err.code == 500: + # SOAP error + response = SoapError.parse(err.read()) + else: + log.error('Failed to send SOAP message: %s' % (err, )) + raise err + except urllib2.URLError, err: + log.error("Failed to send SOAP message: %s" % (err, )) + raise err + + return response + + +def hms_to_sec(hms): + """ + Convert a HMS time string to seconds. + + The supported HMS time string formats are: + + H+:MM:SS[.F+] or H+:MM:SS[.F0/F1] + + where: + * H+ means one or more digits to indicate elapsed hours + * MM means exactly 2 digits to indicate minutes (00 to 59) + * SS means exactly 2 digits to indicate seconds (00 to 59) + * [.F+] means optionally a dot followed by one or more digits to + indicate fractions of seconds + * [.F0/F1] means optionally a dot followed by a fraction, with F0 + and F1 at least one digit long, and F0 < F1 + + The string may be preceded by an optional + or - sign, and the decimal + point itself may be omitted if there are no fractional second digits. + + A ValueError is raised if the input string does not adhere to the + requirements stated above. + + """ + hours, minutes, seconds = hms.split(':') + if len(minutes) != 2 or len(seconds.split('.')[0]) != 2: + raise ValueError('Minute and second parts must have two digits each.') + hours = int(hours) + minutes = int(minutes) + if minutes < 0 or minutes > 59: + raise ValueError('Minute out of range, must be 00-59.') + if seconds.find('/') > 0: + whole, frac = seconds.split('.') + sf0, sf1 = frac.split('/') + sf0 = int(sf0) + sf1 = int(sf1) + if sf0 >= sf1: + raise ValueError( + 'Nominator must be less than denominator in exact fraction.') + seconds = int(whole) + float(sf0) / sf1 + else: + seconds = float(seconds) + if seconds < 0 or seconds >= 60.0: + raise ValueError('Second out of range, must be 00-60 (exclusive).') + sec = 3600.0 * abs(hours) + 60.0 * minutes + seconds + return sec if hours >= 0 else -sec + + +def sec_to_hms(sec): + """ + Convert a number of seconds to an HMS time string. + + The resulting string has the form: + + H+:MM:SS[.F+] + + This function is the inverse of the hms_to_sec function. If the + number of seconds is negative, the resulting string will have a + preceding - sign. It will never have a preceding + sign, nor will + the fraction be expressed as an integer division of the form F0/F1. + + """ + sgn = -1 if sec < 0 else 1 + sec = abs(sec) + frac = sec - int(sec) + sec = int(sec) + seconds = sec % 60 + mins = (sec - seconds) / 60 + minutes = mins % 60 + hours = (mins - minutes) / 60 + hms = '%d:%02d:%02d' % (hours, minutes, seconds) + if frac > 0: + hms = '%s%s' % (hms, str(frac)[1:]) + return '-%s' % (hms, ) if sgn < 0 else hms + + +def split_usn(usn): + """Split a USN into a UDN and a device or service type. + + USN is short for Unique Service Name, and UDN is short for Unique Device + Name. If the USN only contains a UDN, the type is empty. + + Return a list of exactly two items. + + """ + parts = usn.split('::') + if len(parts) == 2: + return parts + else: + return [parts[0], ''] + + +def get_max_age(headers): + """Parse the 'max-age' directive from the 'CACHE-CONTROL' header. + + Arguments: + headers -- dictionary of HTTP headers + + Return the parsed value as an integer, or None if the 'max-age' directive + or the 'CACHE-CONTROL' header couldn't be found, or if the header is + invalid in any way. + + """ + ret = None + cache_control = headers.get('CACHE-CONTROL') + if not cache_control is None: + parts = re.split(r'\s*=\s*', cache_control) + if len(parts) == 2 and parts[0] == 'max-age' and re.match(r'^\d+$', + parts[1]): + ret = int(parts[1]) + return ret diff --git a/airpnp/util.pyc b/airpnp/util.pyc new file mode 100644 index 0000000..3347414 Binary files /dev/null and b/airpnp/util.pyc differ diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..bf461f7 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,7 @@ +# http://stackoverflow.com/questions/1896918/running-unittest-with-typical-test-directory-structure + +import unittest +import test.all_tests +testSuite = test.all_tests.create_test_suite() +text_runner = unittest.TextTestRunner().run(testSuite) + diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/__init__.pyc b/test/__init__.pyc new file mode 100644 index 0000000..0b950f1 Binary files /dev/null and b/test/__init__.pyc differ diff --git a/test/all_tests.py b/test/all_tests.py new file mode 100644 index 0000000..c30b830 --- /dev/null +++ b/test/all_tests.py @@ -0,0 +1,13 @@ +# http://stackoverflow.com/questions/1896918/running-unittest-with-typical-test-directory-structure + +import glob +import unittest + +def create_test_suite(): + test_file_strings = glob.glob('test/test_*.py') + module_strings = ['test.'+str[5:len(str)-3] for str in + test_file_strings] + suites = [unittest.defaultTestLoader.loadTestsFromName(name) \ + for name in module_strings] + testSuite = unittest.TestSuite(suites) + return testSuite diff --git a/test/all_tests.pyc b/test/all_tests.pyc new file mode 100644 index 0000000..f0de5df Binary files /dev/null and b/test/all_tests.pyc differ diff --git a/test/device_root.xml b/test/device_root.xml new file mode 100644 index 0000000..11a152a --- /dev/null +++ b/test/device_root.xml @@ -0,0 +1,77 @@ + + + 1 + 0 + + + VEN_011A&DEV_0001&REV_01 VEN_0033&DEV_0005&REV_01 + MS_DigitalMediaDeviceClass_DMR_V001 + MediaDevices + Multimedia.DMR + urn:schemas-upnp-org:device:MediaRenderer:1 + DMR-1.50 + WDTVLIVE + Western Digital Corporation + http://www.wdc.com + WD TV HD Live Media Player + WD TV HD Live + WDBAAP + http://www.wdtvlive.com/ + + + image/jpeg + 48 + 48 + 24 + icon_logo4wmc_48x48.jpg + + + image/jpeg + 120 + 120 + 24 + icon_logo4wmc_120x120.jpg + + + image/png + 48 + 48 + 24 + icon_logo4wmc_48x48.png + + + image/png + 120 + 120 + 24 + icon_logo4wmc_120x120.png + + + WNV195115900 + uuid:67ff722f-0090-a976-17db-e9396986c234 + + + urn:schemas-upnp-org:service:AVTransport:1 + urn:upnp-org:serviceId:AVTransport + MediaRenderer_AVTransport/scpd.xml + MediaRenderer_AVTransport/control + MediaRenderer_AVTransport/event + + + urn:schemas-upnp-org:service:ConnectionManager:1 + urn:upnp-org:serviceId:ConnectionManager + MediaRenderer_ConnectionManager/scpd.xml + MediaRenderer_ConnectionManager/control + MediaRenderer_ConnectionManager/event + + + urn:schemas-upnp-org:service:RenderingControl:1 + urn:upnp-org:serviceId:RenderingControl + MediaRenderer_RenderingControl/scpd.xml + MediaRenderer_RenderingControl/control + MediaRenderer_RenderingControl/event + + + web + + diff --git a/test/service_scpd.xml b/test/service_scpd.xml new file mode 100644 index 0000000..290e464 --- /dev/null +++ b/test/service_scpd.xml @@ -0,0 +1,588 @@ + + + 1 + 0 + + + + GetCurrentTransportActions + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Actions + out + CurrentTransportActions + + + + + GetDeviceCapabilities + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + PlayMedia + out + PossiblePlaybackStorageMedia + + + RecMedia + out + PossibleRecordStorageMedia + + + RecQualityModes + out + PossibleRecordQualityModes + + + + + GetMediaInfo + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + NrTracks + out + NumberOfTracks + + + MediaDuration + out + CurrentMediaDuration + + + CurrentURI + out + AVTransportURI + + + CurrentURIMetaData + out + AVTransportURIMetaData + + + NextURI + out + NextAVTransportURI + + + NextURIMetaData + out + NextAVTransportURIMetaData + + + PlayMedium + out + PlaybackStorageMedium + + + RecordMedium + out + RecordStorageMedium + + + WriteStatus + out + RecordMediumWriteStatus + + + + + GetPositionInfo + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Track + out + CurrentTrack + + + TrackDuration + out + CurrentTrackDuration + + + TrackMetaData + out + CurrentTrackMetaData + + + TrackURI + out + CurrentTrackURI + + + RelTime + out + RelativeTimePosition + + + AbsTime + out + AbsoluteTimePosition + + + RelCount + out + RelativeCounterPosition + + + AbsCount + out + AbsoluteCounterPosition + + + + + GetTransportInfo + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + CurrentTransportState + out + TransportState + + + CurrentTransportStatus + out + TransportStatus + + + CurrentSpeed + out + TransportPlaySpeed + + + + + GetTransportSettings + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + PlayMode + out + CurrentPlayMode + + + RecQualityMode + out + CurrentRecordQualityMode + + + + + Next + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + + + Pause + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + + + Play + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Speed + in + TransportPlaySpeed + + + + + Previous + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + + + Seek + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + Unit + in + A_ARG_TYPE_SeekMode + + + Target + in + A_ARG_TYPE_SeekTarget + + + + + SetAVTransportURI + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + CurrentURI + in + AVTransportURI + + + CurrentURIMetaData + in + AVTransportURIMetaData + + + + + SetPlayMode + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + NewPlayMode + in + CurrentPlayMode + + + + + Stop + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + + + X_DLNA_GetBytePositionInfo + + + InstanceID + in + A_ARG_TYPE_InstanceID + + + TrackSize + in + X_DLNA_CurrentTrackSize + + + RelByte + out + X_DLNA_RelativeBytePosition + + + AbsByte + out + X_DLNA_AbsoluteBytePosition + + + + + + + CurrentPlayMode + string + + NORMAL + REPEAT_ONE + REPEAT_ALL + RANDOM + + NORMAL + + + RecordStorageMedium + string + + UNKNOWN + DV + MINI-DV + VHS + W-VHS + S-VHS + D-VHS + VHSC + VIDEO8 + HI8 + CD-ROM + CD-DA + CD-R + CD-RW + VIDEO-CD + SACD + MD-AUDIO + MD-PICTURE + DVD-ROM + DVD-VIDEO + DVD-R + DVD+RW + DVD-RW + DVD-RAM + DVD-AUDIO + DAT + LD + HDD + MICRO-MV + NETWORK + NONE + NOT_IMPLEMENTED + + + + LastChange + string + + + RelativeTimePosition + string + + + CurrentTrackURI + string + + + CurrentTrackDuration + string + + + CurrentRecordQualityMode + string + + 0:EP + 1:LP + 2:SP + 0:BASIC + 1:MEDIUM + 2:HIGH + NOT_IMPLEMENTED + + + + PossibleRecordQualityModes + string + + + CurrentMediaDuration + string + + + AbsoluteCounterPosition + i4 + + + RelativeCounterPosition + i4 + + + A_ARG_TYPE_InstanceID + ui4 + + + AVTransportURI + string + + + CurrentTrackMetaData + string + + + NextAVTransportURI + string + + + AVTransportURIMetaData + string + + + CurrentTrack + ui4 + + 0 + 4000 + 1 + + + + AbsoluteTimePosition + string + + + NextAVTransportURIMetaData + string + + + PlaybackStorageMedium + string + + UNKNOWN + DV + MINI-DV + VHS + W-VHS + S-VHS + D-VHS + VHSC + VIDEO8 + HI8 + CD-ROM + CD-DA + CD-R + CD-RW + VIDEO-CD + SACD + MD-AUDIO + MD-PICTURE + DVD-ROM + DVD-VIDEO + DVD-R + DVD+RW + DVD-RW + DVD-RAM + DVD-AUDIO + DAT + LD + HDD + MICRO-MV + NETWORK + NONE + NOT_IMPLEMENTED + + + + CurrentTransportActions + string + + + RecordMediumWriteStatus + string + + WRITABLE + PROTECTED + NOT_WRITABLE + UNKNOWN + NOT_IMPLEMENTED + + + + PossiblePlaybackStorageMedia + string + + + TransportState + string + + STOPPED + PAUSED_PLAYBACK + PAUSED_RECORDING + PLAYING + RECORDING + TRANSITIONING + NO_MEDIA_PRESENT + + + + NumberOfTracks + ui4 + + 0 + 4000 + + + + A_ARG_TYPE_SeekMode + string + + X_DLNA_REL_BYTE + REL_TIME + TRACK_NR + + + + A_ARG_TYPE_SeekTarget + string + + + PossibleRecordStorageMedia + string + + + TransportStatus + string + + OK + ERROR_OCCURRED + + + + TransportPlaySpeed + string + + 1 + + + + X_DLNA_RelativeBytePosition + string + + + X_DLNA_AbsoluteBytePosition + string + + + X_DLNA_CurrentTrackSize + string + + + diff --git a/test/test_device.py b/test/test_device.py new file mode 100644 index 0000000..1838ff5 --- /dev/null +++ b/test/test_device.py @@ -0,0 +1,66 @@ +import unittest +from airpnp.device import * +from xml.etree import ElementTree + + +class TestDevice(unittest.TestCase): + + @classmethod + def setUpClass(self): + f = open('test/device_root.xml', 'r') + elem = ElementTree.parse(f) + self.device = Device(elem, 'http://www.base.com') + + def test_device_attributes(self): + device = self.device + + self.assertEqual(device.friendlyName, 'WDTVLIVE') + self.assertEqual(device.deviceType, + 'urn:schemas-upnp-org:device:MediaRenderer:1') + self.assertEqual(device.manufacturer, 'Western Digital Corporation') + self.assertEqual(device.modelName, 'WD TV HD Live') + self.assertEqual(device.modelNumber, 'WDBAAP') + + def test_service_count(self): + device = self.device + services = device.get_services() + + self.assertEqual(len(services), 3) + + def test_getting_service_by_id(self): + device = self.device + service = device.get_service_by_id('urn:upnp-org:serviceId:AVTransport') + + self.assertEqual(service.__class__, Service) + + +class TestService(unittest.TestCase): + + def setUp(self): + f = open('test/device_root.xml', 'r') + elem = ElementTree.parse(f) + self.device = Device(elem, 'http://www.base.com') + + def test_service_attributes(self): + service = self.device.get_service_by_id('urn:upnp-org:serviceId:AVTransport') + + self.assertEqual(service.serviceType, 'urn:schemas-upnp-org:service:AVTransport:1') + self.assertEqual(service.serviceId, 'urn:upnp-org:serviceId:AVTransport') + + # URLs are resolved using the base URL + self.assertEqual(service.SCPDURL, 'http://www.base.com/MediaRenderer_AVTransport/scpd.xml') + self.assertEqual(service.controlURL, 'http://www.base.com/MediaRenderer_AVTransport/control') + self.assertEqual(service.eventSubURL, 'http://www.base.com/MediaRenderer_AVTransport/event') + + def test_service_actions(self): + service = self.device.get_service_by_id('urn:upnp-org:serviceId:AVTransport') + f = open('test/service_scpd.xml', 'r') + elem = ElementTree.parse(f) + service.initialize(elem, lambda url, msg: None) + + self.assertTrue(hasattr(service, 'GetCurrentTransportActions')) + + #TODO: + # - calling method + # - not passing IN argument + # - getting result diff --git a/test/test_device.pyc b/test/test_device.pyc new file mode 100644 index 0000000..4ef2971 Binary files /dev/null and b/test/test_device.pyc differ diff --git a/test/test_util.py b/test/test_util.py new file mode 100644 index 0000000..6ccec7b --- /dev/null +++ b/test/test_util.py @@ -0,0 +1,289 @@ +import unittest +import urllib2 +from airpnp.util import * +from airpnp.upnp import SoapMessage, SoapError +from cStringIO import StringIO + + +def nosleep(seconds): + pass + + +class RaisingOpener: + + def __init__(self): + self.calls = 0 + + def open(self, req, data=None, timeout=0): + self.calls += 1 + self.req = req + raise urllib2.URLError('error') + + +class TestGetMaxAge(unittest.TestCase): + + def test_with_proper_header(self): + headers = {'CACHE-CONTROL': 'max-age=10'} + max_age = get_max_age(headers) + + self.assertEqual(max_age, 10) + + def test_with_spaces_around_eq(self): + headers = {'CACHE-CONTROL': 'max-age = 10'} + max_age = get_max_age(headers) + + self.assertEqual(max_age, 10) + + def test_with_missing_max_age(self): + headers = {'CACHE-CONTROL': 'xyz=10'} + max_age = get_max_age(headers) + + self.assertIsNone(max_age) + + def test_with_missing_header(self): + headers = {'a': 'b'} + max_age = get_max_age(headers) + + self.assertIsNone(max_age) + + def test_with_malformed_max_age(self): + headers = {'CACHE-CONTROL': 'max-age='} + max_age = get_max_age(headers) + + self.assertIsNone(max_age) + + +class TestSendSoapMessage(unittest.TestCase): + + def setUp(self): + self.old_opener = urllib2._opener + + def tearDown(self): + urllib2.install_opener(self.old_opener) + + def test_request_headers(self): + o = RaisingOpener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + try: + send_soap_message('http://www.dummy.com', msg) + except: + pass + + req = o.req + self.assertEqual(req.get_header('Content-type'), 'text/xml; charset="utf-8"') + self.assertEqual(req.get_header('User-agent'), 'OS/1.0 UPnP/1.0 airpnp/1.0') + self.assertEqual(req.get_header('Soapaction'), + '"urn:schemas-upnp-org:service:ConnectionManager:1#GetCurrentConnectionIDs"') + + def test_soap_response(self): + class Opener: + def open(self, req, data=None, timeout=0): + response = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', + 'GetCurrentConnectionIDsResponse') + return StringIO(response.tostring()) + + o = Opener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + response = send_soap_message('http://www.dummy.com', msg) + + self.assertEqual(response.__class__, SoapMessage) + self.assertEqual(response.get_header(), + '"urn:schemas-upnp-org:service:ConnectionManager:1#GetCurrentConnectionIDsResponse"') + + def test_soap_error_on_500_response(self): + class Opener: + def open(self, req, data=None, timeout=0): + response = SoapError(501, 'Action Failed') + raise urllib2.HTTPError('http://www.dummy.com', 500, + 'Internal Error', None, + StringIO(response.tostring())) + + o = Opener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + response = send_soap_message('http://www.dummy.com', msg) + + self.assertEqual(response.__class__, SoapError) + self.assertEqual(response.code, '501') + + def test_url_error_is_reraised(self): + class Opener: + def open(self, req, data=None, timeout=0): + raise urllib2.URLError('error') + + o = Opener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + self.assertRaises(urllib2.URLError, send_soap_message, + 'http://www.dummy.com', msg) + + def test_http_error_is_reraised_if_not_405_or_500(self): + class Opener: + def open(self, req, data=None, timeout=0): + raise urllib2.HTTPError('http://www.dummy.com', 404, + 'Not Found', None, + StringIO('Not Found')) + + o = Opener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + self.assertRaises(urllib2.HTTPError, send_soap_message, + 'http://www.dummy.com', msg) + + def test_fallback_to_mpost(self): + class Opener: + def open(self, req, data=None, timeout=0): + if req.get_method() == 'POST': + raise urllib2.HTTPError('http://www.dummy.com', 405, + 'Method Not Allowed', None, + StringIO('Method Not Allowed')) + else: + e = urllib2.URLError('') + e.headers = req.headers + raise e + + o = Opener() + urllib2.install_opener(o) + + msg = SoapMessage('urn:schemas-upnp-org:service:ConnectionManager:1', 'GetCurrentConnectionIDs') + try: + send_soap_message('http://www.dummy.com', msg) + except urllib2.URLError, e: + self.assertEqual(e.headers['Man'], + '"http://schemas.xmlsoap.org/soap/envelope/"; ns=01') + self.assertEqual(e.headers['01-soapaction'], + '"urn:schemas-upnp-org:service:ConnectionManager:1#GetCurrentConnectionIDs"') + + +class TestFetchUrl(unittest.TestCase): + + def setUp(self): + self.old_opener = urllib2._opener + + def tearDown(self): + urllib2.install_opener(self.old_opener) + + def test_request_with_url(self): + # Mock Opener + class Opener: + def open(self, req, data=None, timeout=0): + self.req = req + return None + + o = Opener() + urllib2.install_opener(o) + fetch_url('http://www.dummy.com') + + self.assertEqual(o.req.__class__, urllib2.Request) + self.assertEqual(o.req.get_full_url(), 'http://www.dummy.com') + + def test_reraise_url_error(self): + o = RaisingOpener() + urllib2.install_opener(o) + + self.assertRaises(urllib2.URLError, fetch_url, 'http://www.dummy.com') + + +class TestHmsToSec(unittest.TestCase): + + def test_hour_conversion(self): + sec = hms_to_sec('1:00:00') + self.assertEqual(sec, 3600.0) + + def test_minute_conversion(self): + sec = hms_to_sec('0:10:00') + self.assertEqual(sec, 600.0) + + def test_second_conversion(self): + sec = hms_to_sec('0:00:05') + self.assertEqual(sec, 5.0) + + def test_with_fraction(self): + sec = hms_to_sec('0:00:05.5') + self.assertEqual(sec, 5.5) + + def test_with_div_fraction(self): + sec = hms_to_sec('0:00:05.1/2') + self.assertEqual(sec, 5.5) + + def test_with_plus_sign(self): + sec = hms_to_sec('+1:01:01') + self.assertEqual(sec, 3661.0) + + def test_with_minus_sign(self): + sec = hms_to_sec('-1:01:01') + self.assertEqual(sec, -3661.0) + + def test_without_hour_part(self): + self.assertRaises(ValueError, hms_to_sec, '00:00') + + def test_with_empty_hour_part(self): + self.assertRaises(ValueError, hms_to_sec, ':00:00') + + def test_with_too_short_minute_part(self): + self.assertRaises(ValueError, hms_to_sec, '0:0:00') + + def test_with_too_short_second_part(self): + self.assertRaises(ValueError, hms_to_sec, '0:00:0') + + def test_with_negative_minute(self): + self.assertRaises(ValueError, hms_to_sec, '0:-1:00') + + def test_with_too_large_minute(self): + self.assertRaises(ValueError, hms_to_sec, '0:60:00') + + def test_with_negative_second(self): + self.assertRaises(ValueError, hms_to_sec, '0:00:-1') + + def test_with_too_large_second(self): + self.assertRaises(ValueError, hms_to_sec, '0:00:60') + + def test_with_div_fraction_unsatisfied_inequality(self): + self.assertRaises(ValueError, hms_to_sec, '0:00:05.5/5') + + +class TestSecToHms(unittest.TestCase): + + def test_seconds_only_without_fraction(self): + hms = sec_to_hms(5) + self.assertEqual(hms, '0:00:05') + + def test_seconds_with_fraction(self): + hms = sec_to_hms(5.5) + self.assertEqual(hms, '0:00:05.5') + + def test_minute_conversion(self): + hms = sec_to_hms(65) + self.assertEqual(hms, '0:01:05') + + def test_hour_conversion(self): + hms = sec_to_hms(3600) + self.assertEqual(hms, '1:00:00') + + def test_negative_seconds_conversion(self): + hms = sec_to_hms(-3661.0) + self.assertEqual(hms, '-1:01:01') + + +class TestSplitUsn(unittest.TestCase): + + def test_split_two_parts(self): + usn = 'uuid:x::type' + p1, p2 = split_usn(usn) + + self.assertEqual(p1, 'uuid:x') + self.assertEqual(p2, 'type') + + def test_split_only_udn(self): + usn = 'uuid:x' + p1, p2 = split_usn(usn) + + self.assertEqual(p1, 'uuid:x') + self.assertEqual(p2, '') diff --git a/test/test_util.pyc b/test/test_util.pyc new file mode 100644 index 0000000..c99f617 Binary files /dev/null and b/test/test_util.pyc differ