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.
+
+
+