Wednesday, June 30, 2010

A Simple Sonos JavaScript and Java Application


(The code on this page was last checked and verified in June 2014.)

This is the second of several posts dealing with the Sonos system. (Click the "sonos" label keyword above to see all the posts.) The goal of this exercise was to get information about what Sonos is playing in a browser. The first post looked at simple ways of discovering your Sonos system. This post continues where the first post left off and creates a web page to display what Sonos is playing along with album artwork. I took inspiration for the code shown in the page below from this particular post on the Sonos Forums.

(A really good slick solution to use a browser (locally) to show what Sonos is playing and control it, is here: Node.js Sonos Web Controller. It requires slightly more setup (beyond just your browser), but not much more.)

You can run the page shown below on a web site hosted locally or just run the page as is (e.g., double-clicking it). We only tested this code on Windows 8 with Chrome (version 35) and Internet Explorer (version 11). In these browsers you may have to disable the same-origin policy. The image below shows how to disable same-origin policy in IE (or enable data access across domains). Go to Internet Options / Security / Miscellaneous "Access data sources across domains". Better yet you might want to set it to Prompt. When done experimenting, you put this settings back to Disable.



In Chrome, you can shut all instances of Chrome and then start it up with:

"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --disable-web-security


Or if you don't want to shut down current instances of Chrome, start as a new user:

"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --args --user-data-dir="C:/Chrome dev session" --disable-web-security

The web page her uses POST requests to the zone players which include a SOAP body. The response includes XML to parse with status or metadata. To understand more about this type of request, see the previous post Exploring Sonos via UPnP.

Some points to keep in mind:
  • Customize the code for your situation, in particular the _sonosTopology variable.
  • The more general solution to deal with same-origin policy is to use Cross-Origin Resource Sharing CORS. What wasn't clear to me is if the Sonos zone player (i.e. the server) could deal with CORS, let alone that you could set the proper headers.
  • All the code is in one page for demonstration purposes. The parsing of the XML returned for various queries is not optimal.
  • Encoding/decoding URLs and escaping/unescaping certain characters in XML can be tricky - I'm sure there are some scenarios where it is needed but were missed in this code.
This code is shown below and can also be found on Github here: https://github.com/travelmarx/travelmarx-blog/blob/master/WhatIsSonosPlaying/WhatSonosIsPlaying.htm.

<!DOCTYPE html>
<html>
<head>
<title>What's Our Sonos Playing?</title>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script type="text/javascript">
    jQuery.noConflict();

    // Global variables that need to be customized to the environment.
    // Probably could get these automatically. To get them manually, use a tool like
    // Device Spy.exe to get these, http://opentools.homeip.net/dev-tools-for-upnp.
    var _sonosTopology = {
        "zones": [
        { "name": "office", "ip": "192.168.2.225", "id": "RINCON_000E58512EBC01400" },
        { "name": "kitchen", "ip": "192.168.2.155", "id": "RINCON_000E5825CEFC01400" },
        { "name": "living room", "ip": "192.168.2.10", "id": "RINCON_000E5833994801400" },
        { "name": "media room", "ip": "192.168.2.203", "id": "RINCON_000E5825B14201400" },
        { "name": "bedroom", "ip": "192.168.2.15", "id": "RINCON_000E5858A7CA01400" }
    ]
    };
    var _providers = [{ "name": "Rhapsody", "keyword": "rhapsody" },
                      { "name": "Pandora", "keyword": "pandora" },
                      { "name": "Local", "keyword": "x-file-cifs" },
                      { "name": "Radio", "keyword": "aac" },
                      { "name": "Radio", "keyword": "mms"}];

    // Global variables that don't need to be customized to the environment.
    var _soapRequestTemplate = '<?xml version="1.0" encoding="utf-8"?><s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body>{0}</s:Body></s:Envelope>';
    var _port = ':1400';
    var _currentArtist = "";
    var _currentComposer = "";
    var _currentAlbum = "";
    var _selectedZone = 0;  // zone serving up media
    var _refreshRate = 15000; // milliseconds
    var _debug = false;  // Browser security settings may prevent this from working.
    var _autoSetToMaster = true;
    var _masterFound = false;
    var _playlistsRetrieved = false;
    var _trackChange = true;
    var _debugWindow = null;
    var _debugConsole;
    var RequestType = { "metadata": 0, "transport": 1, "playlists": 2, "oneplaylist": 3 };

    // Some general functions for the page
    //

    // Logging functionality.
    function log(message) {
        if (!_debug) {
            return;
        }
        if (_debugWindow === null) {
            _debugWindow = window.open("", "", "width=350,height=250,menubar=0,toolbar=1,status=0,scrollbars=1,resizable=1");
            _debugWindow.document.writeln('<html><head><title>Console</title></head><body bgcolor=white></body></html>');
            _debugConsole = _debugWindow.document.body;
        }
        _debugConsole.innerHTML += message + "<hr/>";
    }
    // Once the DOM is loaded then we can work with HTML elements.
    jQuery(document).ready(function () {
        jQuery.support.cors = true;
        for (var i = 0; i < _sonosTopology.zones.length; i++) {  
            var zoneName = _sonosTopology.zones[i].name;          
            addOption(jQuery('#ZoneSelect')[0], zoneName, i);
        }
        jQuery('#ZoneSelect')[0].selectedIndex = 1;
        _selectedZone = jQuery('#ZoneSelect')[0].selectedIndex;
        refreshCurrentlyPlaying();
        log("Page initialized.");
        setInterval(refreshCurrentlyPlaying, _refreshRate);
        jQuery("#addthisdiv").click(function () {
        });
    });
    // Creates zone dropdown.
    function addOption(selectbox, text, value) {
        var optn = document.createElement("OPTION");
        optn.text = text;
        optn.value = value;
        selectbox.options.add(optn);
    }

    //
    // The following functions represent the functionality of making requests to the
    // UPnP devices and dealing with the response.
    //

    // Function to mute or unmote the selected zone. Action: 0 (unmute), 1 (mute)
    function muteOrUnMute(action) {
        var url, xml, soapBody, soapAction;
        var _activeZone = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_activeZone].ip + _port;
        url = '/MediaRenderer/RenderingControl/Control';
        soapAction = "urn:upnp-org:serviceId:RenderingControl#SetMute";
        soapBody = '<u:SetMute xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"><InstanceID>0</InstanceID><Channel>Master</Channel><DesiredMute>' + action + '</DesiredMute></u:SetMute>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.transport);
    }

    // Function to process Play, Stop, Pause, Previous and Next commands.
    function transport(cmd) {
        var url, xml, soapBody, soapAction;
        var _activeZone = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_activeZone].ip + _port;
        url = '/MediaRenderer/AVTransport/Control';
        soapAction = "urn:schemas-upnp-org:service:AVTransport:1#" + cmd;
        soapBody = '<u:' + cmd + ' xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><Speed>1</Speed></u:' + cmd + '>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.transport);
    }

    // Get playlists.
    function getPlaylists() {
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaServer/ContentDirectory/Control';
        soapAction = 'urn:schemas-upnp-org:service:ContentDirectory:1#Browse';
        soapBody = '<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID>SQ:</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter></Filter><StartingIndex>0</StartingIndex><RequestedCount>100</RequestedCount><SortCriteria></SortCriteria></u:Browse>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.playlists);
    }

    // Get one playlist based on its identifier.
    function getPlaylist(value) {
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaServer/ContentDirectory/Control';
        soapAction = 'urn:schemas-upnp-org:service:ContentDirectory:1#Browse';
        soapBody = '<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"><ObjectID>' + value + '</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter></Filter><StartingIndex>0</StartingIndex><RequestedCount>1000</RequestedCount><SortCriteria></SortCriteria></u:Browse>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.oneplaylist);
    }

    // Refresh metadata.
    function refreshCurrentlyPlaying() {
        // Set some globals to default.
        _currentAlbum = _currentArtist = _currentComposer = "";

        if (_trackChange) {
            jQuery.each(jQuery('div[id$=Metadata]'), function (i, item) {
                item.className = "ElementHidden";
            });
        }
        var url, xml, soapBody, soapAction;
        var _zoneToPullFrom = jQuery('#ZoneSelect')[0].selectedIndex;
        var host = _sonosTopology.zones[_zoneToPullFrom].ip + _port;
        url = '/MediaRenderer/AVTransport/Control';
        soapAction = 'urn:schemas-upnp-org:service:AVTransport:1#GetPositionInfo';
        soapBody = '<u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><Channel>Master</Channel></u:GetPositionInfo>';
        xml = _soapRequestTemplate.replace('{0}', soapBody);
        sendSoapRequest(url, host, xml, soapAction, RequestType.metadata);
        if (!_playlistsRetrieved) {
            getPlaylists();
            _playlistsRetrieved = true;
        }
    }

    // Main Ajax request function. uPnP requests go through here.
    // Here we use jQuery Ajax method because it does cross-domain without hassle.
    function sendSoapRequest(url, host, xml, soapAction, requestType) {
        url = 'http://' + host + url;
        jQuery.ajax({
            url: url,
            type: "POST",
            async: true,
            beforeSend: function (xhr) {
                xhr.setRequestHeader("SOAPAction", soapAction);
            },
            data: xml,
            success: function (data, status, xhr) {
                if (requestType == RequestType.metadata) {
                    processSuccessfulAjaxRequestNodes_Metadata(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.playlists) {
                    processSuccessfulAjaxRequestNodes_Playlist(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.oneplaylist) {
                    processSuccessfulAjaxRequestNodes_OnePlaylist(jQuery(data).find("*"), host);
                }
                else if (requestType == RequestType.transport) {
                    // If this isn't a metadata request, then we should refresh the metadata to sync UI.
                    refreshCurrentlyPlaying();
                }
                var response = transport.responseText || "no response text";
                log("Success! \n\n" + data.xml);
            },
            complete: function (xhr, status) {
                var response = status || "no response text";
                log("Complete \n\n" + response);
            },
            ajaxError: function (data) {
                var response = data || "no response text";
                log("Failure! \n\n" + response);
            },
            error: function (xhr, status, err) { log('Exception: ' + err); }
        });
    }

    // Process data of one playlist.
    function processSuccessfulAjaxRequestNodes_OnePlaylist(responseNodes, host) {
        if (responseNodes.children("Result").length == 1) {
            jQuery('#PlaylistDump').html("");
            var sb = "";
            var x = jQuery(responseNodes.children("Result"));
            var y = jQuery.parseXML(x.text());
            var responseNodes2 = y.firstChild.childNodes;
            for (var i = 0; i < responseNodes2.length; i++) {
                var track = "UNK", album = "UNK", creator = "UNK";
                if (jQuery("dc\\:title", responseNodes2[i]).length == 1 || jQuery("title", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:title", responseNodes2[i]).text();
                    track = (testval != "") ? testval : jQuery(responseNodes2[i]).find("title").text();
                }
                if (jQuery("dc\\:creator", responseNodes2[i]).length == 1 || jQuery("creator", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:creator", responseNodes2[i]).text();
                    artist = (testval != "") ? testval : jQuery(responseNodes2[i]).find("creator").text();
                }
                if (jQuery("upnp\\:album", responseNodes2[i]).length == 1 || jQuery("album", responseNodes2[i]).length == 1) {
                    var testval = jQuery("dc\\:album", responseNodes2[i]).text();
                    album = (testval != "") ? testval : jQuery(responseNodes2[i]).find("album").text();
                }
                if (track !== "UNK" | artist !== "UNK" | album !== "UNK") {
                    sb += "\"" + track + "\" from <i>" + album + "</i> by " + artist + "<br/>";
                }
            }
            jQuery('#PlaylistDump').html(sb.toString());
        }
    }

    // Process data from list of playlists.
    function processSuccessfulAjaxRequestNodes_Playlist(responseNodes, host) {
        if (responseNodes.children("Result").length == 1) {
            var x = jQuery(responseNodes.children("Result"));
            var y = jQuery.parseXML(x.text());
            var responseNodes2 = y.firstChild.childNodes;
            jQuery("select[id$=PlaylistSelect] > option").remove();
            for (var i = 0; i < responseNodes2.length; i++) {
                var testval = jQuery("dc\\:title", responseNodes2[i]).text();
                var playlistTitle = ( testval != "") ? testval : jQuery(responseNodes2[i]).find("title").text();
                var playlistId = responseNodes2[i].getAttribute("id");
                addOption(jQuery('#PlaylistSelect')[0], playlistTitle, playlistId);
            }
        }
        else {
            jQuery("select[id$=PlaylistSelect] > option").remove();
            addOption(jQuery('#PlaylistSelect')[0], "Cannot get playlists.", 0);
        }
    }

    // Refresh display on what's currently playing.
    function processSuccessfulAjaxRequestNodes_Metadata(responseNodes, host) {
        for (var i = 0; i < responseNodes.length; i++) {
            var currNodeName = responseNodes[i].nodeName;
            if (currNodeName == "TrackURI") {
                var result = responseNodes[i].firstChild.nodeValue;
                if (result.indexOf("x-rincon") > -1) {
                    var master = result.split(":")[1];
                    var indx = _selectedZone;
                    jQuery.each(_sonosTopology.zones, function (i) {
                        if (_sonosTopology.zones[i].id == master) {
                            indx = i;
                        }
                    });
                    if (!_autoSetToMaster) {
                        jQuery('#coordinatorName')[0].innerHTML = "slaved to " + _sonosTopology.zones[indx].name;
                        jQuery('#CoordinatorMetadata')[0].className = "ElementVisible";
                    }
                    else {
                        jQuery('#ZoneSelect')[0].selectedIndex = indx;
                        refreshCurrentlyPlaying();
                    }
                }
                else {
                    _masterFound = true;
                }
            }
            if (currNodeName == "TrackMetaData") {
                var responseNodes2 = jQuery(responseNodes[i].firstChild.nodeValue).find("*");
                var isStreaming = false;
                for (var j = 0; j < responseNodes2.length; j++) {
                    switch (responseNodes2[j].nodeName) {
                        case "DC:CREATOR":
                            _currentComposer = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentComposer !== jQuery('#composerName')[0].innerHTML) {
                                jQuery('#composerName')[0].innerHTML = _currentComposer;
                            }
                            jQuery('#ComposerMetadata')[0].className = "ElementVisible";
                            break;
                        case "R:ALBUMARTIST":
                            _currentArtist = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentArtist !== jQuery('#artistName')[0].innerHTML) {
                                jQuery('#artistName')[0].innerHTML = _currentArtist;
                            }
                            jQuery('#ArtistMetadata')[0].className = "ElementVisible";
                            break;
                        case "DC:TITLE":
                            if (!isStreaming) {
                                _currentTrack = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                if (_currentTrack !== jQuery('#trackName')[0].innerHTML) {
                                    jQuery('#trackName')[0].innerHTML = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                    _trackChange = true;
                                }
                                else {
                                    _trackChange = false;
                                }
                                jQuery('#TrackMetadata')[0].className = "ElementVisible";
                            }
                            break;

                        case "R:STREAMCONTENT":
                            if (responseNodes2[j].attributes.getNamedItem('protocolInfo') !== null) {
                                _currentTrack = responseNodes2[j].attributes.getNamedItem('protocolInfo').value;
                                if (_currentTrack.length > 1) {
                                    if (_currentTrack !== jQuery('#trackName')[0].innerHTML) {
                                        jQuery('#trackName')[0].innerHTML = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                                        _trackChange = true;
                                    }
                                    else {
                                        _trackChange = false;
                                    }
                                    jQuery('#TrackMetadata')[0].className = "ElementVisible";
                                    isStreaming = true;
                                }
                            }
                            break;

                        case "UPNP:ALBUM":
                            _currentAlbum = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            if (_currentAlbum !== jQuery('#albumName')[0].innerHTML) {
                                jQuery('#albumName')[0].innerHTML = _currentAlbum;
                                jQuery('#albumArt')[0].alt = _currentAlbum;
                            }
                            jQuery('#AlbumMetadata')[0].className = "ElementVisible";
                            break;
                        case "RES":
                            var protocolInfo = responseNodes2[j].attributes.getNamedItem('protocolInfo').value;
                            if (protocolInfo !== undefined) {
                                for (var k = 0; k < _providers.length; k++) {
                                    if (protocolInfo.toLowerCase().indexOf(_providers[k].keyword) > -1) {
                                        jQuery('#sourceName')[0].innerHTML = _providers[k].name;
                                        jQuery('#SourceMetadata')[0].className = "ElementVisible";
                                    }
                                }
                            }
                            break;
                        case "UPNP:ALBUMARTURI":
                            var newPath = XMLEscape.unescape(responseNodes2[j].firstChild.nodeValue);
                            newPath = (newPath.indexOf("http:") > -1) ? newPath : "http://" + host + newPath;
                            var currPath = jQuery('#albumArt')[0].src;
                            if (newPath !== currPath) {
                                jQuery('#albumArt')[0].src = newPath;
                            }
                            break;

                    }
                }
            }
        }
    }

    // Start dump of one playlist's items.
    function getPlaylistDump() {
        var selectedPlaylist = jQuery('#PlaylistSelect')[0].value;
        getPlaylist(selectedPlaylist);
    }

    // Do simple Google image search by opening a new window.
    function doGoogleImageSearch() {
        var query = (_currentArtist.length > 0) ? _currentArtist + " " + _currentAlbum : _currentComposer + " " + _currentAlbum;
        var url = "https://www.google.com/search?source=lnms&tbm=isch&sa=X&q=";
        url = url + query;
        window.open(url, "_blank");
    }

    // Do simple Bing image search by opening a new window.
    function doBingImageSearch() {
        var query = (_currentArtist.length > 0) ? _currentArtist + " " + _currentAlbum : _currentComposer + " " + _currentAlbum;
        var url = "http://www.bing.com/images/search?q=";
        url = url + query;
        window.open(url, "_blank");
    }

    // Utility functions.

    var XMLEscape = {
        escape: function (string) {
            return this.xmlEscape(string);
        },
        unescape: function (string) {
            return this.xmlUnescape(string);
        },
        xmlEscape: function (string) {
            string = string.replace(/&/g, "&amp;");
            string = string.replace(/"/g, "&quot;");
            string = string.replace(/'/g, "&apos;");
            string = string.replace(/</g, "&lt;");
            string = string.replace(/>/g, "&gt;");
            return string;
        },
        xmlUnescape: function (string) {
            string = string.replace(/&amp;/g, "&");
            string = string.replace(/&quot;/g, "\"");
            string = string.replace(/&apos;/g, "'");
            string = string.replace(/&lt;/g, "<");
            string = string.replace(/&gt;/g, ">");
            return string;
        }
    };
</script>
<style type="text/css">
body { font-family: Verdana; background-color: #BBCDDF; }
span.label { font-weight: bold;}
div.ElementHidden {display: none; }
div.ElementVisible {display: block; text-align: left;}
select.blend {background-color: white;}
img.AssetImg { height: 200px; border: none;}
a.AssetImg { }
div.TraceConsole {position:fixed; bottom:0px; left:0px; right:0px;}
#TableWrapper, #AssetsLostvibe, #AssetsSearch { text-align: center;}
#TableDiv { border: solid 1px black; width: 750px;}
table { }
table tr { vertical-align: top; }
table tr td { padding: 10px;}
</style>
</head>
<body>
    <div id="TableWrapper">
    <div id="TableDiv">
    <table>
        <tr>
            <td>
                <img alt="currently playing" id="albumArt" src="about:blank" 
                     width="300" height="300" style="border: solid 1px black"/>
            </td>
            <td >
                Zone:
                <select class="blend" id="ZoneSelect">
                </select>
                <input type="button" value="refresh" onclick="refreshCurrentlyPlaying()" /> <br />
                <a href="#" onclick="muteOrUnMute(1)">Mute Zone</a> | 
                <a href="#" onclick="muteOrUnMute(0)">UnMute Zone</a><br /><br />
                <div class="ElementHidden" id="TrackMetadata">
                <span class="label">Track:</span> <span id="trackName">
                </span></div>
                <div class="ElementHidden" id="ArtistMetadata">
                <span class="label">Artist:</span> <span id="artistName">
                </span> </div>
                <div class="ElementHidden" id="ComposerMetadata">
                <span class="label">Composer:</span> <span id="composerName">
                </span> </div>
                <div class="ElementHidden" id="AlbumMetadata">
                <span class="label">Album:</span> <span id="albumName">
                </span> </div>
                <div class="ElementHidden" id="CoordinatorMetadata">
                <span class="label">Status:</span> <span id="coordinatorName">
                </span></div>
                <div class="ElementHidden" id="SourceMetadata">
                <span class="label">Source:</span> <span id="sourceName">
                </span></div>
                <div class="ElementVisible" id="PlaylistDiv">
                <span class="label">Playlists:</span>
                <select class="blend" id="PlaylistSelect">
                <option>Getting playlists...please wait</option>
                </select> <input type="button" value="Print" onclick="getPlaylistDump()" />
                </div>
                <br />
                <a href="javascript:transport('Previous');">Previous
                </a> | <a href="javascript:transport('Play');">Play
                </a> | <a href="javascript:transport('Stop');">Stop
                </a> | <a href="javascript:transport('Pause');">Pause
                </a> | <a href="javascript:transport('Next');">Next
                </a> 
                <br /><br />
                <input type="button" value="Google Image Search" onclick="doGoogleImageSearch()" />
                <input type="button" value="Bing Image Search" onclick="doBingImageSearch()" />
            </td>
        </tr>
    </table>
    </div>
    </div>
    <div id="AssetsLostvibe"></div>
    <div id="AssetsSearch"></div>
    <div id="PlaylistDump"></div>
</body>
</html>
Next steps to improve the page:
  • Use auto-discovery of Sonos topology instead of a fixed array of the topology as done here.
  • Subscribe to UPnP events from Sonos to sync UI. In the page below a simple timer is used to poll for changes. It would be nice to go to a subscription/callback approach. From what I read it should be possible, but I never got event subscription to work with Sonos.
  • When choosing a slaved zone, the current page just informs you that it is a slave. The page should take next step go to the coordinator for metadata.
  • Use jQuery plugins to display additional images in a carousel or other interesting display.
  • Stop flicker when metadata update occurs. This is caused by the approach of hiding/showing data elements. It’s not elegant and can be improved on. If subscribing to Sonos events is figured out then this becomes a non-issue.
  • I think there is more resolution for album artwork returned in Sonos metadata. It would be nice to use it.
Update January 2011 A reader, Finn Ellebaek Nielsen, of this post (see comments below) contributed the following small Java application that saves the current queue as both a playlist (.m3u) and a text file with track number, artist and title. The source code is on GitHub at well: https://github.com/travelmarx/travelmarx-blog/tree/master/PlayListExtractor.

package travelmarx;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

import java.io.InputStreamReader;
import java.io.OutputStreamWriter;

import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.net.URLDecoder;

import org.apache.commons.lang3.StringEscapeUtils;

/**
* Class responsible for extracting current Sonos queue to playlist and text
* file.
*/

public class PlayListExtractor {
 public PlayListExtractor(String ipAddress, String filePath, String playListName)
   throws MalformedURLException, ProtocolException, IOException {
   // Build HTTP request with SOAP envelope asking for details about the
   // current queue.
   URL url = new URL("http://" + ipAddress + ":1400/MediaServer/ContentDirectory/Control");
   HttpURLConnection request = (HttpURLConnection)url.openConnection();
  
   request.setRequestMethod("POST");
   request.addRequestProperty("SOAPACTION", "\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"");
   request.setDoOutput(true);
   request.setReadTimeout(10000);
  
   request.connect();
  
   OutputStreamWriter input = new OutputStreamWriter(request.getOutputStream());
   input.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
   input.write("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\r\n");
   input.write("  <s:Body>\r\n");
   input.write("    <u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">\r\n");
   input.write("      <ObjectID>Q:0</ObjectID>\r\n");
   input.write("      <BrowseFlag>BrowseDirectChildren</BrowseFlag>\r\n");
   input.write("      <Filter>upnp:artist,dc:title</Filter>\r\n");
   input.write("     <StartingIndex>0</StartingIndex>\r\n");
   input.write("     <RequestedCount>100</RequestedCount>\r\n");
   input.write("     <SortCriteria></SortCriteria>\r\n");
   input.write("    </u:Browse>\r\n");
   input.write("  </s:Body>\r\n");
   input.write("</s:Envelope>\r\n");
   input.flush();

   // Read entire HTTP response, which is assumed to be in UTF-8.
  BufferedReader output = new BufferedReader(new InputStreamReader(request.getInputStream(), "UTF-8"));
   String response = new String();
   String line;
  
   while ((line = output.readLine()) != null) {
     response += line + "\r\n";
   }

   // Open output files, both in ISO8859-1 encoding.
   OutputStreamWriter txt = new OutputStreamWriter(new FileOutputStream(new File(filePath + "/" + playListName + ".txt")), "8859_1");
   OutputStreamWriter m3u = new OutputStreamWriter(new FileOutputStream(new File(filePath + "/" + playListName + ".m3u")), "8859_1");
  
   int i = 0, j = 0, k = 0, l = 0;
   i = response.indexOf("&lt;item", j);
   while (i >= 0) {
     // Loop over all items, where each item is a track on the queue.
     j = response.indexOf("&lt;/item&gt;", i);
     String track = response.substring(i + 8, j);
     String trackNo, artist, title, unc;

     trackNo = extract(track, " id=&quot;Q:", "&quot;");
     trackNo = trackNo.substring(trackNo.indexOf('/') + 1);
     unc = URLDecoder.decode(extract(track, "&gt;x-file-cifs:", "&lt;").replace('/', '\\').replaceAll("%20", " "), "UTF-8");
     artist = extract(track, "&lt;dc:creator&gt;", "&lt;/dc:creator&gt;");
     title = extract(track, "&lt;dc:title&gt;", "&lt;/dc:title&gt;");
    
     txt.append(decode(trackNo + ". " + artist + ": " + title) + "\r\n");
     m3u.append(decode(unc) + "\r\n");
    
     i = response.indexOf("&lt;item", j);
   }
   txt.close();
   m3u.close();
  
   request.disconnect();
 }

/**
* Extracts text surrounded by markers from given string.
* @param   s       String to extract text from.
* @param   start   Start marker.
* @param   stop    Stop marker.
* @return  Extracted text found between start and stop markers, markers
*          excluded.
*/

 private String extract(String s, String start, String stop) {
   int i = s.indexOf(start) + start.length();
  
   return s.substring(i, s.indexOf(stop, i));
 }

/**
* Decodes HTML character entities. First changes &amp; to &, then uses Apache
* Commons Lang to decode the standard entities and then manually decodes a non-
* standard entity (&apos;).
* @param     s     Text to be decoded.
* @return    Text with HTML character entities decoded.
*/

 private String decode(String s) {
   // Convert &amp; to &, &apos; to ' and let Apache Commons Lang about the rest.
   return StringEscapeUtils.unescapeHtml3(s.replaceAll("&amp;", "&")).replaceAll("&apos;", "'");
 }

/**
* Extracts current Sonos queue and saves track information to a playlist file
* and a text file. The playlist file is saved in .m3u format and the text file
* is a plain text file with each line in the format
* <track_no>. <artist>: <title>
* Both files are in ISO8859-1 format.
* @param   args    0: Sonos master IP address.
*                  1: Export file path.
*                  2: Playlist name.
* @throws  MalformedURLException
* @throws  ProtocolException
* @throws  IOException
*/

 public static void main(String[] args)
   throws MalformedURLException, ProtocolException, IOException {
   if (args.length < 3) {
     System.err.println("Usage: PlayListExtractor sonos_master_ip_address export_file_path playlist_name");
     System.exit(0);
   }
   PlayListExtractor pE = new PlayListExtractor(args[0], args[1], args[2]);
 }
}
The Java program above uses Apache Commons Lang libraries. The application command line is:

PlayListExtractor 192.168.1.13 c:\playlists queue

which takes the queue controlled by the Sonos master with IP address 192.168.1.13 and saves it to c:\playlists\queue.m3u and c:\playlists\queue.txt.

Here's a quick rundown on running the code:

In another post Java, Apache Ant and Hello World, we cover the basics of getting a simple Java program working. Here's a quick overview:

1. Create the following directory structure:

..\PlayListExtractor

..\PlayListExtractor\src\travelmarx\PlayListExtractor.java

..\PlayListExtractor\lib\commons-lang3-3.3.2.jar

..\PlayListExtractor\build\classes


2. Compile the code:
..\PlayListExtractor>javac -classpath lib\* -d build\classes src\travelmarx\PlayListExtractor.java


3. Run the code:
..\PlayListExtractor>java -classpath build\classes;lib\* travelmarx.PlayListExtractor 192.168.2.225 c:\playlists queue


Some ways to improve the program:
  • Figure out which of the players is the master one.
  • Programmatically clear the queue, find the given Sonos playlist to be exported and place this on the queue. With the current version you have to do this manually.
  • Improve error handling.
Thanks Finn.

Saturday, June 26, 2010

The Getty Center – Marino Marini

Marino Marini - Angelo della citta
We first ran into the Italian sculptor Marino Marini (1901 -1980) in Venice where his (in)famous sculpture Angelo della Città greets the boats on the Grand Canal spread-eagle-style from the terrace of the Peggy Guggenheim Collection. Next we ran into him while living in Florence and we spent an afternoon at his museum in Piazza San Pancrazio. Recently, we saw Angelo della Città again at the Getty Center. According to a Getty acquisition list the full title of the work is: Angel of the Citadel-Horse and Rider-Town’s Guardian Angel, 1949-1950, Bronze, 172.7 x 167.6 x 94 cm, but that’s a mouthful. For more on Marini’s inspiration from Etruscan and Northern European sculpture for this composition see here.

Perry Creek Hike

Perry Creek Hike Vista
The Perry Creek Trail is a visually interesting hike for the diversity of plants and scenery you encounter. In fact, part of the trail passes through the Perry Creek Research Natural Area – an area recognized as valuable in its biological diversity. Even on the misty, cool June-ary day we hiked, it was obvious that there is lots going on here. As you walk along avalanche slopes – talus on the north side of Mt. Dickerman - you pass through stands of deciduous and coniferous trees with ferns and moss everywhere; the intensity of green is amazing.

Perry Creek is trail #711. You drive 16 miles past the Verlot Public Service Center (where you can buy a Northwest Pass) to find a parking lot shared by the Mt. Dickerman Hike trailhead and this hike’s trailhead which is really called Perry Creek - Mt. Forgotten Meadows trail. At the parking lot you are at about 2,000 feet. The first part of the hike is through a stand of coniferous trees before intersecting Perry Creek Road #4063 the former trailhead until about a year ago. After leaving the road, the scenery really gets interesting. You walk along the south side of the valley looking across Perry Creek towards Stillaguamish Peak. It’s a fairly easy hike up to the Falls (about 2 miles from the old trailhead, 3.3 miles from the parking lot). Then you cross over the (wide this particular day) creek. One of us took off shoes and socks and waded in up to the shins. The real climb begins after crossing the creek. On this particular June day it was so-so muddy with snow patches at first, and then out-and–out all snow as we reached the ridge at just under 5,000 feet after about 1.7 miles. View: fog and mist on this day, meadows under snow, Mt. Forgotten nowhere to be seen. So we turned around and started back for a total of 10+ miles. Maybe next time with clearer weather and proper equipment we’ll go on to Mt. Forgotten. And alas, we didn’t see any of the famed botrychium from the trail - perhaps too early or too rare?
American Pika - Ochotona princeps

Tuesday, June 22, 2010

Happy Flowers’ Day


Just when I was getting down on Fremont (the Center of the Universe), this note and lupine flower (Lupinus perennis) shows up on our doorstep to be found by us Monday morning. At least one other neighbor received a note and flower too. Monday was the summer solstice after all and Fremontonians love to celebrate the solstice. To the child and parent who collaborated on this gift, thank you, you made our day.

Saturday, June 19, 2010

Exploring Sonos via UPnP

We’ve had the Sonos system for a while now and we decided that it would be “fun” to create a web application that would talk to the Sonos, get information about what’s playing, and most importantly, match additional album art imagery from a place like http://www.lostvibe.com/ to what’s playing. The Sonos system is great, but the album artwork situation is lacking. You only get the front cover artwork. Well, with this as our goal, we set out to figure how to talk to our Sonos. It took endless hours of trying this and that. Hopefully, in this post we’ll simplify the approach and present a better way to attack the problem (not in the order we did). Others have tackled this problem and created products you could download and use like this or this or this (for the HomeSeer system). These applications assume you know little to nothing about how the system is put together and you just want the functionality. We wanted to strike a balance somewhere in the middle, knowing how Sonos works, getting familiar with UPnP, and having some fun with programming. This post just deals with the first part of the investigation and not building of the actual web application. Stay tuned for that. Update 2010-06-30: here it is, the follow up post.

Sonos is UPnP Compatible…What’s That?
Universal Plug and Play (UPnP) is a set of network protocols whose goal is allowing devices to seamlessly discover and connect to a network and thereafter be monitored and controlled. The UPnP protocols simplify your life as a user because routers, printers, phones, and other devices can be plugged in and ready to go without much intervention by you. (Well, that’s the idea and it usually works.)

How Can I See What UPnP Devices I Have?
So you have devices that are UPnP, but how do you see them if you are running Windows? Well you can use the Univeral Plug-and-Play Tester from noeld.com which is where we first started or you can use the Device Spy tool that was once part of Intel’s Tools for UPnP Technologies, but is now open-sourced and can be found here: http://opentools.homeip.net/dev-tools-for-upnp. The Device Spy tool is a bit easier to work with because its two panes make it easier to see what’s going on and overall it's more user-friendly. Here is an example of what you get when you run it each of the tools.

Left: UPnP Tester: Right: Device Spy
UPnP TesterDevice Spy
As you can see from the screenshot, there are five Sonos devices in our house. The highlighted item is an CONNECT:AMP. Expanding any item, we see there is a lot more going on. To understand what's being shown you need to know that there are two generic objects of interest: a device and service. A device is a container for other devices and services. A service exposes actions and models its state with state variables. So, in the expanded screenshot of a PLAY:5 you can see an example of devices and services:




Device (e.g. ZPS5) (device XML http://192.168.2.10:1400/xml/zone_player.xml)
--Service: AlarmClock
--Service: AudioIn
--Service: DeviceProperties
--Service: GroupManagement
--Service: MusicServices
--Service: SystemProperties
--Service: ZoneGroup Topology
----Device: Media Renderer
------Service: Queue
------Service: AVTransport
------Service: Connection Manager
------Service: GroupRenderingControl (device XML http://192.168.2.225:1400/xml/AVTransport1.xml)
------Service: RenderingControl
----Device: Media Server
------Service: ConnectionManager
------Service: ContentDirectory

We only show two device/service XML URLs above. Once you use the Device Spy tool, you can easily get the other XML URLs and they follow the same pattern as shown above. The service XML is useful because it tells you what actions you can take with the service. So for the AVTransport service you probably can guess there are actions of some sort to start and stop the music. The device/service topology presented by the Device Spy is (probably) built from looking at device and service XML files. The tool doesn't know anything about the devices and services and so has to discover them. To be exact, the Device Spy discovers what’s on the network, then it looks at the device XML which refers to all the sub-devices and services and their XML files and so on. From all the device and service XML, the topology can be rendered.

You can see from the screenshot that we are dealing with a zone called “Office”.

Can I Control My Sonos From Here?
Yes, with the Device Spy tool (or similar) you can control Sonos devices. It’s not convenient for lots of actions so people write programs to abstract the process, naturally. But, using this tool you can get an idea of what’s available and help you understand the system.

Expand the AVTransport service, right click the GetTransportInfo action, and select Invoke Action which brings you to the window shown. When you click the Invoke button it queries the zone player and returns information. You see in this example that the zone is PLAYING. You can see possible values for the TransportState by navigating to the State Variables folder icon, expanding and clicking on TransportState.





To stop the current zone player go the Stop action, right click and select Invoke Action (or just double-click Stop), and click the Invoke button.

Can I Figure Out What’s Playing Using Device Spy?
Yes, but it’s a bit tricky. Because zone players can be grouped into zone groups, there is the concept of a group coordinator. The group coordinator is what you have to “ask” for the metadata on what’s playing. So, to continue with this example, we are looking at the “Office” zone player. Going to the GetPositionInfo action and invoking it you see that the TrackURI value has “x-rincon” in it and that TrackMetaData has NOT_IMPLEMENTED. Cutting to the chase, this means that this zone is a slave to the master or group coordinator with the given id RINCON_xxxxxxxxx.



If you go to the group coordinator specified in the slave zone's TrackURI field and invoke the GetPositionInfo action for that group coordinator zone player then you will get metadata for what's playing.




In this example, a track is being played from a local server so the TrackMetaData gives us information to this effect: the artist, the album, the track, and a link to the album art imagery.

<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"  xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
  <item id="-1" parentID="-1" restricted="true">
   <res protocolInfo="x-file-cifs:*:audio/flac:*" duration="0:06:40">x-file-cifs://mediamarx/music/Master/Beth%20Orton/Daybreaker/Beth%20Orton%20-%2010%20-%20Thinking%20About%20Tomorrow.flac</res>
   <r:streamContent></r:streamContent>
   <upnp:albumArtURI>/getaa?u=x-file-cifs%3a%2f%2fmediamarx%2fmusic%2fMaster%2fBeth%2520Orton%2fDaybreaker%2fBeth%2520Orton%2520-%252010%2520-%2520Thinking%2520About%2520Tomorrow.flac&amp;v=353</upnp:albumArtURI>
   <dc:title>Thinking About Tomorrow</dc:title>
   <upnp:class>object.item.audioItem.musicTrack</upnp:class>
   <dc:creator>Beth Orton</dc:creator>
   <upnp:album>Daybreaker</upnp:album>
   <upnp:originalTrackNumber>10</upnp:originalTrackNumber>
   <r:albumArtist>Beth Orton</r:albumArtist>
  </item>
</DIDL-Lite>


Okay, So How Would I Control the Sonos via UPnP Without Device Spy?

So far we know that the UPnP Sonos device has certain properties that can be viewed via HTTP, like http://192.168.2.225:1400/xml/AVTransport1.xml, so it looks like we are going to talk using HTTP. To that end, we will use the Fiddler Tool to construct simple HTTP requests to start with. Let’s see if we can duplicate the GetTransportInfo action that we performed with Device Spy above. To piece together what’s needed:

1) View the device_description.xml (right click on zone player and select Get Device XML)  for any zone player and look for AVTransport service.





You should find something like this in the devcie_description.xml:

<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
<eventSubURL>/MediaRenderer/AVTransport/Event</eventSubURL>
<SCPDURL>/xml/AVTransport1.xml</SCPDURL>


2) Refer back to the reference on UPnP given at the start of this article and see that we are going to be sending some kind of SOAP message to the device.

3) Run the Device Validator (Device Validator.exe in the download from http://opentools.homeip.net/dev-tools-for-upnp) run some Control tests against the PLAY:5 device to get a sense of what kind of actions we can take.





Here's an information about an example test. It's a POST to the device with a SOAP action.

POST /MediaRenderer/AVTransport/Control HTTP/1.1
HOST: 192.168.2.225:1400
SOAPACTION: "urn:schemas-upnp-org:service:AVTransport:1#GetTransportInfo"
CONTENT-TYPE: text/xml; charset="utf-8"
Content-Length: 353

The body of the request will look something like this:

<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <u:ACTION  xmlns:u="serviceType">
      <!-- input arguments here -->
      </u:ACTION>
   </s:Body>
</s:Envelope>


Let's take a step back for a second and review HTTP requests.  HTTP requests have three parts: a method like POST or GET, headers, and a body. The body in our work here will contain the SOAP envelope with an ACTION. ACTION in the example above is GetTransportInfo, and in general, is any other applicable action for the device. serviceType is the type from the zone_player.xml file, urn:schemas-upnp-org:service:AVTransport:1 in this example.. Putting it all together, we can construct an HTTP request. The two screen shots below are from Fiddler, but it will be similar in any tool you use

Left: Fiddler Compose Request: Right: Fiddler Response Inspector

A successful request will return the following SOAP message in the body of the response.


<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
  <u:GetTransportInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
    <CurrentTransportState>PLAYING</CurrentTransportState>       <CurrentTransportStatus>OK</CurrentTransportStatus>
    <CurrentSpeed>1</CurrentSpeed>
  </u:GetTransportInfoResponse>
</s:Body>
</s:Envelope>



Can I Search The Sonos Music Index?
Yes, you can browse the Sonos index using a SOAP request over HTTP to the zone player. You can construct a Fiddler HTTP request with the following components:

METHOD: POST http://192.168.2.6:1400/MediaServer/ContentDirectory/Control
HEADER: SOAPACTION: "urn:schemas-upnp-org:service:ContentDirectory:1#Browse"
BODY:

<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
    <u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
         <ObjectID>A:ARTIST</ObjectID>
          <BrowseFlag>BrowseDirectChildren</BrowseFlag>
          <Filter>*</Filter>
         <StartingIndex>0</StartingIndex>
         <RequestedCount>10</RequestedCount>
         <SortCriteria>*</SortCriteria>
     </u:Browse>
</s:Body>
</s:Envelope>


This gets all Artists (since we are using * for a filter) and shows the first 10 results. The ObjectID can be strings like A:ALBUM (to search for albums), A:TRACKS (to search for tracks), or A:PLAYLISTS (to search playlists). The range of ObjectID values can be found from one of the other actions in the Media Server/ContentDirectory service.

Next Steps?
Create a web page that issues HTTP / SOAP requests to your devices. We have something running now, but need to work out the kinks and distill out the basics before showing it. Stay tuned. Here’s a screenshot for now of the prototype:



Update: 01/16/2011. Be sure to check out the followup post on queries here: http://travelmarx.blogspot.com/2011/01/extracting-sonos-playlist-simple-sonos.html.
Update: 06/18/2014. Review instructions. Update with new version of DeviceSpy (downloadable from new location). Try steps in Fiddler on Windows 8.

Friday, June 18, 2010

Andre Petterson – Stitch.Print

Andre Petterson - Burst
A fun show of the latest work of the Canadian artist Andre Petterson is now at the Foster/White Gallery in Pioneer Square, Seattle. The name of Petterson’s show is STICH.PRINT and in the show you’ll see images of typewriters, adding machines, and sewing machines usually exploding and swirling with inky and squiggly lines, characters, tape, plates, birds, and wire. It’s like these venerable old machines want to say “see we are still here and you wait, you’ll be obsolete too someday” – like a last portrait of an unkempt movie starlet from days of yore. Be that the case, there is still something timeless and satisfying about these images. In the case of the sewing machines, Petterson was inspired partly by their continued use in developing countries. Obsolete is relative.

Thursday, June 17, 2010

Unemployed in Summertime – Programming

Emiliana Torrini - Love in the Time of Science - Album Cover 

I’ve always wanted to work in Unemployed in Summertime since I first heard this song by Emiliana Torrini (from her Love in the Time of Science release). Alas, it applies to my situation now – at least for a few more weeks. In the space before my next trick (the last trick a la Anja Garbaek?) I’m taking some time to see what’s the latest in programming languages – since I feel like a pre-dinosaur of sorts (see for example diictodon which plays a role in the interesting BBC series Walking with Monsters that we saw recently.) Anyways, I found the TIOBE index and the LangPop* index of languages which rate programming languages by popularity. What’s popular as a measure is problematic as pointed out elsewhere, however, it is not without interest as a measure of some kind of common currency of communication. I guess I felt a bit deflated since the top 5 languages of the TIOBE index and the top 4 of the LangPop index are not languages I use at all. So I grit my teeth and decided to take a look at Python (not the snake) because I overheard a teenager talking about how awesome Python is. Maybe it's not the best reason for picking up a new language, but I'm unemployed in summertime so I have the luxury of entertaining bad motivation - at least for a little while.

* This link "http://langpop.com/" used to work, but hey nothing lasts forever on the internet.

Monday, June 14, 2010

Nightblooming Cereus (E. oxypetalum)

Epiphyllum oxypetalum
A friend gave us an Epiphyllum oxypetalum plant a few months ago, one of the several species with the common name night-blooming cereus that are members of the Cactus family. I’m pretty sure ours is E. oxypetalum. The plant itself is honestly a bit gangly with an unruly growth habit, though not without its charm. E. oxypetalum’s special treat is its one-night, fragrant blooms. So far we haven’t experienced any blooms and it looks like we won’t this year. The friend shared some blooms from previous years so we know what we are in store for.

The first thing we did is cut it back (late spring) as it had one large stem that dwarfed the main plant. From what we’ve read, new blossoms come on new growth. From that cut stem we took several leaves and propagated them very easily. The method we used was to place the lower part of the dried-off leaf between two moist paper towels. In a week or so, roots develop. Then we cut the paper towel out (as the roots were stuck pretty well to it) and put it in soil. The photo with this post shows two potted plants, one sprouting new growth from the leaf and the other sending up new stems from the base. The photo also shows some other starts in paper towel.

Name origins: Epi = on or over and phyllum = leaf. The genus name refers to the fact that the blooms are on the margins of the leaves. Oxy = sharp, from the Greek “oyxs,” and petalum = petal. The species name refers to the bloom’s sharp-looking petals? (I don't remember them being much more than sharp-looking.) The common name cereus = Latin for “waxy” referring to the waxy nature of the plant leaves and stems.