From 4f29e13063bcf2d9ea5cd91f335df0004198d5a0 Mon Sep 17 00:00:00 2001 From: Sam Soffes Date: Thu, 27 Dec 2012 01:31:07 -0500 Subject: [PATCH] System now owns groups that correctly represent the topology --- Changelog.markdown | 4 ++- Readme.markdown | 9 +++-- lib/sonos.rb | 27 +++++++-------- lib/sonos/device.rb | 8 +++++ lib/sonos/device/base.rb | 28 +++++++++------ lib/sonos/device/speaker.rb | 4 +++ lib/sonos/discovery.rb | 45 ++++++++---------------- lib/sonos/endpoint/a_v_transport.rb | 11 +++--- lib/sonos/endpoint/rendering.rb | 2 ++ lib/sonos/group.rb | 45 +++++++++++++++++++++++- lib/sonos/system.rb | 53 ++++++++++++++++++++++++++--- lib/sonos/topology_node.rb | 21 ++++++++++++ 12 files changed, 189 insertions(+), 68 deletions(-) create mode 100644 lib/sonos/device.rb create mode 100644 lib/sonos/topology_node.rb diff --git a/Changelog.markdown b/Changelog.markdown index 95ad3e9..157fb47 100644 --- a/Changelog.markdown +++ b/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 diff --git a/Readme.markdown b/Readme.markdown index c04cb47..b15b55b 100644 --- a/Readme.markdown +++ b/Readme.markdown @@ -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. @@ -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 @@ -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 diff --git a/lib/sonos.rb b/lib/sonos.rb index 7b3d316..10fd0fc 100644 --- a/lib/sonos.rb +++ b/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' diff --git a/lib/sonos/device.rb b/lib/sonos/device.rb new file mode 100644 index 0000000..fd0bdba --- /dev/null +++ b/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' diff --git a/lib/sonos/device/base.rb b/lib/sonos/device/base.rb index 4294e9c..8263390 100644 --- a/lib/sonos/device/base.rb +++ b/lib/sonos/device/base.rb @@ -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 @@ -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 diff --git a/lib/sonos/device/speaker.rb b/lib/sonos/device/speaker.rb index 4be294a..7cf8c45 100644 --- a/lib/sonos/device/speaker.rb +++ b/lib/sonos/device/speaker.rb @@ -17,5 +17,9 @@ class Speaker < Base def self.model_numbers MODEL_NUMBERS end + + def speaker? + true + end end end diff --git a/lib/sonos/discovery.rb b/lib/sonos/discovery.rb index 017709d..3b63f21 100644 --- a/lib/sonos/discovery.rb +++ b/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, @@ -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) @@ -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 diff --git a/lib/sonos/endpoint/a_v_transport.rb b/lib/sonos/endpoint/a_v_transport.rb index 21e8de4..f87cc09 100644 --- a/lib/sonos/endpoint/a_v_transport.rb +++ b/lib/sonos/endpoint/a_v_transport.rb @@ -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], @@ -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 diff --git a/lib/sonos/endpoint/rendering.rb b/lib/sonos/endpoint/rendering.rb index 9279037..b0dffc5 100644 --- a/lib/sonos/endpoint/rendering.rb +++ b/lib/sonos/endpoint/rendering.rb @@ -3,6 +3,7 @@ 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') @@ -10,6 +11,7 @@ def volume 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) diff --git a/lib/sonos/group.rb b/lib/sonos/group.rb index 7fbb9de..29aa246 100644 --- a/lib/sonos/group.rb +++ b/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 diff --git a/lib/sonos/system.rb b/lib/sonos/system.rb index 85b9100..6ed8a12 100644 --- a/lib/sonos/system.rb +++ b/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 diff --git a/lib/sonos/topology_node.rb b/lib/sonos/topology_node.rb new file mode 100644 index 0000000..82d5219 --- /dev/null +++ b/lib/sonos/topology_node.rb @@ -0,0 +1,21 @@ +module Sonos + class TopologyNode + attr_accessor :name, :group, :coordinator, :location, :version, :uuid + + def initialize(node) + node.attributes.each do |k, v| + self.send("#{k}=", v.inner_text) 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 || Device::Base.detect(ip) + end + end +end