from: http://frungy.org/~tpot/weblog/2006/01/19#python-upnp-abandoned [git-p4: depot-paths = "//depot/": change = 717]replace/5b80aeb26dc425aaddcd5182126c969e5cc04cbb
| @@ -0,0 +1,19 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| # Connection Manager service | |||
| from twisted.python import log | |||
| from twisted.web import resource, static, soap | |||
| from upnp import UPnPPublisher | |||
| class ConnectionManagerControl(UPnPPublisher): | |||
| pass | |||
| class ConnectionManagerServer(resource.Resource): | |||
| def __init__(self): | |||
| resource.Resource.__init__(self) | |||
| self.putChild('scpd.xml', static.File('connection-manager-scpd.xml')) | |||
| self.putChild('control', ConnectionManagerControl()) | |||
| @@ -0,0 +1,202 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| # | |||
| # This module implements the Content Directory Service (CDS) service | |||
| # type as documented in the ContentDirectory:1 Service Template | |||
| # Version 1.01 | |||
| # | |||
| # | |||
| # TODO: Figure out a nicer pattern for debugging soap server calls as | |||
| # twisted swallows the tracebacks. At the moment I'm going: | |||
| # | |||
| # try: | |||
| # .... | |||
| # except: | |||
| # traceback.print_exc(file = log.logfile) | |||
| # | |||
| from twisted.python import log | |||
| from twisted.web import resource, static | |||
| from elementtree.ElementTree import Element, SubElement, tostring | |||
| from upnp import UPnPPublisher | |||
| from DIDLLite import DIDLElement, Container, Movie, Resource, MusicTrack | |||
| import traceback | |||
| from urllib import quote | |||
| class ContentDirectoryControl(UPnPPublisher): | |||
| """This class implements the CDS actions over SOAP.""" | |||
| # Required actions | |||
| def soap_GetSearchCapabilities(self, *args, **kwargs): | |||
| """Return the searching capabilities supported by the device.""" | |||
| log.msg('GetSearchCapabilities()') | |||
| def soap_GetSortCapabilities(self, *args, **kwargs): | |||
| """Return the CSV list of meta-data tags that can be used in | |||
| sortCriteria.""" | |||
| log.msg('GetSortCapabilities()') | |||
| def soap_GetSystemUpdateID(self, *args, **kwargs): | |||
| """Return the current value of state variable SystemUpdateID.""" | |||
| log.msg('GetSystemUpdateID()') | |||
| BrowseFlags = ('BrowseMetaData', 'BrowseDirectChildren') | |||
| def soap_Browse(self, *args): | |||
| """Incrementally browse the native heirachy of the Content | |||
| Directory objects exposed by the Content Directory Service.""" | |||
| (ObjectID, BrowseFlag, Filter, StartingIndex, RequestedCount, | |||
| SortCriteria) = args | |||
| log.msg('Browse(ObjectID=%s, BrowseFlags=%s, Filter=%s, ' | |||
| 'StartingIndex=%s RequestedCount=%s SortCriteria=%s)' % | |||
| (`ObjectID`, `BrowseFlag`, `Filter`, `StartingIndex`, | |||
| `RequestedCount`, `SortCriteria`)) | |||
| didl = DIDLElement() | |||
| try: | |||
| if ObjectID == '0\\Music\\': | |||
| c = Container('0\\Music\\Spotty\\', '0\\Music\\', 'Spotty') | |||
| c.childCount = 6 | |||
| c.searchable = 0 | |||
| didl.addItem(c) | |||
| c = Container('0\\Music\\Foot\\', '0\\Music\\', 'Foot') | |||
| c.childCount = 1 | |||
| c.searchable = 0 | |||
| didl.addItem(c) | |||
| if ObjectID == '0\\Music\\Spotty\\': | |||
| m = MusicTrack('0\\Music\\media\\foo.mp3', '0\\Music\\', 'foo.mp3') | |||
| m.res = Resource('http://192.168.1.105:8080/media/foo.mp3', 'http-get:*:audio/mpeg:*') | |||
| m.res.size = 1234 | |||
| m.res.bitrate = 256 | |||
| m.genre = 'Others' | |||
| m.artist = 'Others' | |||
| m.album = 'Others' | |||
| didl.addItem(m) | |||
| result = {'BrowseResponse': {'Result': didl.toString() , | |||
| 'NumberReturned': didl.numItems(), | |||
| 'TotalMatches': didl.numItems(), | |||
| 'UpdateID': 0}} | |||
| except: | |||
| traceback.print_exc(file=log.logfile) | |||
| log.msg('Returning: %s' % result) | |||
| return result | |||
| # Optional actions | |||
| def soap_Search(self, *args, **kwargs): | |||
| """Search for objects that match some search criteria.""" | |||
| (ContainerID, SearchCriteria, Filter, StartingIndex, | |||
| RequestedCount, SortCriteria) = args | |||
| log.msg('Search(ContainerID=%s, SearchCriteria=%s, Filter=%s, ' \ | |||
| 'StartingIndex=%s, RequestedCount=%s, SortCriteria=%s)' % | |||
| (`ContainerID`, `SearchCriteria`, `Filter`, | |||
| `StartingIndex`, `RequestedCount`, `SortCriteria`)) | |||
| def soap_CreateObject(self, *args, **kwargs): | |||
| """Create a new object.""" | |||
| (ContainerID, Elements) = args | |||
| log.msg('CreateObject(ContainerID=%s, Elements=%s)' % | |||
| (`ContainerID`, `Elements`)) | |||
| def soap_DestroyObject(self, *args, **kwargs): | |||
| """Destroy the specified object.""" | |||
| (ObjectID) = args | |||
| log.msg('DestroyObject(ObjectID=%s)' % `ObjectID`) | |||
| def soap_UpdateObject(self, *args, **kwargs): | |||
| """Modify, delete or insert object metadata.""" | |||
| (ObjectID, CurrentTagValue, NewTagValue) = args | |||
| log.msg('UpdateObject(ObjectID=%s, CurrentTagValue=%s, ' \ | |||
| 'NewTagValue=%s)' % (`ObjectID`, `CurrentTagValue`, | |||
| `NewTagValue`)) | |||
| def soap_ImportResource(self, *args, **kwargs): | |||
| """Transfer a file from a remote source to a local | |||
| destination in the Content Directory Service.""" | |||
| (SourceURI, DestinationURI) = args | |||
| log.msg('ImportResource(SourceURI=%s, DestinationURI=%s)' % | |||
| (`SourceURI`, `DestinationURI`)) | |||
| def soap_ExportResource(self, *args, **kwargs): | |||
| """Transfer a file from a local source to a remote | |||
| destination.""" | |||
| (SourceURI, DestinationURI) = args | |||
| log.msg('ExportResource(SourceURI=%s, DestinationURI=%s)' % | |||
| (`SourceURI`, `DestinationURI`)) | |||
| def soap_StopTransferResource(self, *args, **kwargs): | |||
| """Stop a file transfer initiated by ImportResource or | |||
| ExportResource.""" | |||
| (TransferID) = args | |||
| log.msg('StopTransferResource(TransferID=%s)' % TransferID) | |||
| def soap_GetTransferProgress(self, *args, **kwargs): | |||
| """Query the progress of a file transfer initiated by | |||
| an ImportResource or ExportResource action.""" | |||
| (TransferID, TransferStatus, TransferLength, TransferTotal) = args | |||
| log.msg('GetTransferProgress(TransferID=%s, TransferStatus=%s, ' \ | |||
| 'TransferLength=%s, TransferTotal=%s)' % | |||
| (`TransferId`, `TransferStatus`, `TransferLength`, | |||
| `TransferTotal`)) | |||
| def soap_DeleteResource(self, *args, **kwargs): | |||
| """Delete a specified resource.""" | |||
| (ResourceURI) = args | |||
| log.msg('DeleteResource(ResourceURI=%s)' % `ResourceURI`) | |||
| def soap_CreateReference(self, *args, **kwargs): | |||
| """Create a reference to an existing object.""" | |||
| (ContainerID, ObjectID) = args | |||
| log.msg('CreateReference(ContainerID=%s, ObjectID=%s)' % | |||
| (`ContainerID`, `ObjectID`)) | |||
| class ContentDirectoryServer(resource.Resource): | |||
| def __init__(self): | |||
| resource.Resource.__init__(self) | |||
| self.putChild('scpd.xml', static.File('content-directory-scpd.xml')) | |||
| self.putChild('control', ContentDirectoryControl()) | |||
| @@ -0,0 +1,304 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| from elementtree.ElementTree import Element, SubElement, tostring, _ElementInterface | |||
| class Resource: | |||
| """An object representing a resource.""" | |||
| def __init__(self, data, protocolInfo): | |||
| self.data = data | |||
| self.protocolInfo = protocolInfo | |||
| self.bitrate = None | |||
| self.size = None | |||
| def toElement(self): | |||
| root = Element('res') | |||
| root.attrib['protocolInfo'] = self.protocolInfo | |||
| root.text = self.data | |||
| if self.bitrate is not None: | |||
| root.attrib['bitrate'] = str(self.bitrate) | |||
| if self.size is not None: | |||
| root.attrib['size'] = str(self.size) | |||
| return root | |||
| class Object: | |||
| """The root class of the entire content directory class heirachy.""" | |||
| klass = 'object' | |||
| creator = None | |||
| res = None | |||
| writeStatus = None | |||
| def __init__(self, id, parentID, title, restricted = False, | |||
| creator = None): | |||
| self.id = id | |||
| self.parentID = parentID | |||
| self.title = title | |||
| self.creator = creator | |||
| if restricted: | |||
| self.restricted = '1' | |||
| else: | |||
| self.restricted = '0' | |||
| def toElement(self): | |||
| root = Element(self.elementName) | |||
| root.attrib['id'] = self.id | |||
| root.attrib['parentID'] = self.parentID | |||
| SubElement(root, 'dc:title').text = self.title | |||
| SubElement(root, 'upnp:class').text = self.klass | |||
| root.attrib['restricted'] = self.restricted | |||
| if self.creator is not None: | |||
| SubElement(root, 'dc:creator').text = self.creator | |||
| if self.res is not None: | |||
| root.append(self.res.toElement()) | |||
| if self.writeStatus is not None: | |||
| SubElement(root, 'upnp:writeStatus').text = self.writeStatus | |||
| return root | |||
| def toString(self): | |||
| return tostring(self.toElement()) | |||
| class Item(Object): | |||
| """A class used to represent atomic (non-container) content | |||
| objects.""" | |||
| klass = Object.klass + '.item' | |||
| elementName = 'item' | |||
| refID = None | |||
| def toElement(self): | |||
| root = Object.toElement(self) | |||
| if self.refID is not None: | |||
| SubElement(root, 'refID').text = self.refID | |||
| return root | |||
| class ImageItem(Item): | |||
| klass = Item.klass + '.imageItem' | |||
| class Photo(ImageItem): | |||
| klass = ImageItem.klass + '.photo' | |||
| class AudioItem(Item): | |||
| """A piece of content that when rendered generates some audio.""" | |||
| klass = Item.klass + '.audioItem' | |||
| genre = None | |||
| description = None | |||
| longDescription = None | |||
| publisher = None | |||
| language = None | |||
| relation = None | |||
| rights = None | |||
| def toElement(self): | |||
| root = Item.toElement(self) | |||
| if self.genre is not None: | |||
| SubElement(root, 'upnp:genre').text = self.genre | |||
| if self.description is not None: | |||
| SubElement(root, 'dc:description').text = self.description | |||
| if self.longDescription is not None: | |||
| SubElement(root, 'upnp:longDescription').text = \ | |||
| self.longDescription | |||
| if self.publisher is not None: | |||
| SubElement(root, 'dc:publisher').text = self.publisher | |||
| if self.language is not None: | |||
| SubElement(root, 'dc:language').text = self.language | |||
| if self.relation is not None: | |||
| SubElement(root, 'dc:relation').text = self.relation | |||
| if self.rights is not None: | |||
| SubElement(root, 'dc:rights').text = self.rights | |||
| return root | |||
| class MusicTrack(AudioItem): | |||
| """A discrete piece of audio that should be interpreted as music.""" | |||
| klass = AudioItem.klass + '.musicTrack' | |||
| artist = None | |||
| album = None | |||
| originalTrackNumber = None | |||
| playlist = None | |||
| storageMedium = None | |||
| contributor = None | |||
| date = None | |||
| def toElement(self): | |||
| root = AudioItem.toElement(self) | |||
| if self.artist is not None: | |||
| SubElement(root, 'upnp:artist').text = self.artist | |||
| if self.album is not None: | |||
| SubElement(root, 'upnp:album').text = self.album | |||
| if self.originalTrackNumber is not None: | |||
| SubElement(root, 'upnp:originalTrackNumber').text = \ | |||
| self.originalTrackNumber | |||
| if self.playlist is not None: | |||
| SubElement(root, 'upnp:playlist').text = self.playlist | |||
| if self.storageMedium is not None: | |||
| SubElement(root, 'upnp:storageMedium').text = self.storageMedium | |||
| if self.contributor is not None: | |||
| SubElement(root, 'dc:contributor').text = self.contributor | |||
| if self.date is not None: | |||
| SubElement(root, 'dc:date').text = self.date | |||
| return root | |||
| class AudioBroadcast(AudioItem): | |||
| klass = AudioItem.klass + '.audioBroadcast' | |||
| class AudioBook(AudioItem): | |||
| klass = AudioItem.klass + '.audioBook' | |||
| class VideoItem(Item): | |||
| klass = Item.klass + '.videoItem' | |||
| class Movie(VideoItem): | |||
| klass = VideoItem.klass + '.movie' | |||
| class VideoBroadcast(VideoItem): | |||
| klass = VideoItem.klass + '.videoBroadcast' | |||
| class MusicVideoClip(VideoItem): | |||
| klass = VideoItem.klass + '.musicVideoClip' | |||
| class PlaylistItem(Item): | |||
| klass = Item.klass + '.playlistItem' | |||
| class TextItem(Item): | |||
| klass = Item.klass + '.textItem' | |||
| class Container(Object): | |||
| """An object that can contain other objects.""" | |||
| klass = Object.klass + '.container' | |||
| elementName = 'container' | |||
| childCount = 0 | |||
| createClass = None | |||
| searchClass = None | |||
| searchable = None | |||
| def __init__(self, id, parentID, title, restricted = 0, creator = None): | |||
| Object.__init__(self, id, parentID, title, restricted, creator) | |||
| def toElement(self): | |||
| root = Object.toElement(self) | |||
| root.attrib['childCount'] = str(self.childCount) | |||
| if self.createClass is not None: | |||
| SubElement(root, 'upnp:createclass').text = self.createClass | |||
| if self.searchClass is not None: | |||
| if not isinstance(self.searchClass, (list, tuple)): | |||
| self.searchClass = ['searchClass'] | |||
| for i in searchClass: | |||
| SubElement(root, 'upnp:searchclass').text = i | |||
| if self.searchable is not None: | |||
| root.attrib['searchable'] = str(self.searchable) | |||
| return root | |||
| class Person(Container): | |||
| klass = Container.klass + '.person' | |||
| class MusicArtist(Person): | |||
| klass = Person.klass + '.musicArtist' | |||
| class PlaylistContainer(Container): | |||
| klass = Container.klass + '.playlistContainer' | |||
| class Album(Container): | |||
| klass = Container.klass + '.album' | |||
| class MusicAlbum(Album): | |||
| klass = Album.klass + '.musicAlbum' | |||
| class PhotoAlbum(Album): | |||
| klass = Album.klass + '.photoAlbum' | |||
| class Genre(Container): | |||
| klass = Container.klass + '.genre' | |||
| class MusicGenre(Genre): | |||
| klass = Genre.klass + '.musicGenre' | |||
| class MovieGenre(Genre): | |||
| klass = Genre.klass + '.movieGenre' | |||
| class StorageSystem(Container): | |||
| klass = Container.klass + '.storageSystem' | |||
| class StorageVolume(Container): | |||
| klass = Container.klass + '.storageVolume' | |||
| class StorageFolder(Container): | |||
| klass = Container.klass + '.storageFolder' | |||
| class DIDLElement(_ElementInterface): | |||
| def __init__(self): | |||
| _ElementInterface.__init__(self, 'DIDL-Lite', {}) | |||
| self.attrib['xmlns'] = 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite' | |||
| self.attrib['xmlns:dc'] = 'http://purl.org/dc/elements/1.1/' | |||
| self.attrib['xmlns:upnp'] = 'urn:schemas-upnp-org:metadata-1-0/upnp' | |||
| def addContainer(self, id, parentID, title, restricted = False): | |||
| e = Container(id, parentID, title, restricted, creator = '') | |||
| self.append(e.toElement()) | |||
| def addItem(self, item): | |||
| self.append(item.toElement()) | |||
| def numItems(self): | |||
| return len(self) | |||
| def toString(self): | |||
| return tostring(self) | |||
| if __name__ == '__main__': | |||
| root = DIDLElement() | |||
| root.addContainer('0\Movie\\', '0\\', 'Movie') | |||
| root.addContainer('0\Music\\', '0\\', 'Music') | |||
| root.addContainer('0\Photo\\', '0\\', 'Photo') | |||
| root.addContainer('0\OnlineMedia\\', '0\\', 'OnlineMedia') | |||
| print tostring(root) | |||
| @@ -0,0 +1,9 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| from twisted.web import static | |||
| class MediaServer(static.File): | |||
| pass | |||
| @@ -0,0 +1,8 @@ | |||
| This code has been abandoned, but released in the hope that it might | |||
| be useful for someone. It is licensed under the MIT license at: | |||
| http://opensource.org/licenses/mit-license.php | |||
| Tim Potter <tpot@samba.org> | |||
| 2006-01-19 | |||
| @@ -0,0 +1,143 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| # | |||
| # Implementation of SSDP server under Twisted Python. | |||
| # | |||
| import string | |||
| from twisted.python import log | |||
| from twisted.internet.protocol import DatagramProtocol | |||
| # TODO: Is there a better way of hooking the SSDPServer into a reactor | |||
| # without having to know the default SSDP port and multicast address? | |||
| # There must be a twisted idiom for doing this. | |||
| SSDP_PORT = 1900 | |||
| SSDP_ADDR = '239.255.255.250' | |||
| # TODO: Break out into a HTTPOverUDP class and implement | |||
| # process_SEARCH(), process_NOTIFY() methods. Create UPNP specific | |||
| # class to handle services etc. | |||
| class SSDPServer(DatagramProtocol): | |||
| """A class implementing a SSDP server. The notifyReceived and | |||
| searchReceived methods are called when the appropriate type of | |||
| datagram is received by the server.""" | |||
| elements = {} | |||
| known = {} | |||
| def datagramReceived(self, data, (host, port)): | |||
| """Handle a received multicast datagram.""" | |||
| # Break up message in to command and headers | |||
| data = string.replace(data, '\r', '') | |||
| data = string.replace(data, ': ', ':') | |||
| lines = string.split(data, '\n') | |||
| lines = filter(lambda x: len(x) > 0, lines) | |||
| cmd = string.split(lines[0], ' ') | |||
| headers = dict([string.split(x, ':', 1) for x in lines[1:]]) | |||
| # TODO: datagram may contain a payload, i.e Content-Length | |||
| # header is > 0. Maybe use some twisted HTTP object to do the | |||
| # parsing for us. | |||
| # SSDP discovery | |||
| if cmd[0] == 'M-SEARCH' and cmd[1] == '*': | |||
| self.discoveryRequest(headers, (host, port)) | |||
| # SSDP presence | |||
| if cmd[0] == 'NOTIFY' and cmd[1] == '*': | |||
| self.notifyReceived(headers, (host, port)) | |||
| def discoveryRequest(self, headers, (host, port)): | |||
| """Process a discovery request. The response must be sent to | |||
| the address specified by (host, port).""" | |||
| log.msg('Discovery request for %s' % headers['ST']) | |||
| # Do we know about this service? | |||
| if not self.known.has_key(headers['ST']): | |||
| return | |||
| # Generate a response | |||
| response = [] | |||
| response.append('HTTP/1.1 200 OK') | |||
| for k, v in self.known[headers['ST']].items(): | |||
| response.append('%s: %s' % (k, v)) | |||
| log.msg('responding with: %s' % response) | |||
| self.transport.write( | |||
| string.join(response, '\r\n') + '\r\n\r\n', (host, port)) | |||
| def register(self, usn, st, location): | |||
| """Register a service or device that this SSDP server will | |||
| respond to.""" | |||
| log.msg('Registering %s' % st) | |||
| self.known[st] = {} | |||
| self.known[st]['USN'] = usn | |||
| self.known[st]['LOCATION'] = location | |||
| self.known[st]['ST'] = st | |||
| self.known[st]['EXT'] = '' | |||
| self.known[st]['SERVER'] = 'Twisted, UPnP/1.0, python-upnp' | |||
| self.known[st]['CACHE-CONTROL'] = 'max-age=1800' | |||
| def notifyReceived(self, headers, (host, port)): | |||
| """Process a presence announcement. We just remember the | |||
| details of the SSDP service announced.""" | |||
| if headers['NTS'] == 'ssdp:alive': | |||
| if not self.elements.has_key(headers['NT']): | |||
| # Register device/service | |||
| self.elements[headers['NT']] = {} | |||
| self.elements[headers['NT']]['USN'] = headers['USN'] | |||
| self.elements[headers['NT']]['host'] = (host, port) | |||
| log.msg('Detected presence for %s' % headers['NT']) | |||
| elif headers['NTS'] == 'ssdp:byebye': | |||
| if self.elements.has_key(headers['NT']): | |||
| # Unregister device/service | |||
| del(self.elements[headers['NT']]) | |||
| log.msg('Detected absence for %s' % headers['NT']) | |||
| else: | |||
| log.msg('Unknown subtype %s for notification type %s' % | |||
| (headers['NTS'], headers['NT'])) | |||
| def findService(self, name): | |||
| """Return information about a service registered over SSDP.""" | |||
| # TODO: Implement me. | |||
| # TODO: Send out a discovery request if we haven't registered | |||
| # a presence announcement. | |||
| def findDevice(self, name): | |||
| """Return information about a device registered over SSDP.""" | |||
| # TODO: Implement me. | |||
| # TODO: Send out a discovery request if we haven't registered | |||
| # a presence announcement. | |||
| @@ -0,0 +1,53 @@ | |||
| #!/usr/bin/python | |||
| # | |||
| # Small client for sending text to a socket and displaying the result. | |||
| # | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| from twisted.internet import reactor, error | |||
| from twisted.internet.protocol import Protocol, ClientFactory | |||
| class Send(Protocol): | |||
| def connectionMade(self): | |||
| self.transport.write('''POST /ContentDirectory/control HTTP/1.1\r | |||
| Host: 192.168.126.1:80\r | |||
| User-Agent: POSIX, UPnP/1.0, Intel MicroStack/1.0.1423\r | |||
| SOAPACTION: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"\r | |||
| Content-Type: text/xml\r | |||
| Content-Length: 511\r | |||
| \r | |||
| \r | |||
| <?xml version="1.0" encoding="utf-8"?>\r | |||
| <s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">\r | |||
| <s:Body>\r | |||
| <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">\r | |||
| <ObjectID>0\OnlineMedia\Internet radio\</ObjectID>\r | |||
| <BrowseFlag>BrowseDirectChildren</BrowseFlag>\r | |||
| <Filter>*</Filter>\r | |||
| <StartingIndex>0</StartingIndex>\r | |||
| <RequestedCount>7</RequestedCount>\r | |||
| <SortCriteria></SortCriteria>\r | |||
| </u:Browse>\r | |||
| </s:Body>\r | |||
| </s:Envelope>\r\n''') | |||
| def dataReceived(self, data): | |||
| print(data) | |||
| def connectionLost(self, reason): | |||
| if reason.type != error.ConnectionDone: | |||
| print str(reason) | |||
| reactor.stop() | |||
| class SendFactory(ClientFactory): | |||
| protocol = Send | |||
| host = '192.168.126.128' | |||
| port = 5643 | |||
| reactor.connectTCP(host, port, SendFactory()) | |||
| reactor.run() | |||
| @@ -0,0 +1,67 @@ | |||
| #!/usr/bin/python | |||
| # | |||
| # Test harness for UPnP ContentDirectory:1 service. | |||
| # | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| from twisted.web import client | |||
| from twisted.internet import reactor | |||
| from twisted.python import usage | |||
| import sys, string, SOAPpy | |||
| class UPnPSOAPProxy: | |||
| """A proxy for making UPnP SOAP calls.""" | |||
| def __init__(self, url): | |||
| self.url = url | |||
| def _cbGotResult(self, result): | |||
| return SOAPpy.parseSOAPRPC(result) | |||
| def callRemote(self, method, *args, **kwargs): | |||
| payload = SOAPpy.buildSOAP(args = args, kw = kwargs, method = method) | |||
| payload = string.replace(payload, '\n', '\r\n') | |||
| action = \ | |||
| '"urn:schemas-upnp-org:service:ContentDirectory:1#%s"' % method | |||
| page = client.getPage(self.url, postdata = payload, method = 'POST', | |||
| headers = {'Content-Type': 'text/xml', | |||
| 'SOAPACTION': action}) | |||
| return page.addCallback(self._cbGotResult) | |||
| class Options(usage.Options): | |||
| pass | |||
| def printResult(value): | |||
| print value | |||
| reactor.stop() | |||
| def printError(error): | |||
| print 'error', error | |||
| reactor.stop() | |||
| #proxy = UPnPSOAPProxy('http://192.168.126.128:5643/ContentDirectory/control') | |||
| proxy = UPnPSOAPProxy('http://127.0.0.1:8080/ContentDirectory/control') | |||
| #proxy = UPnPSOAPProxy('http://16.176.65.48:5643/ContentDirectory/control') | |||
| #proxy.callRemote('GetSearchCapabilities').addCallbacks(printResult, printError) | |||
| #proxy.callRemote('GetSortCapabilities').addCallbacks(printResult, printError) | |||
| proxy.callRemote('Browse', | |||
| ObjectID = '0\\Music\\Genres\\Others\\chimes.wav', | |||
| # BrowseFlag = 'BrowseDirectChildren', | |||
| BrowseFlag = 'BrowseMetadata', | |||
| Filter = '*', | |||
| StartingIndex = 0, | |||
| RequestedCount = 700, | |||
| SortCriteria = None).addCallbacks(printResult, printError) | |||
| reactor.run() | |||
| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="utf-8"?><scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>GetCurrentConnectionInfo</name><argumentList><argument><name>ConnectionID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument><argument><name>RcsID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable></argument><argument><name>AVTransportID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable></argument><argument><name>ProtocolInfo</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable></argument><argument><name>PeerConnectionManager</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable></argument><argument | |||
| ><name>PeerConnectionID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument><argument><name>Direction</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable></argument><argument><name>Status</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable></argument></argumentList></ | |||
| action><action><name>GetProtocolInfo</name><argumentList><argument><name>Source</name><direction>out</direction><relatedStateVariable>SourceProtocolInfo</relatedStateVariable></argument><argument><name>Sink</name><direction>out</direction><relatedStateVariable>SinkProtocolInfo</relatedStateVariable></argument></argumentList></action><action><name>GetCurrentConnectionIDs</name><argumentList><argument><name>ConnectionIDs</name><direction>out</direction><relatedStateVariable>CurrentConnectionIDs</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionStatus</name><dataType>string</dataType><allowedValueList><allowedValue>OK</allowedValue><allowedValue>ContentFormatMismatch</allowedValue><allowedValue>InsufficientBandwidth</allowedValue><allowedValue>UnreliableChannel</allowedValue><allowedValue>Unknown</allowedValu | |||
| e></allowedValueList></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_AVTransportID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_RcsID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionID</name><dataType>i4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ConnectionManager</name><da | |||
| taType>string</dataType> | |||
| </stateVariable><stateVariable sendEvents="yes"><name>SourceProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SinkProtocolInfo</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Direction</name><dataType>string</dataType><allowedValueList><allowedValue>Input</allowedValue><allowedValue>Output</allowedValue></allowedValueList></stateVariable><stateVariable sendEvents="yes"><name>CurrentConnectionIDs</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd> | |||
| @@ -0,0 +1,7 @@ | |||
| <?xml version="1.0" encoding="utf-8"?><scpd xmlns="urn:schemas-upnp-org:service-1-0"><specVersion><major>1</major><minor>0</minor></specVersion><actionList><action><name>Browse</name><argumentList><argument><name>ObjectID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable></argument><argument><name>BrowseFlag</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable></argument><argument><name>Filter</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable></argument><argument><name>StartingIndex</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable></argument><argument><name>RequestedCount</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument><argument><name>SortCriteria</name><direction>in</direction><related | |||
| StateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable></argument><argument><name>Result</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable></argument><argument><name>NumberReturned</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument><argument><name>TotalMatches</name><direction>out</direction><relatedStateVariable>A_ARG_T | |||
| YPE_Count</relatedStateVariable></argument><argument><name>UpdateID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable></argument></argumentList></action><action><name>GetSortCapabilities</name><argumentList><argument><name>SortCaps</name><direction>out</direction><relatedStateVariable>SortCapabilities</relatedStateVariable></argument></argumentList></action><action><name>GetSystemUpdateID</name><argumentList><argument><name>Id</name><direction>out</direction><relatedStateVariable>SystemUpdateID</relatedStateVariable></argument></argumentList></action><action><name>Search</name><argumentList><argument><name>ContainerID</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable></argument><argument><name>SearchCriteria</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_SearchCriteria</relatedStateVariable></argument><argument><name>Filter</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Filter</relatedS | |||
| tateVariable></argument><argument><name>StartingIndex</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable></argument><argument><name>RequestedCount</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument><argument><name>SortCriteria</name><direction>in</direction><relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable></arg | |||
| ument><argument><name>Result</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable></argument><argument><name>NumberReturned</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument><argument><name>TotalMatches</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument><argument><name>UpdateID</name><direction>out</direction><relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable></argument></argumentList></action><action><name>GetSearchCapabilities</name><argumentList><argument><name>SearchCaps</name><direction>out</direction><relatedStateVariable>SearchCapabilities</relatedStateVariable></argument></argumentList></action></actionList><serviceStateTable><stateVariable sendEvents="no"><name>A_ARG_TYPE_BrowseFlag</name><dataType>string</dataType><allowedValueList><allowedValue>BrowseMetadata</allowedValue><allowedValue>BrowseDirectChildren</allowedValue></allowedValueList> | |||
| </stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_SearchCriteria</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="yes"><name>SystemUpdateID</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Count</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_SortCriteria</name><dataType>string</dataType></s | |||
| tateVariable><stateVariable sendEvents="no"><name>SortCapabilities</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Index</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_ObjectID</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_UpdateID</name><dataType>ui4</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Result</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>SearchCapabilities</name><dataType>string</dataType></stateVariable><stateVariable sendEvents="no"><name>A_ARG_TYPE_Filter</name><dataType>string</dataType></stateVariable></serviceStateTable></scpd> | |||
| @@ -0,0 +1,77 @@ | |||
| #!/usr/bin/python | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| import sys | |||
| from twisted.python import log | |||
| from twisted.internet import reactor | |||
| listenAddr = sys.argv[1] | |||
| log.startLogging(sys.stdout) | |||
| # Create SSDP server | |||
| from SSDP import SSDPServer, SSDP_PORT, SSDP_ADDR | |||
| s = SSDPServer() | |||
| port = reactor.listenMulticast(SSDP_PORT, s, SSDP_ADDR) | |||
| port.joinGroup(SSDP_ADDR, listenAddr) | |||
| uuid = 'uuid:XVKKBUKYRDLGJQDTPOT' | |||
| s.register('%s::upnp:rootdevice' % uuid, | |||
| 'upnp:rootdevice', | |||
| 'http://%s:8080/root-device.xml' % listenAddr) | |||
| s.register(uuid, | |||
| uuid, | |||
| 'http://%s:8080/root-device.xml' % listenAddr) | |||
| s.register('%s::urn:schemas-upnp-org:device:MediaServer:1' % uuid, | |||
| 'urn:schemas-upnp-org:device:MediaServer:1', | |||
| 'http://%s:8080/root-device.xml' % listenAddr) | |||
| s.register('%s::urn:schemas-upnp-org:service:ConnectionManager:1' % uuid, | |||
| 'urn:schemas-upnp-org:device:ConnectionManager:1', | |||
| 'http://%s:8080/root-device.xml' % listenAddr) | |||
| s.register('%s::urn:schemas-upnp-org:service:ContentDirectory:1' % uuid, | |||
| 'urn:schemas-upnp-org:device:ContentDirectory:1', | |||
| 'http://%s:8080/root-device.xml' % listenAddr) | |||
| # Create SOAP server | |||
| from twisted.web import server, resource, static | |||
| from ContentDirectory import ContentDirectoryServer | |||
| from ConnectionManager import ConnectionManagerServer | |||
| class WebServer(resource.Resource): | |||
| def __init__(self): | |||
| resource.Resource.__init__(self) | |||
| class RootDevice(static.File): | |||
| def __init__(self): | |||
| static.File.__init__(self, 'root-device.xml', defaultType = 'text/xml') | |||
| root = WebServer() | |||
| root.putChild('ContentDirectory', ContentDirectoryServer()) | |||
| root.putChild('ConnectionManager', ConnectionManagerServer()) | |||
| root.putChild('root-device.xml', RootDevice()) | |||
| # Area of server to serve media files from | |||
| from MediaServer import MediaServer | |||
| root.putChild('media', static.File('media')) | |||
| site = server.Site(root) | |||
| reactor.listenTCP(8080, site) | |||
| # Main loop | |||
| reactor.run() | |||
| @@ -0,0 +1,35 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
| <root xmlns="urn:schemas-upnp-org:device-1-0"> | |||
| <specVersion> | |||
| <major>1</major> | |||
| <minor>0</minor> | |||
| </specVersion> | |||
| <device> | |||
| <deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType> | |||
| <INMPR03>1.0</INMPR03> | |||
| <friendlyName>Python Media Server</friendlyName> | |||
| <manufacturer>Zensonic</manufacturer> | |||
| <manufacturerURL>http://www.redsonic.com</manufacturerURL> | |||
| <modelDescription>UPnP/AV 1.0 Compliant Media Server</modelDescription> | |||
| <modelName>PC-MediaServer-DSM</modelName> | |||
| <modelNumber>103</modelNumber> | |||
| <serialNumber>0000001</serialNumber> | |||
| <UDN>uuid:XVKKBUKYRDLGJQDTPOT</UDN> | |||
| <serviceList> | |||
| <service> | |||
| <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType> | |||
| <serviceId>urn:upnp-org:serviceId:CMGR_0-99</serviceId> | |||
| <SCPDURL>ConnectionManager/scpd.xml</SCPDURL> | |||
| <controlURL>ConnectionManager/control</controlURL> | |||
| <eventSubURL>ConnectionManager/event</eventSubURL> | |||
| </service> | |||
| <service> | |||
| <serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType> | |||
| <serviceId>urn:upnp-org:serviceId:CDS_0-99</serviceId> | |||
| <SCPDURL>ContentDirectory/scpd.xml</SCPDURL> | |||
| <controlURL>ContentDirectory/control</controlURL> | |||
| <eventSubURL>ContentDirectory/event</eventSubURL> | |||
| </service> | |||
| </serviceList> | |||
| </device> | |||
| </root> | |||
| @@ -0,0 +1,18 @@ | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| from twisted.web import soap | |||
| import SOAPpy | |||
| class UPnPPublisher(soap.SOAPPublisher): | |||
| """UPnP requires OUT parameters to be returned in a slightly | |||
| different way than the SOAPPublisher class does.""" | |||
| def _gotResult(self, result, request, methodName): | |||
| response = SOAPpy.buildSOAP(kw=result, | |||
| encoding=self.encoding) | |||
| self._sendResponse(request, response) | |||
| @@ -0,0 +1,32 @@ | |||
| <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp"> | |||
| <container id="0\Music\All%20Tracks\" parentID="0\Music\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>All Tracks</dc:title> | |||
| <upnp:class>object.container.storageFolder</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\Playlists\" parentID="0\Music\" restricted="0" searchable="0" childCount="0"> | |||
| <dc:title>Playlists</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\Genres\" parentID="0\Music\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>Genres</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\Artists\" parentID="0\Music\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>Artists</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\Albums\" parentID="0\Music\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>Albums</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\Folders\" parentID="0\Music\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>Folders</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| </DIDL-Lite> | |||
| @@ -0,0 +1,11 @@ | |||
| <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp"> | |||
| <item id="0\Music\Genres\Others\chimes.wav" parentID="0\Music\Genres\Others\" restricted="0"> | |||
| <dc:title>chimes.wav</dc:title> | |||
| <upnp:class>object.item.audioItem.musicTrack</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| <upnp:genre>Others</upnp:genre> | |||
| <upnp:artist>Others</upnp:artist> | |||
| <upnp:album>Others</upnp:album> | |||
| <res protocolInfo="http-get:*:audio/x-wav:*" bitrate="176" size="15920">http://16.176.65.48:5643/web/C:\temp\Resource%20Kit\kixtart\chimes.wav</res> | |||
| </item> | |||
| </DIDL-Lite> | |||
| @@ -0,0 +1,14 @@ | |||
| <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp"> | |||
| <container id="0\Photo\All%20Photos\" parentID="0\Photo\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>All Photos</dc:title> | |||
| <upnp:class>object.container.album.photoAlbum</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| <upnp:icon>http://16.176.65.48:5643/web/C:\temp\foo.jpg.thumb</upnp:icon> | |||
| </container> | |||
| <container id="0\Photo\Folders\" parentID="0\Photo\" restricted="0" searchable="0" childCount="1"> | |||
| <dc:title>Folders</dc:title> | |||
| <upnp:class>object.container.album.photoAlbum</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| <upnp:icon>http://16.176.65.48:5643/web/C:\temp\foo.jpg.thumb</upnp:icon> | |||
| </container> | |||
| </DIDL-Lite> | |||
| @@ -0,0 +1,22 @@ | |||
| <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp"> | |||
| <container id="0\Movie\" parentID="0\" restricted="0" searchable="0" childCount="0"> | |||
| <dc:title>Movie</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Music\" parentID="0\" restricted="0" searchable="0" childCount="0"> | |||
| <dc:title>Music</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\Photo\" parentID="0\" restricted="0" searchable="0" childCount="0"> | |||
| <dc:title>Photo</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| <container id="0\OnlineMedia\" parentID="0\" restricted="0" searchable="0" childCount="0"> | |||
| <dc:title>OnlineMedia</dc:title> | |||
| <upnp:class>object.container</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </container> | |||
| </DIDL-Lite> | |||
| @@ -0,0 +1,8 @@ | |||
| <DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp"> | |||
| <item id="0\\Music\\Genres\\Others\\chimes.wav" parentID="0\\Music\\Genres\\Others\\" restricted="0"> | |||
| <dc:title>chimes.wav</dc:title> | |||
| <upnp:class>object.item.audioItem.musicTrack</upnp:class> | |||
| <dc:creator></dc:creator> | |||
| </item> | |||
| </DIDL-Lite> | |||
| @@ -0,0 +1,18 @@ | |||
| #!/usr/bin/python | |||
| # (c) 2005, Tim Potter <tpot@samba.org> | |||
| # Licensed under the MIT license | |||
| # http://opensource.org/licenses/mit-license.php | |||
| """Take XML on stdin and produce pretty-printed XML on stdout.""" | |||
| import sys | |||
| from xml.dom import minidom | |||
| str = "" | |||
| for s in sys.stdin.readlines(): | |||
| str = str + s[:-1] # Eat trailing \n | |||
| doc = minidom.parseString(str) | |||
| print doc.toprettyxml(indent = " ") | |||