sonos 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+
data/Changelog.markdown CHANGED
@@ -1,3 +1,10 @@
1
+ ### Version 0.3.0 — February 2, 2012
2
+
3
+ * System owns groups that reflect the topology
4
+ * Group and ungroup speakers
5
+ * Rename `playlist_position` to `queue_position` for consistency
6
+ * Add `seek`
7
+
1
8
  ### Version 0.2.1 — December 24, 2012
2
9
 
3
10
  * Fix album art in now playing
data/Gemfile CHANGED
@@ -1,7 +1,17 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- # Specify your gem's dependencies in control.gemspec
3
+ # Gem dependencies
4
4
  gemspec
5
5
 
6
+ # Development dependencies
6
7
  gem 'rake'
7
8
  gem 'yard'
9
+
10
+ # Testing dependencies
11
+ group :test do
12
+ gem 'minitest'
13
+ gem 'minitest-wscolor'
14
+ gem 'fakeweb'
15
+ gem 'vcr'
16
+ gem 'mocha', require: false
17
+ end
data/Rakefile CHANGED
@@ -1 +1,8 @@
1
1
  require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'test'
6
+ t.pattern = 'test/**/*_test.rb'
7
+ end
8
+ task default: :test
data/Readme.markdown CHANGED
@@ -4,6 +4,8 @@ Control Sonos speakers with Ruby.
4
4
 
5
5
  Huge thanks to [Rahim Sonawalla](https://github.com/rahims) for making [SoCo](https://github.com/rahims/SoCo). This gem would not be possible without his work.
6
6
 
7
+ [![Code Climate](https://codeclimate.com/badge.png)](https://codeclimate.com/github/soffes/sonos) [![Dependency Status](https://gemnasium.com/soffes/sonos.png)](https://gemnasium.com/soffes/sonos)
8
+
7
9
  ## Installation
8
10
 
9
11
  Add this line to your application's Gemfile:
@@ -36,7 +38,8 @@ $ irb
36
38
  ``` ruby
37
39
  require 'rubygems'
38
40
  require 'sonos'
39
- speaker = Sonos.discover
41
+ system = Sonos::System.new # Auto-discovers your system
42
+ speaker = system.speakers.first
40
43
  ```
41
44
 
42
45
  Now that we have a reference to the speaker, we can do all kinds of stuff.
@@ -56,41 +59,48 @@ speaker.clear_queue
56
59
 
57
60
  ### Topology
58
61
 
59
- `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.
62
+ `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`.
63
+
64
+ All of this is based off of the raw `Sonos.system.topology`.
60
65
 
61
66
  ### CLI
62
67
 
63
- There is a very limited CLI right now. You can run `sonos discover` to get the IP of one of your devices. Run `sonos discover --all` to get all of them.
68
+ There is a very limited CLI right now. You can run `sonos devices` to get the IP of all of your devices.
69
+
70
+ You can also run `sonos pause_all` to pause all your Sonos groups.
64
71
 
65
72
  ## To Do
66
73
 
67
74
  ### General
68
75
 
69
- * Refactor all of the things
70
- * Nonblocking calls with Celluloid::IO
71
- * List other speakers
72
76
  * Handle errors better
73
77
  * Handle line-in in `now_playing`
74
- * Better support for stero pairs
78
+ * Detect fixed volume
79
+ * Detect stereo pair
75
80
  * CLI client for everything
81
+ * Nonblocking calls with Celluloid::IO
76
82
 
77
83
  ### Features
78
84
 
79
- * Alarm clock
85
+ * Manipulating groups doesn't update `System#groups`
80
86
  * Pause all (there is no play all in the controller, we could loop through and do it though)
81
- * Group management
82
- * Party Mode
83
- * Join
87
+ * Party Mode
84
88
  * Line-in
85
89
  * Toggle cross fade
86
90
  * Toggle shuffle
87
91
  * Set repeat mode
88
- * Scrub
89
92
  * Search music library
90
93
  * Browse music library
91
94
  * Add songs to queue
92
95
  * Skip to song in queue
96
+ * Alarm clock
93
97
  * Sleep timer
98
+ * Pandora doesn't use the Queue. I bet things are all jacked up.
99
+ * CONNECT (and possibly PLAY:5) line in settings
100
+ * Source name
101
+ * Level
102
+ * Autoplay room
103
+ * Autoplay include grouped rooms
94
104
 
95
105
  ### Maybe
96
106
 
@@ -98,14 +108,16 @@ If we are implementing everything the official Sonos Controller does, here's som
98
108
 
99
109
  * Set zone name and icon
100
110
  * Create stero pair
101
- * Support for BRIDGE
111
+ * Support for SUB
102
112
  * Support for DOCK
113
+ * Support for CONNECT:AMP (not sure if this is any different from CONNECT)
103
114
  * Manage services
104
115
  * Date and time
105
116
  * Wireless channel
106
117
  * Audio compression
107
118
  * Automatically check for updates (not sure if this is a controller only preference)
108
119
  * Local music servers
120
+ * Add component
109
121
 
110
122
  ## Contributing
111
123
 
data/lib/sonos/cli.rb CHANGED
@@ -3,18 +3,29 @@ require 'sonos'
3
3
 
4
4
  module Sonos
5
5
  class Cli < Thor
6
- desc 'discover', 'Finds the IP address of a Sonos device on your network'
7
- method_option :all, type: :boolean, aliases: '-a', desc: 'Find all of the IP address instead of the first one discoverd'
8
- def discover
9
- speaker = Sonos.discover
6
+ desc 'devices', 'Finds the IP address of all of the Sonos devices on your network'
7
+ def devices
8
+ system.devices.each do |device|
9
+ puts device.name.ljust(20) + device.ip
10
+ end
11
+ end
10
12
 
11
- if options[:all]
12
- speaker.topology.each do |node|
13
- puts "#{node.name.ljust(20)} #{node.ip}"
14
- end
15
- else
16
- puts "#{speaker.zone_name.ljust(20)} #{speaker.ip}"
13
+ desc 'speakers', 'Finds the IP address of all of the Sonos speakers on your network'
14
+ def speakers
15
+ system.speakers.each do |speaker|
16
+ puts speaker.name.ljust(20) + speaker.ip
17
17
  end
18
18
  end
19
+
20
+ desc 'pause_all', 'Pauses all Sonos speaker groups'
21
+ def pause_all
22
+ system.pause_all
23
+ end
24
+
25
+ private
26
+
27
+ def system
28
+ @system ||= Sonos::System.new
29
+ end
19
30
  end
20
31
  end
@@ -0,0 +1,79 @@
1
+ require 'open-uri'
2
+ require 'nokogiri'
3
+
4
+ module Sonos::Device
5
+ class Base
6
+ attr_reader :ip, :name, :uid, :serial_number, :software_version, :hardware_version, :mac_address, :group
7
+
8
+ def self.detect(ip)
9
+ data = retrieve_information(ip)
10
+ model_number = data[:model_number]
11
+
12
+ if Bridge.model_numbers.include?(model_number)
13
+ Bridge.new(ip, data)
14
+ elsif Speaker.model_numbers.include?(model_number)
15
+ Speaker.new(ip, data)
16
+ else
17
+ raise ArgumentError.new("#{self.data[:model_number]} not supported")
18
+ end
19
+ end
20
+
21
+ def initialize(ip, data = nil)
22
+ @ip = ip
23
+
24
+ if data.nil?
25
+ self.data = Base.retrieve_information(ip)
26
+ else
27
+ self.data = data
28
+ end
29
+ end
30
+
31
+ def data=(data)
32
+ @name = data[:name]
33
+ @uid = data[:uid]
34
+ @serial_number = data[:serial_number]
35
+ @software_version = data[:software_version]
36
+ @hardware_version = data[:hardware_version]
37
+ @zone_type = data[:zone_type]
38
+ @model_number = data[:model_number]
39
+ end
40
+
41
+ def data
42
+ {
43
+ name: @name,
44
+ uid: @uid,
45
+ serial_number: @serial_number,
46
+ software_version: @software_version,
47
+ hardware_version: @hardware_version,
48
+ zone_type: @zone_type,
49
+ model_number: @model_number
50
+ }
51
+ end
52
+
53
+ # Can this device play music?
54
+ # @return [Boolean] a boolean indicating if it can play music
55
+ def speaker?
56
+ false
57
+ end
58
+
59
+ protected
60
+
61
+ def self.retrieve_information(ip)
62
+ url = "http://#{ip}:#{Sonos::PORT}/xml/device_description.xml"
63
+ parse_description(Nokogiri::XML(open(url)))
64
+ end
65
+
66
+ # Get information about the device
67
+ def self.parse_description(doc)
68
+ {
69
+ name: doc.xpath('/xmlns:root/xmlns:device/xmlns:roomName').inner_text,
70
+ uid: doc.xpath('/xmlns:root/xmlns:device/xmlns:UDN').inner_text,
71
+ serial_number: doc.xpath('/xmlns:root/xmlns:device/xmlns:serialNum').inner_text,
72
+ software_version: doc.xpath('/xmlns:root/xmlns:device/xmlns:hardwareVersion').inner_text,
73
+ hardware_version: doc.xpath('/xmlns:root/xmlns:device/xmlns:softwareVersion').inner_text,
74
+ zone_type: doc.xpath('/xmlns:root/xmlns:device/xmlns:zoneType').inner_text,
75
+ model_number: doc.xpath('/xmlns:root/xmlns:device/xmlns:modelNumber').inner_text
76
+ }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ require 'savon'
2
+ require 'sonos/endpoint'
3
+
4
+ module Sonos::Device
5
+
6
+ # Used for Zone Bridge
7
+ class Bridge < Base
8
+
9
+ MODEL_NUMBERS = ['ZB100']
10
+
11
+ attr_reader :icon
12
+
13
+ def self.model_numbers
14
+ MODEL_NUMBERS
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ require 'savon'
2
+ require 'sonos/endpoint'
3
+
4
+ module Sonos::Device
5
+
6
+ # Used for PLAY:3, PLAY:5, and CONNECT
7
+ class Speaker < Base
8
+ include Sonos::Endpoint::AVTransport
9
+ include Sonos::Endpoint::Rendering
10
+ include Sonos::Endpoint::Device
11
+ include Sonos::Endpoint::ContentDirectory
12
+
13
+ MODEL_NUMBERS = ['S3', 'S5', 'ZP90']
14
+
15
+ attr_reader :icon
16
+
17
+ def self.model_numbers
18
+ MODEL_NUMBERS
19
+ end
20
+
21
+ def speaker?
22
+ true
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ module Sonos
2
+ module Device
3
+ end
4
+ end
5
+
6
+ require 'sonos/device/base'
7
+ require 'sonos/device/speaker'
8
+ require 'sonos/device/bridge'
@@ -1,6 +1,7 @@
1
1
  require 'socket'
2
2
  require 'ipaddr'
3
3
  require 'timeout'
4
+ require 'sonos/topology_node'
4
5
 
5
6
  #
6
7
  # Inspired by https://github.com/rahims/SoCo, https://github.com/turboladen/upnp,
@@ -18,17 +19,29 @@ module Sonos
18
19
  DEFAULT_TIMEOUT = 1
19
20
 
20
21
  attr_reader :timeout
22
+ attr_reader :first_device_ip
21
23
 
22
- def initialize(timeout = nil)
23
- @timeout = timeout || DEFAULT_TIMEOUT
24
-
24
+ def initialize(timeout = DEFAULT_TIMEOUT)
25
+ @timeout = timeout
25
26
  initialize_socket
26
27
  end
27
28
 
29
+ # Look for Sonos devices on the network and return the first IP address found
30
+ # @return [String] the IP address of the first Sonos device found
28
31
  def discover
29
32
  send_discovery_message
30
- result = listen_for_responses
31
- Sonos::Speaker.new(result) if result
33
+ @first_device_ip = listen_for_responses
34
+ end
35
+
36
+ # Find all of the Sonos devices on the network
37
+ # @return [Array] an array of TopologyNode objects
38
+ def topology
39
+ self.discover unless @first_device_ip
40
+
41
+ doc = Nokogiri::XML(open("http://#{@first_device_ip}:#{Sonos::PORT}/status/topology"))
42
+ doc.xpath('//ZonePlayers/ZonePlayer').map do |node|
43
+ TopologyNode.new(node)
44
+ end
32
45
  end
33
46
 
34
47
  private
@@ -1,6 +1,6 @@
1
1
  require 'uri'
2
2
 
3
- module Sonos::Endpoint::Transport
3
+ module Sonos::Endpoint::AVTransport
4
4
  TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
5
5
  TRANSPORT_XMLNS = 'urn:schemas-upnp-org:service:AVTransport:1'
6
6
 
@@ -20,7 +20,7 @@ module Sonos::Endpoint::Transport
20
20
  title: doc.xpath('//dc:title').inner_text,
21
21
  artist: doc.xpath('//dc:creator').inner_text,
22
22
  album: doc.xpath('//upnp:album').inner_text,
23
- playlist_position: body[:track],
23
+ queue_position: body[:track],
24
24
  track_duration: body[:track_duration],
25
25
  current_position: body[:rel_time],
26
26
  uri: body[:track_uri],
@@ -58,6 +58,16 @@ module Sonos::Endpoint::Transport
58
58
  send_transport_message('Previous')
59
59
  end
60
60
 
61
+ # Seeks to a given timestamp in the current track
62
+ # @param [Fixnum] seconds
63
+ def seek(seconds = 0)
64
+ # Must be sent in the format of HH:MM:SS
65
+ timestamp = Time.at(seconds).utc.strftime('%H:%M:%S')
66
+
67
+ send_transport_message('Seek', "<Unit>REL_TIME</Unit><Target>#{timestamp}</Target>")
68
+
69
+ end
70
+
61
71
  # Clear the queue
62
72
  def clear_queue
63
73
  send_transport_message('RemoveAllTracksFromQueue')
@@ -65,30 +75,41 @@ module Sonos::Endpoint::Transport
65
75
 
66
76
  # Save queue
67
77
  def save_queue(title)
68
- name = 'SaveQueue'
69
- action = "#{TRANSPORT_XMLNS}##{name}"
70
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Title>#{title}</Title><ObjectID></ObjectID></u:#{name}>}
71
- transport_client.call(name, soap_action: action, message: message)
78
+ send_transport_message('SaveQueue', "<Title>#{title}</Title><ObjectID></ObjectID>")
79
+ end
80
+
81
+ # Join another speaker's group.
82
+ # Trying to call this on a stereo pair slave will fail.
83
+ def join(master)
84
+ set_av_transport_uri('x-rincon:' + master.uid.sub('uuid:', ''))
85
+ end
86
+
87
+ # Add another speaker to this group.
88
+ # Trying to call this on a stereo pair slave will fail.
89
+ def group(slave)
90
+ slave.join(self)
91
+ end
92
+
93
+ # Ungroup from its current group.
94
+ # Trying to call this on a stereo pair slave will fail.
95
+ def ungroup
96
+ send_transport_message('BecomeCoordinatorOfStandaloneGroup')
72
97
  end
73
98
 
74
99
  private
75
100
 
76
101
  # Play a stream.
77
102
  def set_av_transport_uri(uri)
78
- name = 'SetAVTransportURI'
79
- action = "#{TRANSPORT_XMLNS}##{name}"
80
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData></u:#{name}>}
81
- transport_client.call(name, soap_action: action, message: message)
82
- self.play
103
+ send_transport_message('SetAVTransportURI', "<CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>")
83
104
  end
84
105
 
85
106
  def transport_client
86
107
  @transport_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{TRANSPORT_ENDPOINT}", namespace: Sonos::NAMESPACE
87
108
  end
88
109
 
89
- def send_transport_message(name)
110
+ def send_transport_message(name, part = '<Speed>1</Speed>')
90
111
  action = "#{TRANSPORT_XMLNS}##{name}"
91
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Speed>1</Speed></u:#{name}>}
112
+ message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID>#{part}</u:#{name}>}
92
113
  transport_client.call(name, soap_action: action, message: message)
93
114
  end
94
115
  end
@@ -3,6 +3,7 @@ module Sonos::Endpoint::Rendering
3
3
  RENDERING_XMLNS = 'urn:schemas-upnp-org:service:RenderingControl:1'
4
4
 
5
5
  # Get the current volume.
6
+ # Fixed volume speakers will always return 100.
6
7
  # @return [Fixnum] the volume from 0 to 100
7
8
  def volume
8
9
  response = send_rendering_message('GetVolume')
@@ -10,6 +11,7 @@ module Sonos::Endpoint::Rendering
10
11
  end
11
12
 
12
13
  # Set the volume from 0 to 100.
14
+ # Trying to set the volume of a fixed volume speaker will fail.
13
15
  # @param [Fixnum] the desired volume from 0 to 100
14
16
  def volume=(value)
15
17
  send_rendering_message('SetVolume', value)
@@ -6,4 +6,4 @@ end
6
6
  require 'sonos/endpoint/content_directory'
7
7
  require 'sonos/endpoint/device'
8
8
  require 'sonos/endpoint/rendering'
9
- require 'sonos/endpoint/transport'
9
+ require 'sonos/endpoint/a_v_transport'
@@ -0,0 +1,48 @@
1
+ module Sonos
2
+ # Represents a Sonos group. A group can contain one or more speakers. All speakers in a group
3
+ # play the same music in sync.
4
+ class Group
5
+ # The master speaker in the group
6
+ attr_reader :master_speaker
7
+
8
+ # All other speakers in the group
9
+ attr_reader :slave_speakers
10
+
11
+ def initialize(master_speaker, slave_speakers)
12
+ @master_speaker = master_speaker
13
+ @slave_speakers = (slave_speakers or [])
14
+ end
15
+
16
+ # All of the speakers in the group
17
+ def speakers
18
+ [self.master_speaker] + self.slave_speakers
19
+ end
20
+
21
+ # Remove all speakers from the group
22
+ def disband
23
+ self.slave_speakers.each do |speaker|
24
+ speaker.ungroup
25
+ end
26
+ end
27
+
28
+ # Full group name
29
+ def name
30
+ self.speakers.collect(&:name).uniq.join(', ')
31
+ end
32
+
33
+ # Forward AVTransport methods to the master speaker
34
+ %w{now_playing pause stop next previous queue clear_queue}.each do |method|
35
+ define_method(method) do
36
+ self.master_speaker.send(method.to_sym)
37
+ end
38
+ end
39
+
40
+ def play(uri = nil)
41
+ self.master_speaker.play(uri)
42
+ end
43
+
44
+ def save_queue(name)
45
+ self.master_speaker.save_queue(name)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,59 @@
1
+ module Sonos
2
+ # The Sonos system. The root object to manage the collection of groups and devices. This is
3
+ # intended to be a singleton accessed from `Sonos.system`.
4
+ class System
5
+ attr_reader :topology
6
+ attr_reader :groups
7
+ attr_reader :devices
8
+
9
+ # Initialize the system
10
+ # @param [Array] the system topology. If this is nil, it will autodiscover.
11
+ def initialize(topology = Discovery.new.topology)
12
+ @topology = topology
13
+ @groups = []
14
+ @devices = @topology.collect(&:device)
15
+
16
+ construct_groups
17
+ end
18
+
19
+ # Returns all speakers
20
+ def speakers
21
+ @devices.select(&:speaker?)
22
+ end
23
+
24
+ # Pause all speakers
25
+ def pause_all
26
+ self.groups.each do |group|
27
+ group.pause
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def construct_groups
34
+ # Loop through all of the unique groups
35
+ @topology.collect(&:group).uniq.each do |group_uid|
36
+ master_uuid = group_uid.split(':').first
37
+ nodes = []
38
+ master = nil
39
+
40
+ @topology.each do |node|
41
+ # Select all of the nodes with this group uid
42
+ next unless node.group == group_uid
43
+
44
+ if node.uuid == master_uuid
45
+ master = node
46
+ else
47
+ nodes << node
48
+ end
49
+ end
50
+
51
+ # Skip this group if there are no nodes or master
52
+ next if nodes.empty? or master.nil?
53
+
54
+ # Add the group
55
+ @groups << Group.new(master.device, nodes.collect(&:device))
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,21 @@
1
+ module Sonos
2
+ class TopologyNode
3
+ attr_accessor :name, :group, :coordinator, :location, :version, :uuid
4
+
5
+ def initialize(node)
6
+ node.attributes.each do |k, v|
7
+ self.send("#{k}=", v.inner_text) if self.respond_to?(k.to_sym)
8
+ end
9
+
10
+ self.name = node.inner_text
11
+ end
12
+
13
+ def ip
14
+ @ip ||= URI.parse(location).host
15
+ end
16
+
17
+ def device
18
+ @device || Device::Base.detect(ip)
19
+ end
20
+ end
21
+ end
data/lib/sonos/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sonos
2
- VERSION = '0.2.1'
2
+ VERSION = '0.3.0'
3
3
  end
data/lib/sonos.rb CHANGED
@@ -1,16 +1,21 @@
1
1
  require 'sonos/version'
2
- require 'sonos/speaker'
2
+ require 'sonos/system'
3
3
  require 'sonos/discovery'
4
+ require 'sonos/device'
5
+ require 'sonos/group'
4
6
 
5
7
  module Sonos
6
8
  PORT = 1400
7
9
  NAMESPACE = 'http://www.sonos.com/Services/1.1'
8
10
 
9
- def self.Speaker(ip)
10
- Speaker.new(ip)
11
- end
11
+ # # Create a new speaker with it's IP address
12
+ # # @param [String] the speaker's IP address
13
+ # def self.speaker(ip)
14
+ # Device::Speaker.new(ip)
15
+ # end
12
16
 
13
- def self.discover
14
- Sonos::Discovery.new.discover
15
- end
17
+ # # Get the Sonos system
18
+ # def self.system
19
+ # @system ||= Sonos::System.new
20
+ # end
16
21
  end
data/sonos.gemspec CHANGED
@@ -11,12 +11,14 @@ Gem::Specification.new do |gem|
11
11
  gem.description = 'Control Sonos speakers with Ruby'
12
12
  gem.summary = gem.description
13
13
  gem.homepage = 'https://github.com/soffes/sonos'
14
+ gem.license = 'MIT'
14
15
 
15
16
  gem.files = `git ls-files`.split($/)
16
17
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
18
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
19
  gem.require_paths = ['lib']
19
20
 
21
+ gem.required_ruby_version = '>= 1.9.2'
20
22
  gem.add_dependency 'savon', '~> 2.0.2'
21
23
  gem.add_dependency 'nokogiri'
22
24
  gem.add_dependency 'thor'