sonos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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:
DMETA-INF/MANIFEST.MF | 3---
MMakefile | 10++++++----
Anet/frotz/sonos/Discover.java | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnet/frotz/sonos/SoapRPC.java | 6+++---
Mnet/frotz/sonos/Sonos.java | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mnet/frotz/sonos/app.java | 29++++++++++++++++++++++++++---
Anotes.txt | 38++++++++++++++++++++++++++++++++++++++
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. + + +