commit 16a831e82398dfac2c4d7888b2b20f3b99f2ac2b
parent 1f39f8de922f6c8dbbea4249cd76096b7e9d40fb
Author: Brian Swetland <swetland@frotz.net>
Date:   Sun, 31 Jul 2011 08:21:59 -0700
discovery and other goodies
- added Discover class to do SSDP discovery of ZonePlayers
- added "discover" command to commandline
- use String instead of byte[] for hostnames
- added getZoneName(), save(), set(), and destroy() operations
- dump a position report if no arguments are given
- added some notes on the sonos environment
Signed-off-by: Brian Swetland <swetland@frotz.net>
Diffstat:
7 files changed, 259 insertions(+), 18 deletions(-)
diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF
@@ -1,3 +0,0 @@
-Manifest-Version: 1.0
-Main-Class: net.frotz.sonos.app
-Created-By: vi
diff --git a/Makefile b/Makefile
@@ -1,12 +1,14 @@
 
-SRCS := $(wildcard net/frotz/sonos/*.java)
+SRCS := $(shell find net -name \*.java)
 
 all: sonos.jar
 
 sonos.jar: $(SRCS)
-	javac $(SRCS)
+	rm -rf out
+	mkdir -p out
+	javac -d out $(SRCS)
 	rm -rf sonos.jar
-	zip -qr sonos.jar META-INF net -x \*.java
+	jar cfe sonos.jar net.frotz.sonos.app -C out/ .
 
 clean::
-	rm -rf net/frotz/sonos/*.class sonos.jar
+	rm -rf out sonos.jar
diff --git a/net/frotz/sonos/Discover.java b/net/frotz/sonos/Discover.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2011 Brian Swetland
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.frotz.sonos;
+
+import java.net.InetAddress;
+import java.net.MulticastSocket;
+import java.net.DatagramPacket;
+import java.io.IOException;
+
+import java.util.regex.Pattern;
+import java.util.regex.Matcher;
+import java.util.HashMap;
+import java.util.Set;
+
+public class Discover extends Thread {
+	static final int SSDP_PORT = 1900;
+	static final String SSDP_ADDR = "239.255.255.250";
+
+	static String query =
+		"M-SEARCH * HTTP/1.1\r\n"+
+		"HOST: 239.255.255.250:1900\r\n"+
+		"MAN: \"ssdp:discover\"\r\n"+
+		"MX: 1\r\n"+
+		"ST: urn:schemas-upnp-org:service:AVTransport:1\r\n"+
+		//"ST: ssdp:all\r\n"+
+		"\r\n";
+
+	InetAddress addr;
+	MulticastSocket s;
+	Pattern pLocation;
+	volatile boolean active;
+	Discover.Listener callback;
+	Object lock;
+	HashMap<String,String> list;
+
+	void send_query() throws IOException {
+		DatagramPacket p;
+		p = new DatagramPacket(
+			query.getBytes(),query.length(),
+			addr,SSDP_PORT);
+		s.send(p);
+		s.send(p);
+		s.send(p);
+	}
+	void handle_notify(DatagramPacket p) throws IOException {
+		s.receive(p);
+		String s = new String(p.getData(), 0, p.getLength());
+		Matcher m = pLocation.matcher(s);
+		if (m.find(0)) {
+			boolean notify = false;
+			String a = m.group(1);
+			synchronized (lock) {
+				if (!list.containsKey(a)) {
+					list.put(a,a);
+					notify = true;
+				}
+			}
+			if (notify && (callback != null))
+				callback.found(a);
+		}
+	}
+	public String[] getList() {
+		synchronized (lock) {
+			Set<String> set = list.keySet();
+			String[] out = new String[set.size()];
+			int n = 0;
+			for (String s : set)
+				out[n++] = s;
+			return out;
+		}
+	}
+	public void run() {
+		DatagramPacket p =
+			new DatagramPacket(new byte[1540], 1540);
+		list = new HashMap<String,String>();
+		lock = new Object();
+		try {
+			addr = InetAddress.getByName(SSDP_ADDR);
+			s = new MulticastSocket(SSDP_PORT);
+			s.joinGroup(addr);
+			send_query();
+		} catch (IOException x) {
+			System.err.println("cannot create socket");
+		}
+		while (active) {
+			try {
+				handle_notify(p);
+			} catch (IOException x) {
+				/* done causes an exception when it closes the socket */
+				if (active)
+					System.err.println("io error");
+			}
+		}
+	}
+	public void done() {
+		active = false;
+		s.close();	
+	}
+	public Discover() {
+		init(null);
+	}	
+	public Discover(Discover.Listener cb) {
+		init(cb);
+	}
+	void init(Discover.Listener cb) {
+		active = true;
+		this.callback = cb;
+		pLocation = Pattern.compile("^LOCATION:\\s*http://(.*):1400/xml/zone_player.xml$",Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
+		start();
+	}
+	public static interface Listener {
+		public void found(String host);
+	}
+}
diff --git a/net/frotz/sonos/SoapRPC.java b/net/frotz/sonos/SoapRPC.java
@@ -55,13 +55,13 @@ class SoapRPC {
 	/* hold remote information while assembling message */
 	String xmethod, xservice, xpath;
 
-	public SoapRPC(byte[] host, int port) {
+	public SoapRPC(String host, int port) {
 		init(host,port);
 	}
 
-	void init(byte[] host, int port) {
+	void init(String host, int port) {
 		try {
-			addr = InetAddress.getByAddress(host);
+			addr = InetAddress.getByName(host);
 		} catch (Exception x) {
 		}
 		this.port = port;
diff --git a/net/frotz/sonos/Sonos.java b/net/frotz/sonos/Sonos.java
@@ -22,20 +22,21 @@ public class Sonos {
 	SoapRPC.Endpoint xport;
 	SoapRPC.Endpoint media;
 	SoapRPC.Endpoint render;
+	SoapRPC.Endpoint props;
 	SoapRPC rpc;
 	XMLSequence name, value;
 	SonosItem item;
 
-	public Sonos(byte[] ip) {
-		init(ip);
+	public Sonos(String host) {
+		init(host);
 	}
 
-	void init(byte[] ip) {
+	void init(String host) {
 		name = new XMLSequence();
 		value = new XMLSequence();
 		item = new SonosItem();
 
-		rpc = new SoapRPC(ip, 1400);
+		rpc = new SoapRPC(host, 1400);
 
 		xport = new SoapRPC.Endpoint(
 			"AVTransport:1",
@@ -46,12 +47,27 @@ public class Sonos {
 		render = new SoapRPC.Endpoint(
 			"RenderingControl:1",
 			"/MediaRenderer/RenderingControl/Control");
+		props = new SoapRPC.Endpoint(
+			"DeviceProperties:1",
+			"/DeviceProperties/Control");
 	}
 
 	public void trace_io(boolean x) { rpc.trace_io = x; }
 	public void trace_reply(boolean x) { rpc.trace_reply = x; }
 	public void trace_browse(boolean x) { trace_browse = x; }
 
+	public String getZoneName() {
+		rpc.prepare(props,"GetZoneAttributes");
+		XML xml = rpc.invoke();
+		try {
+			xml.open("u:GetZoneAttributesResponse");
+			return xml.read("CurrentZoneName").toString();
+			//xml.read("CurrentIcon").toString();
+		} catch (XML.Oops x) {
+			return null;
+		}
+	}
+
 	/* volume controls */
 	public void volume() {
 		rpc.prepare(render,"GetVolume");
@@ -69,7 +85,20 @@ public class Sonos {
 		rpc.invoke();
 	}
 
+/* GetMediaInfo:
+ * NrTracks and CurrentURI indicate active queue and size 
+ */
+/* CurrentTransportState STOPPED | PAUSED_PLAYBACK | PLAYING | */
 	/* transport controls */
+	public void getPosition() {
+		rpc.prepare(xport,"GetMediaInfo");
+		//rpc.prepare(xport,"GetPositionInfo");
+		//rpc.prepare(xport,"GetTransportInfo");
+		rpc.simpleTag("InstanceID",0);
+		XML xml = rpc.invoke();
+		xml.print(System.out,1024);
+		xml.rewind();
+	}
 	public void play() {
 		rpc.prepare(xport,"Play");
 		rpc.simpleTag("InstanceID",0);
@@ -108,6 +137,23 @@ public class Sonos {
 	}
 
 	/* queue management */
+	public void save(String name, String uri) {
+		rpc.prepare(xport,"SaveQueue");
+		rpc.simpleTag("InstanceID",0);
+		rpc.simpleTag("Title",name); /* not unique */
+		rpc.simpleTag("ObjectID",uri); /* "" for new */
+		XML xml = rpc.invoke();
+		/* saved queues are named SQ:# */
+		xml.print(System.out,1024);
+		xml.rewind();
+	}
+	public void set(String uri) {
+		rpc.prepare(xport,"SetAVTransportURI");
+		rpc.simpleTag("InstanceID",0);
+		rpc.simpleTag("CurrentURI",uri);
+		rpc.simpleTag("CurrentURIMetaData","");
+		rpc.invoke();
+	}
 	public void add(String uri) {
 		rpc.prepare(xport,"AddURIToQueue");
 		rpc.simpleTag("InstanceID",0);
@@ -139,6 +185,13 @@ public class Sonos {
 		rpc.invoke();
 	}
 
+	/* can be used to delete saved queues (SQ:*) */
+	public void destroy(String id) {
+		rpc.prepare(media,"DestroyObject");
+		rpc.simpleTag("ObjectID", id);
+		rpc.invoke();
+	}
+
 	/* content service calls */
 	public void browse(String _id, SonosListener cb) {
 		int total, count, updateid;
@@ -149,7 +202,7 @@ public class Sonos {
 			rpc.prepare(media,"Browse");
 			rpc.simpleTag("ObjectID",_id);
 			rpc.simpleTag("BrowseFlag","BrowseDirectChildren"); // BrowseMetadata
-			rpc.simpleTag("Filter","");
+			rpc.simpleTag("Filter","*");
 			rpc.simpleTag("StartingIndex", n);
 			rpc.simpleTag("RequestedCount",100);
 			rpc.simpleTag("SortCriteria","");
diff --git a/net/frotz/sonos/app.java b/net/frotz/sonos/app.java
@@ -19,17 +19,38 @@ package net.frotz.sonos;
 public class app implements SonosListener {
 	public static void main(String args[]) {
 		app a = new app();
-		Sonos sonos = new Sonos(new byte[] { 10, 0, 0, (byte) 199});
-		String cmd = args[0];
+		Sonos sonos = new Sonos("10.0.0.199");
 
 		//sonos.trace_io(true);
 		//sonos.trace_reply(true);
 		//sonos.trace_browse(true);
 
-		if (cmd.equals("play")) {
+		if (args.length == 0) {
+			sonos.getPosition();
+			return;
+		}
+
+		String cmd = args[0];
+		if (cmd.equals("discover")) {
+			Discover d = new Discover();
+			try {
+				Thread.sleep(1000);
+			} catch (InterruptedException x) {
+			}
+			d.done();
+			String[] list = d.getList();
+			for (int n = 0; n < list.length; n++) {
+				Sonos s = new Sonos(list[n]);
+				String name = s.getZoneName();
+				if (name != null)
+					System.out.println(list[n] + " - " + name);
+			}
+		} else if (cmd.equals("play")) {
 			sonos.play();
 			sonos.play();
 			sonos.play();
+		} else if (cmd.equals("save")) {
+			sonos.save(args[1],"SQ:3");
 		} else if (cmd.equals("pause")) {
 			sonos.pause();
 		} else if (cmd.equals("next")) {
@@ -40,6 +61,8 @@ public class app implements SonosListener {
 			sonos.browse(args[1],a);
 		} else if (cmd.equals("add")) {
 			sonos.add(args[1]);
+		} else if (cmd.equals("set")) {
+			sonos.set(args[1]);
 		} else if (cmd.equals("remove")) {
 			sonos.remove(args[1]);
 		} else if (cmd.equals("removeall")) {
diff --git a/notes.txt b/notes.txt
@@ -0,0 +1,38 @@
+
+There are two kinds of identifiers in the world of Sonos:
+1. objectID, which is what the Browse call deals in
+2. resourceID, which is what represents a song or playlist
+
+A zoneplayer's state is a resource URI (CurrentURI) which represents 
+the song or playlist it's playing, the number of tracks in that 
+resource (1 for a song, N for a playlist), and the current track, 
+along with the play state (STOPPED, PLAYING, etc) and the play mode 
+(NORMAL, SHUFFLE, etc).
+
+The AVTransport actions Play, Pause, Stop, Next, Previous, Seek act on
+this player state.  SetAVTransportURI can be used to change the resource
+URI that the player is playing from.  This will cause the player to enter
+the STOPPED state.
+
+The Browse call returns both ObjectID and ResourceID for items, allowing
+them to be both browsed and played.  The AVTransport can be queried for
+its CurrentURI (via GetMediaInfo) but the ObjectID is not returned.  If
+you don't know the ResourceID:ObjectID mapping there is no easy way to
+find it.
+
+objectIDs exist in hierarchies:
+S: is the root of all filesystems the sonos can play from
+A: is the root of all metadata hierarchies (A:ARTIST, A:ALBUM, ...)
+Q: is the root of "The Queue" which is always Q:0
+SQ: is the root of saved queues (user created playlists)
+
+Q:0 can be manipulated by the actions AddURIToQueue, RemoveTrackFromQueue,
+RemoveAllTracksFromQueue, ReorderTracksInQueue.  Its contents may be
+saved to a SQ:# saved queue (created new or overwritten existing) using
+the SaveQueue action.  The SetAVTransportURI call which switches what's
+being played never modifies the contents of Q:0 -- you can switch to
+an album or any other playlist, and switch back to the main queue later
+without losing its state.
+
+
+