From 104376c38fdbb667189b0b43b4b8b85fbc6009e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Roveg=C3=A5rd?= Date: Sun, 17 Jul 2011 01:30:34 +0200 Subject: [PATCH] Initial commit --- LICENSE | 26 + README | 68 +++ airpnp/AirPlayService.py | 327 +++++++++++ airpnp/ZeroconfService.py | 50 ++ airpnp/__init__.py | 0 airpnp/__init__.pyc | Bin 0 -> 131 bytes airpnp/airpnp.py | 248 ++++++++ airpnp/device.py | 256 ++++++++ airpnp/device.pyc | Bin 0 -> 9838 bytes airpnp/device_builder.py | 282 +++++++++ airpnp/device_discovery.py | 322 ++++++++++ airpnp/upnp.py | 1131 ++++++++++++++++++++++++++++++++++++ airpnp/upnp.pyc | Bin 0 -> 39755 bytes airpnp/util.py | 242 ++++++++ airpnp/util.pyc | Bin 0 -> 7353 bytes run_tests.py | 7 + test/__init__.py | 0 test/__init__.pyc | Bin 0 -> 129 bytes test/all_tests.py | 13 + test/all_tests.pyc | Bin 0 -> 655 bytes test/device_root.xml | 77 +++ test/service_scpd.xml | 588 +++++++++++++++++++ test/test_device.py | 66 +++ test/test_device.pyc | Bin 0 -> 3590 bytes test/test_util.py | 289 +++++++++ test/test_util.pyc | Bin 0 -> 15696 bytes 26 files changed, 3992 insertions(+) create mode 100644 LICENSE create mode 100644 README create mode 100644 airpnp/AirPlayService.py create mode 100644 airpnp/ZeroconfService.py create mode 100644 airpnp/__init__.py create mode 100644 airpnp/__init__.pyc create mode 100644 airpnp/airpnp.py create mode 100644 airpnp/device.py create mode 100644 airpnp/device.pyc create mode 100644 airpnp/device_builder.py create mode 100644 airpnp/device_discovery.py create mode 100644 airpnp/upnp.py create mode 100644 airpnp/upnp.pyc create mode 100644 airpnp/util.py create mode 100644 airpnp/util.pyc create mode 100644 run_tests.py create mode 100644 test/__init__.py create mode 100644 test/__init__.pyc create mode 100644 test/all_tests.py create mode 100644 test/all_tests.pyc create mode 100644 test/device_root.xml create mode 100644 test/service_scpd.xml create mode 100644 test/test_device.py create mode 100644 test/test_device.pyc create mode 100644 test/test_util.py create mode 100644 test/test_util.pyc 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 0000000000000000000000000000000000000000..cfc37cac369edb1b09d93bedeb2b04144c21e8c5 GIT binary patch literal 131 zcmZSn%**vfPRTEs0SXv_v;z +# 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 0000000000000000000000000000000000000000..b6542264161c9eb9e64695a53c5a2847fc78638e GIT binary patch literal 9838 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 Yc(Y>-Z@Pxok;bXHvAI|0-kf{se@{mP761SM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..365564b998cefbe3a21297ddf244026685ad6337 GIT binary patch literal 39755 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 qa+zfJ!+7R|0c1bo%N(T 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�`vq&UYiTuvow;OZkPn!LQM z5?J=(eHk25%Tw5#kfvsJLc;c8gMkk^yGZYS(g9XE#1Si*=rjeat?AsX7xM;c^n30(o zP4l}re!(LflYEe8kI7t-#Q`D*(dE*Kv+xE#;hjEfSJmdIH#B)vOV|4iw(%6}lEV5p zV+E$sJ|QKL-)Lbxg6f5o&<$d@DKFT3+e8fChuR#pJ?eT}VZrFj+z%^Z4}5-lbmHJ4 zSTOo{O*)5?BgxMgKJu$s9pc1}U&+R9G}yZJ zzc7jt@v)*KH3!!wb!2+_3j~)x9O{$!gzrU=NWj|=g&=0B))aG)BlyF;Nr=PBVzW?bU=<%sfaLdP z3Gq2nUj@i%T-2Y92M*4f3jqyMbNH^L7)VwiIj>8?z5k8JjRM+%cX{2O+8K^veWz(2 zz+3TH;*f~Erg;`SYlo<^y2vnw7G*aKUOV39aT1;eJo0X#qy7{_7meeoAP=Mr!eM^9 z62BSgZyZuCh6w3=)Y20%w6+o_@!2=yOk?W3iwrI?xXj>11dSO~Y+d-~@mTO9Y_SuO zHmth4@Y|#Y6Jt)|6V?zI6ELM0vN#3jG}8QAE;1%N0GogZk}(3)hVcl+7jX!6{F&n% z)C6;!yW7ncP>ZP%7qbj#!%L_D5E~bH7LjDdWHwyzCg>F4pj?kz#N@p|r4EsCyO|B( zxTHb7@atzsYjZ_}(S@yK+u|C$Mw$*$l34iKtcrPD6NDuk-`9+Yl30ap6MS&Zw5)GN zxHdb^86gT&Pu|WAC{!rY=l?N$?y7q-QvftjVts8>*o?7lcEzRDYAa&wYXP6|pCDNM zEcMOW-;XpxZe&4=l1#YP>S|Y_M@u(oX~G@Bvgp6yQ7sLs`AEpO? zwuFx;1#{I{%k#a)8G;bss;5C{QiU`oGk|HM{h7nNm(fnNl-iLhBEg6`#x6lUhtve> zTbNrQ4$pO99t}-~7(t3nJ$o0cBA|Hbd+IP=jV#nE!OnwQ0ZV`&yVu|Y@!Wa})HH)y zJt&ntLfn{TkDWKv*v!~5=Ayg>>mxC;iH{hlf2=$_$m7*}3mF6)t=0bYPCKMhTtX*Oi+#*aV1StE}Y zx8CT~QS8EFJhqB*t%7^P@dbN4C5^M5Z^xx$C3jMn1U9+okKm&8pnk}7T!RWt+#3t0 zS^yRp8x0DqF#ZUgI4Vu#uLgl$_kPW{lo2kMW2<7^EL&951BgI$Kum*fQ4*_na(WPH z?6&~aifvL{HRwx>^zBIAEFXV@!Sk%89Rai^eY2A%ajTBJf#eNNuhzFz#8l_?Mn8(~ zBdC<*+O%>b-;(m=aTCt&mD5s9S{m8M-BdMi9vKbz6k8wr*o}6dA#A;fw52;?pXPnH z9oxEEx;nbLcn59)Ir`yu;z!c*X!{j`XdkA99enHx%&Ld6My-2rj zOvJk8K#0Os6@0gXYajlbh0j}9gCXk&jNcw6z`nK#Fhu3LM4HmTjiMD3r3;dk48_QS z4s^w!fWdA!P@x|TUS1clZObz-JSC4L+&2QfZGR+mX?K&_4I>_%EMRzsv&ToN@l$}C zqZ3Sbu-Ij&nnC(dicqRpUTn>4af8;iOb9T1tMgfIw2*a73fB0 zFewsz;_#E%^Sb(4-*I-LRH|NwtKM&+z&m(%@JxWz$1@Wr#=ZZ_7aI8cma7($`(<&? z_$_Nvi9lWS>7AH7JrV9fWqO=BdwybiV&VlQ509UlJTW}wotD&n;>c6k=qr`sxZ=rL zFbP+cX>7}d^TqsJDbSrevL2ExMnq$#sWWJyu>$q3qAwnWw8-o1lu2SBSkt{dI=5)9 zBA@2lc?KV6@cRrn9QT6!+7WysgHaqtb;B5t&d%vGj%o1Xkr8S@-P7Yezrp#Z8~hp z!F@M)LUu`98N(-}F97g_$N;p^y3|(oSg9JWgo8wvX|Qad%Y$6vNfco6i8%P~I_w^q z>xQj|Aba4S0aT)YV7vQUcy4#$qAH>F0J0%*lTC07BIHxDElBmmg`y}%+(N}LRAGw= zuK*n(&VXdCZYQ#LF%5zWuYNhsX-jf=Cn>(-2?7dL;2jjijuilg<18F*bqOh)Xi*m` zKyC0-L@uiAg?BAc8m=oSia7qdv_6ZZB+P7P2%CH-&Tdy-ktFeD_x81WrVkaF=2bED zMhoqDjKA?-gkmJYnm`$5;5UoP45xrok3t4 z`Wt+_gZ|hnK%1j#PKxmdx&YeyeDdeaiZ zppYb3H7FD%#+4NSyUrvf%;0_wwrn<71(LkL=3#kwFxs$2$}~wGsZH78X(~?O9!PB1 zFn}9mD_|9(Dpm&Jkt9kAF%cHUBvE_jOcbjD;kINTI;x+MstZ#}+ND#~A150EGqSuN zZO#jS>8_AKUj)D2T!!3q;Nmk+bvFS{ZjYKzZYp>y?EpJaw}*ZJ}}72SZ=2_zhp<>4QVVVHoc3 z+qQHN|5HRF1PQ zNpW~sGBG9|B=cLxxugW(g{z9qm#XMQ2hz~q)pCdu9`!~ESCv?xf1(|1yupk<2EY@1 z8v%n4Gx!LC22re!9>`{Xlk|{9#I^CnN+)Qt4R?6$!kLv_>1Pnz3zuS0qglk+_eAY^ z5f9W9CC(%v;II%UVJ^{`USOnhfR8H4WSHOZPV&6F568P9{(v9A9psoTjm=CJw}OXi zd}TBoERGHyKFZ*!;m7BtjcK%uQ)oPr;o0b*kgKN9c(e(zHFY0!3*D*k@&^wdy7F9t zMTSdj!?oPv9gBjMtki>qd5pf}3x?Xs3tzoF_*ue;4(@+lkEagf<%9c2n04xi;$U?J zAdyzdZTLTiPe^%*8M=f0KpazJJwPP{vx~_zBPx)e)D1nPnE@q<%?v0>Y+^v*72q@2 zL4hW1st-{n0~@UWxhK2Zv7`KKMFAek0cftn`pbBGx*h#5nNd#nr64K}w{`#g9 zZWryrEU;bvXxz+bNX{ealAjBWw~?c5l#lVfqwFH5lwa(*GgVLw0jEr4PG^10gb%-K zn8DNyko+x_M^a>4lzuD-rKs{NPH|F2~xQA;kCyVwt*gIqodN za|P9#l*OHwui6s@>dH#dKVpycCz#SP9VrYogX>R`YWZB#!=XV}8s*}>?SfWz$YHA* zlusaH^Qm)T5KaBu#>GfsB~P0kVzdlGbrFX&4@0cf_+22NYmoFUI{*UR3in{MJl1D@ zr_WH&!KSc9zyK7%l!F0aLjc20TseIP!-03ehp5Vdc&LnLy?`&P#GBCsM_cD%wgLSkP;dYiEiZ5%0d^XVzf zioPd--#FNb-v`s%u-()FwqPm1LoTG4H;hYs=k5`LW>;~_8aTM$^juK_%C zh!?!r)h}G~5jq2=fG7PVq(}Np@Wr)9?3(!2~8e1{qMLMDk>GBiUv!_52M}eWKE-3?P)Nwml@#}Pxog!k-?QTQPE$=rDLQg#wuhR2yIr(ldyO(JN<0<} z3O^<0k!+Q@FU$c2NZU-WZ-Z7Zht#adefTU&P;DxZ-)MJ9h-JwUje`Ap6;Gjo*2S! zdq>;$-MtgN`+B>2+j}y-|DbAE&z^1aQ7P-GTcO|1T#`4%Ywx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..334741463fbe236d3c3514ca05ef43a916c7ec3c GIT binary patch literal 7353 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4ef297138a8f5bb7a3ddd35b4d368f624d777ec6 GIT binary patch literal 3590 zcmcInYfs!p6dik)B}+(YLtp75p{c5_(uP&~lxP%{EU1l?t|GG`Dr#jJdjKP^z4eTt zROD0gkK6C+1D>tm>Zzet(ZwC@*O$t<-)~?RYAgQBRe6qSVh@Gb(9KZOo20S`@m`xqu7O zzwy)Wcof8=Wt?eA<18wY@K1gFd*44-IT{QCrI;gPaDeY5fs^C#K zT!isfQzgC(pi@HzEfs8Znm88oT0WYshi1M)XXW8I^u$+>J?ggat7DH+AN$HS)G_X9 zaQ9fL5aJacL~(udCXYkcZX(KtWl`AW--nsJuQZF;-m`YJ((N7|9xiW3MlZ+3urtGB zlu(Rx9ta>@8bzhiwuO;h2Xo-5u$&J1UEP z9b40l(sGoKx}qw(&`Dh$9SPjH_2HF@88>8H=kirmHCQ^_w+C_ zH!HmDW>N00xL2+Gq?0rXbe`x^mn*l-`#g+S>oQ+jOLtQnWlO!H92I3`(;_#QMEKHR zbLq#mrTgiDv%#aayQ`}YI|E-ELJCFHJnc|-8s2>fT=Q;i3W&Qkz{&#TWEbx{8E{LyaiyY8wJK z#0I1;@@O&h$%p4c;4&|vqu0{HxTtdbE>@j)du?ytf1y|NfszP_2A=P$ z{RShE&OUjyxen3v$+0ii7vD=(RyPM_lpDC4mB@FvynzE#xP;qBVVXE_=qF(qGakdx z()TtTc+bPdt@@sGq4gf*2wG6crups|HBss+Z;_k;)E0b+2(eTc=5Twc$dnz1aONuG zl%6fxBPRBWB;Y(?stjm=;6rp1bzjlP13DX(l9bi*7h3JPc1x}v!0t6*F+PwV=GePv z=3{h@Jz%`2pbO-7#~YHbxY8tD&_Pf8$NxkZ0dMiNCIWs}~6wTHod3ENr&PI*woZdBWtPZ81w z*g2O|y#oQyq~i%AeNOdA63V*_Mp|r)`G_vh)L*UtQa}NFuFAltE|1gI`!DKLZ{cG;s9Pf&o;ZPoa?Iq!#* literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c99f6173266f4f3c2facadb77d2a09067acfe160 GIT binary patch literal 15696 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 E1?dAE^Z)<= literal 0 HcmV?d00001