Skip to content

Commit

Permalink
System now owns groups that correctly represent the topology
Browse files Browse the repository at this point in the history
  • Loading branch information
soffes committed Dec 27, 2012
1 parent 2c311cb commit 4f29e13
Show file tree
Hide file tree
Showing 12 changed files with 189 additions and 68 deletions.
4 changes: 3 additions & 1 deletion Changelog.markdown
@@ -1,6 +1,8 @@
### Version 0.2.2 — Unreleased
### Version 0.3.0 — Unreleased

* System owns groups that reflect the topology
* Group and ungroup speakers
* Rename `playlist_position` to `queue_position` for consistency

### Version 0.2.1 — December 24, 2012

Expand Down
9 changes: 6 additions & 3 deletions Readme.markdown
Expand Up @@ -36,7 +36,7 @@ $ irb
``` ruby
require 'rubygems'
require 'sonos'
speaker = Sonos.discover
speaker = Sonos.system.speakers.first
```

Now that we have a reference to the speaker, we can do all kinds of stuff.
Expand All @@ -56,7 +56,9 @@ speaker.clear_queue

### Topology

`Sonos.discover` finds the first speaker it can. We can get all of the Sonos devices (including Bridges, etc) by calling `speaker.topology`. This is going to get refactored a bit. Right now everything is nested under speaker which is kinda messy and confusing.
`Sonos.discover` finds the first speaker it can. We can get all of the Sonos devices (including Bridges, etc) by calling `Sonos.system.devices`. To get the groups, call `Sonos.system.groups`.

All of this is based off of the raw `Sonos.system.topology`.

### CLI

Expand All @@ -71,7 +73,8 @@ There is a very limited CLI right now. You can run `sonos discover` to get the I
* List other speakers
* Handle errors better
* Handle line-in in `now_playing`
* Better support for stero pairs
* Detect fixed volume
* Detect stereo pair
* CLI client for everything

### Features
Expand Down
27 changes: 13 additions & 14 deletions lib/sonos.rb
@@ -1,22 +1,21 @@
require 'sonos/version'
require 'sonos/system'
require 'sonos/discovery'
require 'sonos/device'
require 'sonos/group'

module Sonos
PORT = 1400
NAMESPACE = 'http://www.sonos.com/Services/1.1'

def self.Speaker(ip)
Speaker.new(ip)
end

def self.discover
Sonos::Discovery.new.discover
# Create a new speaker with it's IP address
# @param [String] the speaker's IP address
def self.speaker(ip)
Device::Speaker.new(ip)
end

module Device
# Get the Sonos system
def self.system
@system ||= Sonos::System.new
end
end

require 'sonos/version'
require 'sonos/discovery'

require 'sonos/device/base'
require 'sonos/device/speaker'
require 'sonos/device/bridge'
8 changes: 8 additions & 0 deletions lib/sonos/device.rb
@@ -0,0 +1,8 @@
module Sonos
module Device
end
end

require 'sonos/device/base'
require 'sonos/device/speaker'
require 'sonos/device/bridge'
28 changes: 17 additions & 11 deletions lib/sonos/device/base.rb
Expand Up @@ -5,6 +5,19 @@ module Sonos::Device
class Base
attr_reader :ip, :name, :uid, :serial_number, :software_version, :hardware_version, :mac_address, :group

def self.detect(ip)
data = retrieve_information(ip)
model_number = data[:model_number]

if Bridge.model_numbers.include?(model_number)
Bridge.new(ip, data)
elsif Speaker.model_numbers.include?(model_number)
Speaker.new(ip, data)
else
raise ArgumentError.new("#{self.data[:model_number]} not supported")
end
end

def initialize(ip, data = nil)
@ip = ip

Expand Down Expand Up @@ -37,17 +50,10 @@ def data
}
end

def self.detect(ip)
data = retrieve_information(ip)
model_number = data[:model_number]

if Bridge.model_numbers.include?(model_number)
Bridge.new(ip, data)
elsif Speaker.model_numbers.include?(model_number)
Speaker.new(ip, data)
else
raise ArgumentError.new("#{self.data[:model_number]} not supported")
end
# Can this device play music?
# @return [Boolean] a boolean indicating if it can play music
def speaker?
false
end

protected
Expand Down
4 changes: 4 additions & 0 deletions lib/sonos/device/speaker.rb
Expand Up @@ -17,5 +17,9 @@ class Speaker < Base
def self.model_numbers
MODEL_NUMBERS
end

def speaker?
true
end
end
end
45 changes: 15 additions & 30 deletions lib/sonos/discovery.rb
@@ -1,6 +1,7 @@
require 'socket'
require 'ipaddr'
require 'timeout'
require 'sonos/topology_node'

#
# Inspired by https://github.com/rahims/SoCo, https://github.com/turboladen/upnp,
Expand All @@ -18,29 +19,33 @@ class Discovery
DEFAULT_TIMEOUT = 1

attr_reader :timeout
attr_reader :first_device_ip

def initialize(timeout = nil)
@timeout = timeout || DEFAULT_TIMEOUT

def initialize(timeout = DEFAULT_TIMEOUT)
@timeout = timeout
initialize_socket
end

# Look for Sonos devices on the network and return the first IP address found
# @return [String] the IP address of the first Sonos device found
def discover
send_discovery_message
first_device_ip = listen_for_responses
discover_topology(first_device_ip)
@first_device_ip = listen_for_responses
end

private

def discover_topology(ip_address)
doc = Nokogiri::XML(open("http://#{ip_address}:#{Sonos::PORT}/status/topology"))
# Find all of the Sonos devices on the network
# @return [Array] an array of TopologyNode objects
def topology
self.discover unless @first_device_ip

doc = Nokogiri::XML(open("http://#{@first_device_ip}:#{Sonos::PORT}/status/topology"))
doc.xpath('//ZonePlayers/ZonePlayer').map do |node|
TopologyNode.new(node).device
TopologyNode.new(node)
end
end

private

def send_discovery_message
# Request announcements
@socket.send(search_message, 0, MULTICAST_ADDR, MULTICAST_PORT)
Expand Down Expand Up @@ -77,25 +82,5 @@ def search_message
"ST: urn:schemas-upnp-org:device:ZonePlayer:1"
].join("\n")
end

class TopologyNode
attr_accessor :name, :group, :coordinator, :location, :version, :uuid

def initialize(node)
node.attributes.each do |k, v|
self.send("#{k}=", v) if self.respond_to?(k.to_sym)
end

self.name = node.inner_text
end

def ip
@ip ||= URI.parse(location).host
end

def device
@device || Sonos::Device::Base.detect(ip)
end
end
end
end
11 changes: 7 additions & 4 deletions lib/sonos/endpoint/a_v_transport.rb
Expand Up @@ -20,7 +20,7 @@ def now_playing
title: doc.xpath('//dc:title').inner_text,
artist: doc.xpath('//dc:creator').inner_text,
album: doc.xpath('//upnp:album').inner_text,
playlist_position: body[:track],
queue_position: body[:track],
track_duration: body[:track_duration],
current_position: body[:rel_time],
uri: body[:track_uri],
Expand Down Expand Up @@ -71,17 +71,20 @@ def save_queue(title)
transport_client.call(name, soap_action: action, message: message)
end

# Join another speaker's group
# Join another speaker's group.
# Trying to call this on a stereo pair slave will fail.
def join(master)
set_av_transport_uri('x-rincon:' + master.uid.sub('uuid:', ''))
end

# Add another speaker to this group
# Add another speaker to this group.
# Trying to call this on a stereo pair slave will fail.
def group(slave)
slave.join(self)
end

# Ungroup from its current group
# Ungroup from its current group.
# Trying to call this on a stereo pair slave will fail.
def ungroup
send_transport_message('BecomeCoordinatorOfStandaloneGroup')
end
Expand Down
2 changes: 2 additions & 0 deletions lib/sonos/endpoint/rendering.rb
Expand Up @@ -3,13 +3,15 @@ module Sonos::Endpoint::Rendering
RENDERING_XMLNS = 'urn:schemas-upnp-org:service:RenderingControl:1'

# Get the current volume.
# Fixed volume speakers will always return 100.
# @return [Fixnum] the volume from 0 to 100
def volume
response = send_rendering_message('GetVolume')
response.body[:get_volume_response][:current_volume].to_i
end

# Set the volume from 0 to 100.
# Trying to set the volume of a fixed volume speaker will fail.
# @param [Fixnum] the desired volume from 0 to 100
def volume=(value)
send_rendering_message('SetVolume', value)
Expand Down
45 changes: 44 additions & 1 deletion lib/sonos/group.rb
@@ -1,5 +1,48 @@
module Sonos
# Represents a Sonos group. A group can contain one or more speakers. All speakers in a group
# play the same music in sync.
class Group
attr_reader :devices
# The master speaker in the group
attr_reader :master_speaker

# All other speakers in the group
attr_reader :slave_speakers

def initialize(master_speaker, slave_speakers)
@master_speaker = master_speaker
@slave_speakers = (slave_speakers or [])
end

# All of the speakers in the group
def speakers
[self.master_speaker] + self.slave_speakers
end

# Remove all speakers from the group
def disband
self.slave_speakers.each do |speaker|
speaker.ungroup
end
end

# Full group name
def name
self.speakers.collect(&:name).uniq.join(', ')
end

# Forward AVTransport methods to the master speaker
%w{now_playing pause stop next previous queue clear_queue}.each do |method|
define_method(method) do
self.master_speaker.send(method.to_sym)
end
end

def play(uri = nil)
self.master_speaker.play(uri)
end

def save_queue(name)
self.master_speaker.save_queue(name)
end
end
end
53 changes: 49 additions & 4 deletions lib/sonos/system.rb
@@ -1,15 +1,60 @@
module Sonos
# The Sonos system. The root object to manage the collection of groups and devices. This is
# intended to be a singleton accessed from `Sonos.system`.
class System
attr_reader :topology
attr_reader :groups
attr_reader :devices

# Initialize the system
# @param [Array] the system topology. If this is nil, it will autodiscover.
def initialize(topology = Sonos::Discovery.new.topology)
@topology = topology
construct_groups
end

# Returns all speakers
def speakers
@devices.select(&:speaker?)
end

# Pause all speakers
def pause_all
# It looks like Sonos is just telling all of the groups to pause instead of
# having a message to actually pause all
self.groups.each do |group|
group.pause
end
end

def self.discover
Sonos::Discovery.new.discover
private

def construct_groups
# Reset
@groups = []
@devices = @topology.collect(&:device)

# Loop through all of the unique groups
@topology.collect(&:group).uniq.each do |group_uid|
# Select all of the nodes with this group uid
nodes = @topology.select do |node|
node.group == group_uid
end

next if nodes.empty?

# Find master node
master_uuid = group_uid.split(':').first
master = nodes.select do |node|
node.uuid == master_uuid
end

next unless master.count == 1
master = master.first

nodes.delete(master)

# Add the group
@groups << Group.new(master.device, nodes.collect(&:device))
end
end
end
end

0 comments on commit 4f29e13

Please sign in to comment.