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.

Wednesday, June 9, 2010

Facebook Simple Integration



In a previous post we looked at the Graph API as resource for reading and writing Facebook data. In this post we look at a simple Facebook integration. A Facebook integration is about tapping into, extending, and enriching (with new experiences) a user’s social graph. If a Facebook user can sign on to a non-Facebook site using Facebook credentials, and the user authorizes the site to access some of his FB data, then any content on the site can be recommended or shared with the user’s friends – to illustrate a simple scenario. The user’s social graph in this scenario has been extended. Before you start an integration you should consider the following:

  • What’s your end result? Is your end result going to be web page that is external to Facebook like TripAdvisor (with FB login button e.g.) or is it going to be an application that runs within Facebook (i.e. has the Facebook chrome around it) like the TripAdvisor Travel Map (see the application directory for more applications)? Or will it be both external and internal to FB. In this post, I’m tackling the first scenario (external), though the code can be used for the second scenario as well.


  • How are you going to render your content? Are going to use IFrames or Facebook Markup Language (FBML) ? This topic discusses the choices. I started with FBML because without any prior experience with Facebook development the documentation (by my reading) seemed to suggest to go that way. As I got into it I found that not all the FBML features are, shall we say, well-baked and I ran into lots of little problems that took time to figure out. I also used the new JavaScript API as opposed to the old one, because the docs recommended it.
Before showing the code there are a couple of things to point out.

1) If you are using FBML like fb:login-button or fb:comments don’t forget to include a namespace declaration so that the prefix “fb” is understood. They rarely mention this in the docs.

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://www.facebook.com/2008/fbml">


2) Don’t forget to include a script reference to the FB SDK. (In this code sample below I did not load the SDK asynchronously as recommended, but that ‘s easy enough to do following the code sample here.)

<script src="https://connect.facebook.net/en_US/all.js" type="text/javascript"></script>



3) In this code sample, I use jQuery as well, so I load that too. It doesn’t matter where you load it from; the sample here loads it from the Microsoft CDN.

<script src="https://ajax.microsoft.com/ajax/jquery/jquery-1.4.2.min.js" type="text/javascript"></script>


4) The FBML tag for like functionality fb:like did not work. had to resort to using an iframe. Last I saw there was a bug open for it.

5) At first I used server-side handling of the FB cookie, that is checking for it and pulling out the necessary information, like tokens. I used the C# approach here. Then I would pass the cookie info from the server-side to the client-side for use in JavaScript. Then, I discovered by chance that you could get the cookie easily using FB.cookie.load() method by playing around with the FB Test Console page. I would have expected this to be called out in the JavaScript SDK page but it wasn’t. In the end, the code sample below examines the cookie but doesn’t really use its information. I guess to the credit of the JavaScript SDK they’ve designed it so you don’t have to work with the cookie for common scenarios.

6) The FB login button doesn’t have a counterpart, log out. I was expecting that. So you have to provide your own log out feature and manage the displaying and showing of it and the FB login button. When the user is logged in, don’t show the FB login but show logout.

7) The code sample below worked in Firefox 3.6.3 and Chrome 5.0.375. But alas, IE 8.0 presented several problems where the consent/authorization dialog would not go away and the page ½ functioned. I saw mentions of this behavior in the forums, but did not pursue. Hopefully it gets worked out.

8) The div with id=”fb-root” is required, but nowhere is there a hint as to why it’s needed. Looking at the all.js file it looks like it might be used for cross domain communication when dealing with older browsers. Debugging and looking at what was inside the fb-root div during various times in the page lifecyle, I never saw anything in the div but that’s probably because I’m using a “modern” browser. In fact, I looked at the FB.XD._transport variabel and confirmed I was using “postMessage” capabilities of HTML 5 – which is the latest and greatest.

Here’s the code sample, a simple HTML page. You must run this file in the context of a web site. For example, register an application and get an application ID at Facebook, host the page on a web site locally on your test server (the same domain you registered), and place this page in the web site and browse to it. I used HTTPS for all my tests.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://www.facebook.com/2008/fbml">
<head runat="server">
<title>Facebook Integration - Test</title>
<script src="https://ajax.microsoft.com/ajax/jquery/jquery-1.4.2.min.js" type="text/javascript"></script>
<script src="https://connect.facebook.net/en_US/all.js" type="text/javascript"></script>
<script type="text/javascript">
var fbCookie = {
accessToken: null,
signature: null,
userId: null,
secret: null
};

$(document).ready(function () {
FB.init({
appId: '107702769253652', // Fill in your correct AppID
status: true, // check login status
cookie: true, // enable cookies to allow the server to access the session
xfbml: true
});
});

FB.Event.subscribe('auth.sessionChange', function (response) {
if (response.session) {
// A user has logged in, get cookie information.
fbCookie.accessToken = FB.Cookie.load().access_token;
fbCookie.signature = FB.Cookie.load().sig;
fbCookie.userId = FB.Cookie.load().uid;
fbCookie.secret = FB.Cookie.load().secret;
$('#Login').css('display', 'none');
$('#Logout').css('display', 'inline');
} else {
// No session. Take action if needed.
}
});


function DoLogout() {
FB.logout(function (response) {
// User is now logged out
$('#Login').css('display', 'inline');
$('#Logout').css('display', 'none');
});
}

function GetDataAboutMe() {
FB.api('/me', function (response) {
$('#UserData').html("Name: " + response.name + ", gender = " + response.gender +
", birdthday = " + response.birthday + ", email = " + response.email);
});
}

function GetDataAboutFriends() {
FB.api('/me/friends', function (response) {
var sb = "";
for (var i = 0; i < response.data.length; i++) {
sb += "Friend" + i + ": " + response.data[i].name + " (" + response.data[i].id + ")<br/>";
}
$('#FriendData').html(sb);
});
}

function PostToNewsFeed() {
var body = $('#TextBox3').val();
FB.api('/me/feed/', 'post',
{
message: body,
name: "Test Post",
caption: "Test Post Caption",
description: "Test Description",
link: "http://travelmarx.blogspot.com"
}
, function (response) {
if (!response response.error) {
$('#PostResults').html("Error posting to news feed. Error: " + response.error.message);
}
else {
$('#PostResults').html("Posted to news feed successfully.");
}
});
}
</script>
<style type="text/css">
#Logout {display: none; }
</style>
</head>
<body>
<form id="form1" runat="server">
<div>
<div id="fb-root">
</div>
<h3>
Login Functionality</h3>
<fb:login-button perms="email,user_birthday,read_stream,publish_stream,read_friendlists"
id="Login">
</fb:login-button>
<input type="button" value="logout" id="Logout" onclick="DoLogout()" />
<hr />
<h3>
Like Functionality (using iframe because fb:like wasn't working)</h3>
<img src="Folder.jpg" width="100px" />
<iframe src="http://www.facebook.com/plugins/like.php?href=http%3A%2F%2Ftravelmarx.blogspot.com&amp;layout=standard&amp;show_faces=true&amp;width=450&amp;action=like&amp;colorscheme=light&amp;height=80
scrolling="no" frameborder="0" style="border: none; overflow: hidden; width: 450px;
height: 80px;" allowtransparency="true"></iframe>
<hr />
<h3>
Comments</h3>
Comments about this integration site? <br />
<fb:comments>
</fb:comments>
<hr />
<h3>
Getting Graph API Data</h3>
<input type="button" id="Button1" value="Get Data About Me" onclick="GetDataAboutMe()" />
<span id="UserData"></span>
<br />
<input type="button" id="Button2" value="Get Data About Friends" onclick="GetDataAboutFriends()" />
<span id="FriendData"></span>
<br />
<fb:prompt-permission perms="read_stream,publish_stream">Would you like our application to read from and post to your News Feed?</fb:prompt-permission>
<br />
<input type="button" id="Button3" value="Post to News Feed" onclick="PostToNewsFeed()" />
<input type="text" id="TextBox3" />
<span id="PostResults"></span>
<br />
<hr />
<h3>
Activity</h3>
<fb:activity recommendations="true">
</fb:activity>
</div>
</form>
</body>
</html>

Sunday, June 6, 2010

Facebook Graph API – A First Look

Trying the Facebook Graph API in a Browser
The Facebook Graph API is for developers who want to integrate with Facebook and read and write data from Facebook. (The docs are here – sometimes it makes me sign in to see the doc!?) What you need to know to start is that “graph” really means a “social graph”. For example, a user’s social graph includes all the things important to a user like people, photos, friends, and shared content. Data in a social graph falls into two categories: objects (like people, photos, and events) and connections between objects (like relationships, shared content, and tags). When you work with the Graph API you’ll be working with objects and connections.

The “normal” way to use the Graph API is to get an application ID (here) that represents your company or application. Then, on a page of your web site you set up the infrastructure using one of the APIs (e.g. the JavaScript API) so that when a user comes to your page, hits the Facebook login button, and authorizes your web site to access data, the Graph API would be used behind the scenes. In a future post I'll cover that.

You can start getting familiar with the Graph API without an application ID by just constructing Graph API requests directly in a URL. Note, that Chrome and Firefox browsers display the JSON results of these requests directly in the browser which is nice. If you put the URL above in the address bar of Internet Explorer it will ask you want to do. You can download it somewhere, name it .txt and view it. Just more work.

If you put this URL (https://graph.facebook.com/68310606562?metadata=1) for the founder of Facebook, Mark Zuckerberg, into the address bar of Chrome you get the publicly available data for his page (an object). The “?metadata=1” part of the URL requests that connection information for the object be shown. The results of the Graph request above will give additional Graph requests you can follow and drill down into the data. So for example, take Mark Zuckerberg’s photos link (https://graph.facebook.com/68310606562/photos) and put that in the browser address line and so on.

This is what I started doing at first for myself and friends - Graph requests in a browser - to see how it worked. Then, I wanted to automatically check all the links without doing so much cutting and pasting and that’s where the code shown below comes in play. I created a first version and then found this page (http://zesty.ca/facebook/) and created a second page with some of the good ideas I found there. I wanted to play around with jQuery and issuing Ajax requests from the page so that drove the design of my page. My page checks all the connection links and displays the results (what was found) in the page. The previously mentioned link (zesty.ca) is a simpler and slicker page in that it’s just based on navigating to URLs.
Using the Graph API to Look at a Page
Drill Down on Feed
Using the Graph API to Drill Down to Feed
Graph API Looking at a User's Info

A Javascript Program To Work With Facebook API
The code below uses the ajax method of jQuery to get data from the Facebook Graph API. No data is written back. A big gotcha is that I could only run this code successfully in Internet Explorer. In Chrome or Firefox, it was a no go. I didn’t troubleshoot enough to find out why, though I saw others had problems with the ajax method in these browsers. I’ll revisit the issue.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Using the Facebook Graph API</title>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js" type="text/javascript"></script>
<script type="text/javascript">
var baseUrl = "http://graph.facebook.com/";
var pageUrl = window.location.protocol + "//" + window.location.hostname + window.location.pathname;
var notDef = "Not Defined";
$(document).ready(function () {
$('#Go').click(function () {
window.location.href = pageUrl + "?check=" + $('#Input').val();
});
if (window.location.search.indexOf("check") > -1) {
var params = window.location.search.split("=");
$('#Input').val(params[1])
checkGraphAPI(params[1]);
}
});
function checkGraphAPI(itemToCheck) {
$.ajax({
url: baseUrl + itemToCheck + '?metadata=1',
async: false,
data: null,
dataType: 'json',
success: function (data) {
$('#Output').html("");
bShowOnlyVisible = $('#ShowFlag').attr('checked');
processObjectResults(data, 0, "object");
processConnectionResults(data);
},
error: function (xhr, ajaxOptions, thrownError) {
$('#Output').html("Can't load anything. Could be invalid Graph API request.");
}
});

}
function processObjectResults(data, level, idFrag) {
if (data !== null) {
var objKeys = [];
for (var objKey in data) {
objKeys.push(objKey);
}
// Get object properties.
for (var i = 0; i < objKeys.length; i++) {
var property = data[objKeys[i]];
if (property === undefined || property === null) {
createOutputElement(objKeys[i], notDef, "Good", "child", level, idFrag + objKeys[i]);
}
else {
if (property.constructor === Object || property.constructor === Array) {
createOutputElement(objKeys[i], "", "", "openparent", level, idFrag + objKeys[i]);
processObjectResults(property, 1, objKeys[i]);
createOutputElement("", "", "", "closeparent", level, idFrag + objKeys[i]);
}
else {
createOutputElement(objKeys[i], property, "Norm", "child", level, idFrag + objKeys[i]);
}
}
}
}
else {
$('#Output').append("Couldn't look up the object. Could be permissions related.");
}
}
function processConnectionResults(data) {
if (data !== null) {
// Get connection properties.
var connections = data.metadata.connections;
var connKeys = [];
for (var connKey in connections) {
connKeys.push(connKey);
}
for (var j = 0; j < connKeys.length; j++) {
var conn = connections[connKeys[j]];
doConnectionCheck(conn, "connections" + connKeys[j]);
}
}
else {
$('#Output').append("Couldn't look up the connections. Could be permissions related.");
}
}
function doConnectionCheck(url, elem) {
$.ajax({
url: url,
async: true,
context: $('#' + elem),
dataType: "json",
success: function (response) {
if (response !== null) {
if (response.data) {
if ((response.data).length === 0) {
$(this).append(" - No Data Found");
}
else {
$(this).append(" - Visible");
}
}
else {
$(this).append(" - Unknown");
}
}
},
error: function (xhr, ajaxOptions, thrownError) {
var errMsg = "Error Viewing." + " Found: " + xhr.status + ", " + xhr.statusText;
$(this).append(" - " + errMsg);
}
});
}
function createOutputElement(prop, text, style, type, level, id) {
var div1 = document.createElement("<div>");
var div2 = document.createElement("<div>");
div1.style.width = 120 + parseInt(level) * 30;
div2.id = id; // where the results go
div1.className = "Prop";
div2.className = style;
switch (type) {
case "child":
div1.innerHTML = prop + " = ";
var link = text;
if (text.toString().indexOf(baseUrl) > -1) {
link = text.split(baseUrl)[1];
}
if (text !== notDef) {
if (prop === "picture" || prop === "icon") {
var img = document.createElement("<img/>");
img.src = text;
div2.innerHTML = img.outerHTML;
}
else {
div2.innerHTML = createHyperlink(link) + " ";
}
}
else {
div2.innerHTML = text + " ";
}
break;
case "openparent":
div1.innerHTML = prop + " { ";
break;
case "closeparent":
div1.innerHTML = " } ";
break;
}
$('#Output').append(div1.outerHTML + div2.outerHTML);
}
function createHyperlink(link) {
var a = document.createElement("<a>");
a.href = pageUrl + "?check=" + link;
//a.target = "_blank";
a.innerHTML = link;
return a.outerHTML;
}
</script>
<style type="text/css">
body { font-family: Verdana;}
div { float: left;}
div.Prop { width:120px; text-align: right; clear: left;}
div.Bad {color: Red; font-weight: bold; }
div.Good {color: Green; font-weight: normal; }
div.Norm {color: Black; }
</style>
</head>
<body>
<div>
<h3>
Using the Facebook Graph API </h3>
Put in the ID or Friendly Name of a Facebook user in the text box below and then
hit go. For example you could enter "markzuckerberg" or his id "68310606562". To
find an ID of Friendly Name right click and view properties of the person/group/product
link in Facebook. This test does not require a Facebook account or to be logged into Facebook.
<br />
<input type="text" id="Input" value="" />
<input type="button" id="Go" value="Go" />
<br />
<span id="Output" ></span>
</div>
</body>
</html>