sonos 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Changelog.markdown CHANGED
@@ -1,28 +1,35 @@
1
- ### Version 0.1.1Unreleased
1
+ ### Version 0.2.0December 24, 2012
2
2
 
3
- * **Feature:** Queue
4
- * **Feature:** Clear Queue
5
- * **Feature:** Save Queue
6
- * **Feature:** Device Description URL
3
+ * Add Loudness setting
4
+ * Moved `sonos-discover` to `sonos discover`
5
+ * Moved `sonos-discover-multiple` to `sonos discover --all`
6
+ * Cleaned up enpoints
7
+
8
+ ### Version 0.1.1 — December 24, 2012
9
+
10
+ * Add Queue
11
+ * Add Clear Queue
12
+ * Add Save Queue
13
+ * Add Device Description URL
7
14
 
8
15
  ### Version 0.1.0 — December 24, 2012
9
16
 
10
- * **Feature:** Stop
11
- * **Feature:** Next
12
- * **Feature:** Previous
13
- * **Feature:** Mute
14
- * **Feature:** Status Light
15
- * **Feature:** Bass
16
- * **Feature:** Treble
17
+ * Add Stop
18
+ * Add Next
19
+ * Add Previous
20
+ * Add Mute
21
+ * Add Status Light
22
+ * Add Bass
23
+ * Add Treble
17
24
  * Remove some method aliases
18
25
 
19
26
  ### Version 0.0.1 — December 23, 2012
20
27
 
21
28
  Initial release.
22
29
 
23
- * **Feature:** Volume
24
- * **Feature:** Play
25
- * **Feature:** Play Stream
26
- * **Feature:** Pause
27
- * **Feature:** Now Playing
30
+ * Add Volume
31
+ * Add Play
32
+ * Add Play Stream
33
+ * Add Pause
34
+ * Add Now Playing
28
35
 
data/Readme.markdown CHANGED
@@ -26,7 +26,7 @@ $ gem install sonos
26
26
 
27
27
  ## Usage
28
28
 
29
- I'm working on a CLI client. For now, we'll use IRB. You will need the IP address of a speaker (auto-detection is on my list too). To get the IP of a speaker, one of your Sonos controllers and go to "About My Sonos System".
29
+ I'm working on a CLI client. For now, we'll use IRB.
30
30
 
31
31
  ``` shell
32
32
  $ gem install sonos
@@ -36,7 +36,7 @@ $ irb
36
36
  ``` ruby
37
37
  require 'rubygems'
38
38
  require 'sonos'
39
- speaker = Sonos::Speaker('10.0.1.10') # or whatever the IP is
39
+ speaker = Sonos.discover
40
40
  ```
41
41
 
42
42
  Now that we have a reference to the speaker, we can do all kinds of stuff.
@@ -49,20 +49,35 @@ speaker.now_playing
49
49
  speaker.volume
50
50
  speaker.volume = 70
51
51
  speaker.volume -= 10
52
+ speaker.queue
53
+ speaker.save_queue 'Jams'
54
+ speaker.clear_queue
52
55
  ```
53
56
 
57
+ ### Topology
58
+
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.
60
+
61
+ ### CLI
62
+
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.
64
+
54
65
  ## To Do
55
66
 
67
+ * Refactor all of the things
68
+ * Nonblocking calls with Celluloid::IO
69
+ * List other speakers
56
70
  * Loudness
57
- * Party Mode
58
- * Join
59
- * Line-in
71
+ * Alarm clock
72
+ * Group management
73
+ * Party Mode
74
+ * Join
75
+ * Line-in (I don't have a PLAY:5, so I'll need help testing this one)
60
76
  * Handle errors better
61
77
  * Fix album art in `now_playing`
62
78
  * Handle line-in in `now_playing`
63
- * Auto-discovery
64
79
  * Better support for stero pairs
65
- * CLI client
80
+ * CLI client for everything
66
81
 
67
82
  ## Contributing
68
83
 
data/bin/sonos ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path('../../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'sonos/cli'
6
+
7
+ Sonos::Cli.start
data/lib/sonos.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'sonos/version'
2
2
  require 'sonos/speaker'
3
+ require 'sonos/discovery'
3
4
 
4
5
  module Sonos
5
6
  PORT = 1400
@@ -8,4 +9,8 @@ module Sonos
8
9
  def self.Speaker(ip)
9
10
  Speaker.new(ip)
10
11
  end
12
+
13
+ def self.discover
14
+ Sonos::Discovery.new.discover
15
+ end
11
16
  end
data/lib/sonos/cli.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'thor'
2
+ require 'sonos'
3
+
4
+ module Sonos
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
10
+
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}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,73 @@
1
+ require 'socket'
2
+ require 'ipaddr'
3
+ require 'timeout'
4
+
5
+ #
6
+ # Inspired by https://github.com/rahims/SoCo, https://github.com/turboladen/upnp,
7
+ # and http://onestepback.org/index.cgi/Tech/Ruby/MulticastingInRuby.red.
8
+ #
9
+ # Turboladen's uPnP work is super-smart, but doesn't seem to work with 1.9.3 due to soap4r dep's.
10
+ #
11
+ # Some day this nonsense should be asynchronous / nonblocking / decorated with rainbows.
12
+ #
13
+
14
+ module Sonos
15
+ class Discovery
16
+ MULTICAST_ADDR = '239.255.255.250'
17
+ MULTICAST_PORT = 1900
18
+ DEFAULT_TIMEOUT = 1
19
+
20
+ attr_reader :timeout
21
+
22
+ def initialize(timeout = nil)
23
+ @timeout = timeout || DEFAULT_TIMEOUT
24
+
25
+ initialize_socket
26
+ end
27
+
28
+ def discover
29
+ send_discovery_message
30
+ result = listen_for_responses
31
+ Sonos::Speaker.new(result) if result
32
+ end
33
+
34
+ private
35
+
36
+ def send_discovery_message
37
+ # Request announcements
38
+ @socket.send(search_message, 0, MULTICAST_ADDR, MULTICAST_PORT)
39
+ end
40
+
41
+ def listen_for_responses
42
+ begin
43
+ Timeout::timeout(timeout) do
44
+ loop do
45
+ message, info = @socket.recvfrom(2048)
46
+ # return the IP address
47
+ return info[2]
48
+ end
49
+ end
50
+ rescue Timeout::Error => ex
51
+ nil
52
+ end
53
+ end
54
+
55
+ def initialize_socket
56
+ # Create a socket
57
+ @socket = UDPSocket.open
58
+
59
+ # We're going to use IP with the multicast TTL. Mystery third parameter is a mystery.
60
+ @socket.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, 2)
61
+ end
62
+
63
+ def search_message
64
+ [
65
+ 'M-SEARCH * HTTP/1.1',
66
+ "HOST: #{MULTICAST_ADDR}:reservedSSDPport",
67
+ 'MAN: ssdp:discover',
68
+ "MX: #{timeout}",
69
+ "ST: urn:schemas-upnp-org:device:ZonePlayer:1"
70
+ ].join("\n")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,9 @@
1
+ module Sonos
2
+ module Endpoint
3
+ end
4
+ end
5
+
6
+ require 'sonos/endpoint/content_directory'
7
+ require 'sonos/endpoint/device'
8
+ require 'sonos/endpoint/rendering'
9
+ require 'sonos/endpoint/transport'
@@ -0,0 +1,53 @@
1
+ module Sonos::Endpoint::ContentDirectory
2
+ CONTENT_DIRECTORY_ENDPOINT = '/MediaServer/ContentDirectory/Control'
3
+ CONTENT_DIRECTORY_XMLNS = 'urn:schemas-upnp-org:service:ContentDirectory:1'
4
+
5
+ # Get the current queue
6
+ def queue(starting_index = 0, requested_count = 100)
7
+ name = 'Browse'
8
+ action = "#{CONTENT_DIRECTORY_XMLNS}##{name}"
9
+ message = %Q{<u:#{name} xmlns:u="#{CONTENT_DIRECTORY_XMLNS}"><ObjectID>Q:0</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter>dc:title,res,dc:creator,upnp:artist,upnp:album,upnp:albumArtURI</Filter><StartingIndex>#{starting_index}</StartingIndex><RequestedCount>#{requested_count}</RequestedCount><SortCriteria></SortCriteria></u:Browse>}
10
+ result = content_directory_client.call name, soap_action: action, message: message
11
+ body = result.body[:browse_response]
12
+
13
+ hash = {
14
+ total: body[:total_matches].to_i,
15
+ items: parse_items(body[:result])
16
+ }
17
+
18
+ # Paginate
19
+ # TODO: This is ugly and inflexible
20
+ if starting_index == 0
21
+ start = starting_index
22
+ while hash[:items].count < hash[:total]
23
+ start += requested_count
24
+ hash[:items] += browse(start, requested_count)[:items]
25
+ end
26
+ end
27
+
28
+ hash
29
+ end
30
+
31
+ private
32
+
33
+ def content_directory_client
34
+ @content_directory_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{CONTENT_DIRECTORY_ENDPOINT}", namespace: Sonos::NAMESPACE
35
+ end
36
+
37
+ def parse_items(string)
38
+ result = []
39
+ doc = Nokogiri::XML(string)
40
+ doc.css('item').each do |item|
41
+ res = item.css('res').first
42
+ result << {
43
+ title: item.xpath('dc:title').inner_text,
44
+ artist: item.xpath('dc:creator').inner_text,
45
+ album: item.xpath('upnp:album').inner_text,
46
+ album_art: "http://#{self.ip}:#{PORT}#{item.xpath('upnp:albumArtURI').inner_text}",
47
+ duration: res['duration'],
48
+ id: res.inner_text
49
+ }
50
+ end
51
+ result
52
+ end
53
+ end
@@ -0,0 +1,23 @@
1
+ module Sonos::Endpoint::Device
2
+ DEVICE_ENDPOINT = '/DeviceProperties/Control'
3
+ DEVICE_XMLNS = 'urn:schemas-upnp-org:service:DeviceProperties:1'
4
+
5
+ # Turn the white status light on or off
6
+ # @param [Boolean] True to turn on the light. False to turn off the light.
7
+ def status_light_enabled=(enabled)
8
+ send_device_message('SetLEDState', enabled ? 'On' : 'Off')
9
+ end
10
+
11
+ private
12
+
13
+ def device_client
14
+ @device_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{DEVICE_ENDPOINT}", namespace: Sonos::NAMESPACE
15
+ end
16
+
17
+ def send_device_message(name, value)
18
+ action = "#{DEVICE_XMLNS}##{name}"
19
+ attribute = name.sub('Set', '')
20
+ message = %Q{<u:#{name} xmlns:u="#{DEVICE_XMLNS}"><Desired#{attribute}>#{value}</Desired#{attribute}>}
21
+ device_client.call(name, soap_action: action, message: message)
22
+ end
23
+ end
@@ -0,0 +1,99 @@
1
+ module Sonos::Endpoint::Rendering
2
+ RENDERING_ENDPOINT = '/MediaRenderer/RenderingControl/Control'
3
+ RENDERING_XMLNS = 'urn:schemas-upnp-org:service:RenderingControl:1'
4
+
5
+ # Get the current volume.
6
+ # @return [Fixnum] the volume from 0 to 100
7
+ def volume
8
+ response = send_rendering_message('GetVolume')
9
+ response.body[:get_volume_response][:current_volume].to_i
10
+ end
11
+
12
+ # Set the volume from 0 to 100.
13
+ # @param [Fixnum] the desired volume from 0 to 100
14
+ def volume=(value)
15
+ send_rendering_message('SetVolume', value)
16
+ end
17
+
18
+ # Get the current bass EQ.
19
+ # @return [Fixnum] the base EQ from -10 to 10
20
+ def bass
21
+ response = send_rendering_message('GetBass')
22
+ response.body[:get_bass_response][:current_bass].to_i
23
+ end
24
+
25
+ # Set the bass EQ from -10 to 10.
26
+ # @param [Fixnum] the desired bass EQ from -10 to 10
27
+ def bass=(value)
28
+ send_rendering_message('SetBass', value)
29
+ end
30
+
31
+ # Get the current treble EQ.
32
+ # @return [Fixnum] the treble EQ from -10 to 10
33
+ def treble
34
+ response = send_rendering_message('GetTreble')
35
+ response.body[:get_treble_response][:current_treble].to_i
36
+ end
37
+
38
+ # Set the treble EQ from -10 to 10.
39
+ # @param [Fixnum] the desired treble EQ from -10 to 10
40
+ def treble=(value)
41
+ send_rendering_message('SetTreble', value)
42
+ end
43
+
44
+ # Mute the speaker
45
+ def mute
46
+ set_mute(true)
47
+ end
48
+
49
+ # Unmute the speaker
50
+ def unmute
51
+ set_mute(false)
52
+ end
53
+
54
+ # Is the speaker muted?
55
+ # @return [Boolean] true if the speaker is muted and false if it is not
56
+ def muted?
57
+ response = send_rendering_message('GetMute')
58
+ response.body[:get_mute_response][:current_mute] == '1'
59
+ end
60
+
61
+ # Get the loudness compenstation setting
62
+ # @return [Boolean] true if the speaker has loudness on and false if it is not
63
+ def loudness
64
+ response = send_rendering_message('GetLoudness')
65
+ response.body[:get_loudness_response][:current_loudness] == '1'
66
+ end
67
+
68
+ # Set the loudness compenstation setting
69
+ # @param [Boolean] if the speaker has loudness on or not
70
+ def loudness=(value)
71
+ send_rendering_message('SetLoudness', value ? 1 : 0)
72
+ end
73
+
74
+ private
75
+
76
+ # Sets the speaker's mute
77
+ # @param [Boolean] if the speaker is muted or not
78
+ def set_mute(value)
79
+ send_rendering_message('SetMute', value ? 1 : 0)
80
+ end
81
+
82
+ def rendering_client
83
+ @rendering_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{RENDERING_ENDPOINT}", namespace: Sonos::NAMESPACE
84
+ end
85
+
86
+ def send_rendering_message(name, value = nil)
87
+ action = "#{RENDERING_XMLNS}##{name}"
88
+ message = %Q{<u:#{name} xmlns:u="#{RENDERING_XMLNS}"><InstanceID>0</InstanceID><Channel>Master</Channel>}
89
+
90
+ if value
91
+ attribute = name.sub('Set', '')
92
+ message += %Q{<Desired#{attribute}>#{value}</Desired#{attribute}></u:#{name}>}
93
+ else
94
+ message += %Q{</u:#{name}>}
95
+ end
96
+
97
+ rendering_client.call(name, soap_action: action, message: message)
98
+ end
99
+ end
@@ -0,0 +1,87 @@
1
+ module Sonos::Endpoint::Transport
2
+ TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
3
+ TRANSPORT_XMLNS = 'urn:schemas-upnp-org:service:AVTransport:1'
4
+
5
+ # Get information about the currently playing track.
6
+ # @return [Hash] information about the current track.
7
+ def now_playing
8
+ response = send_transport_message('GetPositionInfo')
9
+ body = response.body[:get_position_info_response]
10
+ doc = Nokogiri::XML(body[:track_meta_data])
11
+
12
+ {
13
+ title: doc.xpath('//dc:title').inner_text,
14
+ artist: doc.xpath('//dc:creator').inner_text,
15
+ album: doc.xpath('//upnp:album').inner_text,
16
+ playlist_position: body[:track],
17
+ track_duration: body[:track_duration],
18
+ current_position: body[:rel_time],
19
+ uri: body[:track_uri],
20
+ album_art: "http://#{self.ip}:#{PORT}#{doc.xpath('//upnp:albumArtURI').inner_text}"
21
+ }
22
+ end
23
+
24
+ # Pause the currently playing track.
25
+ def pause
26
+ send_transport_message('Pause')
27
+ end
28
+
29
+ # Play the currently selected track or play a stream.
30
+ # @param [String] optional uri of the track to play. Leaving this blank, plays the current track.
31
+ def play(uri = nil)
32
+ # Play a song from the uri
33
+ set_av_transport_uri(uri) and return if uri
34
+
35
+ # Play the currently selected track
36
+ send_transport_message('Play')
37
+ end
38
+
39
+ # Stop playing.
40
+ def stop
41
+ send_transport_message('Stop')
42
+ end
43
+
44
+ # Play the next track.
45
+ def next
46
+ send_transport_message('Next')
47
+ end
48
+
49
+ # Play the previous track.
50
+ def previous
51
+ send_transport_message('Previous')
52
+ end
53
+
54
+ # Clear the queue
55
+ def clear_queue
56
+ send_transport_message('RemoveAllTracksFromQueue')
57
+ end
58
+
59
+ # Save queue
60
+ def save_queue(title)
61
+ name = 'SaveQueue'
62
+ action = "#{TRANSPORT_XMLNS}##{name}"
63
+ message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Title>#{title}</Title><ObjectID></ObjectID></u:#{name}>}
64
+ transport_client.call(name, soap_action: action, message: message)
65
+ end
66
+
67
+ private
68
+
69
+ # Play a stream.
70
+ def set_av_transport_uri(uri)
71
+ name = 'SetAVTransportURI'
72
+ action = "#{TRANSPORT_XMLNS}##{name}"
73
+ message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData></u:#{name}>}
74
+ transport_client.call(name, soap_action: action, message: message)
75
+ self.play
76
+ end
77
+
78
+ def transport_client
79
+ @transport_client ||= Savon.client endpoint: "http://#{self.ip}:#{Sonos::PORT}#{TRANSPORT_ENDPOINT}", namespace: Sonos::NAMESPACE
80
+ end
81
+
82
+ def send_transport_message(name)
83
+ action = "#{TRANSPORT_XMLNS}##{name}"
84
+ message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Speed>1</Speed></u:#{name}>}
85
+ transport_client.call(name, soap_action: action, message: message)
86
+ end
87
+ end
data/lib/sonos/speaker.rb CHANGED
@@ -1,15 +1,15 @@
1
1
  require 'savon'
2
- require 'sonos/transport'
3
- require 'sonos/rendering'
4
- require 'sonos/device'
5
- require 'sonos/content_directory'
2
+ require 'open-uri'
3
+ require 'sonos/endpoint'
4
+ require 'sonos/topology'
6
5
 
7
6
  module Sonos
8
7
  class Speaker
9
- include Transport
10
- include Rendering
11
- include Device
12
- include ContentDirectory
8
+ include Endpoint::Transport
9
+ include Endpoint::Rendering
10
+ include Endpoint::Device
11
+ include Endpoint::ContentDirectory
12
+ include Topology
13
13
 
14
14
  attr_reader :ip, :zone_name, :zone_icon, :uid, :serial_number, :software_version, :hardware_version, :mac_address
15
15
 
@@ -29,7 +29,7 @@ module Sonos
29
29
 
30
30
  # Get information about the speaker.
31
31
  def get_status
32
- doc = Nokogiri::XML(open("http://#{@ip}:1400/status/zp"))
32
+ doc = Nokogiri::XML(open("http://#{@ip}:#{PORT}/status/zp"))
33
33
 
34
34
  @zone_name = doc.xpath('.//ZoneName').inner_text
35
35
  @zone_icon = doc.xpath('.//ZoneIcon').inner_text
@@ -0,0 +1,35 @@
1
+ require 'uri'
2
+
3
+ module Sonos
4
+ module Topology
5
+ def topology
6
+ doc = Nokogiri::XML(open("http://#{@ip}:#{PORT}/status/topology"))
7
+
8
+ doc.xpath('//ZonePlayers/ZonePlayer').map do |node|
9
+ Node.new(node)
10
+ end
11
+ end
12
+
13
+ protected
14
+
15
+ class Node
16
+ attr_accessor :name, :group, :coordinator, :location, :version, :uuid
17
+
18
+ def initialize(node)
19
+ node.attributes.each do |k, v|
20
+ self.send("#{k}=", v) if self.respond_to?(k.to_sym)
21
+ end
22
+
23
+ self.name = node.inner_text
24
+ end
25
+
26
+ def ip
27
+ @ip ||= URI.parse(location).host
28
+ end
29
+
30
+ def speaker
31
+ @speaker || Sonos::Speaker.new(ip)
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/sonos/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Sonos
2
- VERSION = '0.1.1'
2
+ VERSION = '0.2.0'
3
3
  end
data/sonos.gemspec CHANGED
@@ -6,8 +6,8 @@ require 'sonos/version'
6
6
  Gem::Specification.new do |gem|
7
7
  gem.name = 'sonos'
8
8
  gem.version = Sonos::VERSION
9
- gem.authors = ['Sam Soffes']
10
- gem.email = ['sam@soff.es']
9
+ gem.authors = ['Sam Soffes', 'Aaron Gotwalt']
10
+ gem.email = ['sam@soff.es', 'gotwalt@gmail.com']
11
11
  gem.description = 'Control Sonos speakers with Ruby'
12
12
  gem.summary = gem.description
13
13
  gem.homepage = 'https://github.com/soffes/sonos'
@@ -18,4 +18,5 @@ Gem::Specification.new do |gem|
18
18
  gem.require_paths = ['lib']
19
19
 
20
20
  gem.add_dependency 'savon', '~> 2.0.2'
21
+ gem.add_dependency 'thor'
21
22
  end
metadata CHANGED
@@ -1,11 +1,12 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sonos
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
8
8
  - Sam Soffes
9
+ - Aaron Gotwalt
9
10
  autorequire:
10
11
  bindir: bin
11
12
  cert_chain: []
@@ -27,10 +28,28 @@ dependencies:
27
28
  - - ~>
28
29
  - !ruby/object:Gem::Version
29
30
  version: 2.0.2
31
+ - !ruby/object:Gem::Dependency
32
+ name: thor
33
+ requirement: !ruby/object:Gem::Requirement
34
+ none: false
35
+ requirements:
36
+ - - ! '>='
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
30
47
  description: Control Sonos speakers with Ruby
31
48
  email:
32
49
  - sam@soff.es
33
- executables: []
50
+ - gotwalt@gmail.com
51
+ executables:
52
+ - sonos
34
53
  extensions: []
35
54
  extra_rdoc_files: []
36
55
  files:
@@ -40,12 +59,17 @@ files:
40
59
  - LICENSE
41
60
  - Rakefile
42
61
  - Readme.markdown
62
+ - bin/sonos
43
63
  - lib/sonos.rb
44
- - lib/sonos/content_directory.rb
45
- - lib/sonos/device.rb
46
- - lib/sonos/rendering.rb
64
+ - lib/sonos/cli.rb
65
+ - lib/sonos/discovery.rb
66
+ - lib/sonos/endpoint.rb
67
+ - lib/sonos/endpoint/content_directory.rb
68
+ - lib/sonos/endpoint/device.rb
69
+ - lib/sonos/endpoint/rendering.rb
70
+ - lib/sonos/endpoint/transport.rb
47
71
  - lib/sonos/speaker.rb
48
- - lib/sonos/transport.rb
72
+ - lib/sonos/topology.rb
49
73
  - lib/sonos/version.rb
50
74
  - sonos.gemspec
51
75
  homepage: https://github.com/soffes/sonos
@@ -62,7 +86,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
62
86
  version: '0'
63
87
  segments:
64
88
  - 0
65
- hash: -921214504657487484
89
+ hash: -1695773237927316888
66
90
  required_rubygems_version: !ruby/object:Gem::Requirement
67
91
  none: false
68
92
  requirements:
@@ -71,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
71
95
  version: '0'
72
96
  segments:
73
97
  - 0
74
- hash: -921214504657487484
98
+ hash: -1695773237927316888
75
99
  requirements: []
76
100
  rubyforge_project:
77
101
  rubygems_version: 1.8.23
@@ -1,55 +0,0 @@
1
- module Sonos
2
- module ContentDirectory
3
- CONTENT_DIRECTORY_ENDPOINT = '/MediaServer/ContentDirectory/Control'
4
- CONTENT_DIRECTORY_XMLNS = 'urn:schemas-upnp-org:service:ContentDirectory:1'
5
-
6
- # Get the current queue
7
- def queue(starting_index = 0, requested_count = 100)
8
- name = 'Browse'
9
- action = "#{CONTENT_DIRECTORY_XMLNS}##{name}"
10
- message = %Q{<u:#{name} xmlns:u="#{CONTENT_DIRECTORY_XMLNS}"><ObjectID>Q:0</ObjectID><BrowseFlag>BrowseDirectChildren</BrowseFlag><Filter>dc:title,res,dc:creator,upnp:artist,upnp:album,upnp:albumArtURI</Filter><StartingIndex>#{starting_index}</StartingIndex><RequestedCount>#{requested_count}</RequestedCount><SortCriteria></SortCriteria></u:Browse>}
11
- result = content_directory_client.call name, soap_action: action, message: message
12
- body = result.body[:browse_response]
13
-
14
- hash = {
15
- total: body[:total_matches].to_i,
16
- items: parse_items(body[:result])
17
- }
18
-
19
- # Paginate
20
- # TODO: This is ugly and inflexible
21
- if starting_index == 0
22
- start = starting_index
23
- while hash[:items].count < hash[:total]
24
- start += requested_count
25
- hash[:items] += browse(start, requested_count)[:items]
26
- end
27
- end
28
-
29
- hash
30
- end
31
-
32
- private
33
-
34
- def content_directory_client
35
- @content_directory_client ||= Savon.client endpoint: "http://#{self.ip}:#{PORT}#{CONTENT_DIRECTORY_ENDPOINT}", namespace: NAMESPACE
36
- end
37
-
38
- def parse_items(string)
39
- result = []
40
- doc = Nokogiri::XML(string)
41
- doc.css('item').each do |item|
42
- res = item.css('res').first
43
- result << {
44
- title: item.xpath('dc:title').inner_text,
45
- artist: item.xpath('dc:creator').inner_text,
46
- album: item.xpath('upnp:album').inner_text,
47
- album_art: "http://#{self.ip}:#{PORT}#{item.xpath('upnp:albumArtURI').inner_text}",
48
- duration: res['duration'],
49
- id: res.inner_text
50
- }
51
- end
52
- result
53
- end
54
- end
55
- end
data/lib/sonos/device.rb DELETED
@@ -1,25 +0,0 @@
1
- module Sonos
2
- module Device
3
- DEVICE_ENDPOINT = '/DeviceProperties/Control'
4
- DEVICE_XMLNS = 'urn:schemas-upnp-org:service:DeviceProperties:1'
5
-
6
- # Turn the white status light on or off
7
- # @param [Boolean] True to turn on the light. False to turn off the light.
8
- def status_light_enabled=(enabled)
9
- send_device_message('SetLEDState', enabled ? 'On' : 'Off')
10
- end
11
-
12
- private
13
-
14
- def device_client
15
- @device_client ||= Savon.client endpoint: "http://#{self.ip}:#{PORT}#{DEVICE_ENDPOINT}", namespace: NAMESPACE
16
- end
17
-
18
- def send_device_message(name, value)
19
- action = "#{DEVICE_XMLNS}##{name}"
20
- attribute = name.sub('Set', '')
21
- message = %Q{<u:#{name} xmlns:u="#{DEVICE_XMLNS}"><Desired#{attribute}>#{value}</Desired#{attribute}>}
22
- device_client.call(name, soap_action: action, message: message)
23
- end
24
- end
25
- end
@@ -1,88 +0,0 @@
1
- module Sonos
2
- module Rendering
3
- RENDERING_ENDPOINT = '/MediaRenderer/RenderingControl/Control'
4
- RENDERING_XMLNS = 'urn:schemas-upnp-org:service:RenderingControl:1'
5
-
6
- # Get the current volume.
7
- # @return [Fixnum] the volume from 0 to 100
8
- def volume
9
- response = send_rendering_message('GetVolume')
10
- response.body[:get_volume_response][:current_volume].to_i
11
- end
12
-
13
- # Set the volume from 0 to 100.
14
- # @param [Fixnum] the desired volume from 0 to 100
15
- def volume=(value)
16
- send_rendering_message('SetVolume', value)
17
- end
18
-
19
- # Get the current bass EQ.
20
- # @return [Fixnum] the base EQ from -10 to 10
21
- def bass
22
- response = send_rendering_message('GetBass')
23
- response.body[:get_bass_response][:current_bass].to_i
24
- end
25
-
26
- # Set the bass EQ from -10 to 10.
27
- # @param [Fixnum] the desired bass EQ from -10 to 10
28
- def bass=(value)
29
- send_rendering_message('SetBass', value)
30
- end
31
-
32
- # Get the current treble EQ.
33
- # @return [Fixnum] the treble EQ from -10 to 10
34
- def treble
35
- response = send_rendering_message('GetTreble')
36
- response.body[:get_treble_response][:current_treble].to_i
37
- end
38
-
39
- # Set the treble EQ from -10 to 10.
40
- # @param [Fixnum] the desired treble EQ from -10 to 10
41
- def treble=(value)
42
- send_rendering_message('SetTreble', value)
43
- end
44
-
45
- # Mute the speaker
46
- def mute
47
- set_mute(true)
48
- end
49
-
50
- # Unmute the speaker
51
- def unmute
52
- set_mute(false)
53
- end
54
-
55
- # Is the speaker muted?
56
- # @return [Boolean] true if the speaker is muted and false if it is not
57
- def muted?
58
- response = send_rendering_message('GetMute')
59
- response.body[:get_mute_response][:current_mute] == '1'
60
- end
61
-
62
- private
63
-
64
- # Sets the speaker's mute
65
- # @param [Boolean] if the speaker is muted or not
66
- def set_mute(value)
67
- send_rendering_message('SetMute', value ? 1 : 0)
68
- end
69
-
70
- def rendering_client
71
- @rendering_client ||= Savon.client endpoint: "http://#{self.ip}:#{PORT}#{RENDERING_ENDPOINT}", namespace: NAMESPACE
72
- end
73
-
74
- def send_rendering_message(name, value = nil)
75
- action = "#{RENDERING_XMLNS}##{name}"
76
- message = %Q{<u:#{name} xmlns:u="#{RENDERING_XMLNS}"><InstanceID>0</InstanceID><Channel>Master</Channel>}
77
-
78
- if value
79
- attribute = name.sub('Set', '')
80
- message += %Q{<Desired#{attribute}>#{value}</Desired#{attribute}></u:#{name}>}
81
- else
82
- message += %Q{</u:#{name}>}
83
- end
84
-
85
- rendering_client.call(name, soap_action: action, message: message)
86
- end
87
- end
88
- end
@@ -1,89 +0,0 @@
1
- module Sonos
2
- module Transport
3
- TRANSPORT_ENDPOINT = '/MediaRenderer/AVTransport/Control'
4
- TRANSPORT_XMLNS = 'urn:schemas-upnp-org:service:AVTransport:1'
5
-
6
- # Get information about the currently playing track.
7
- # @return [Hash] information about the current track.
8
- def now_playing
9
- response = send_transport_message('GetPositionInfo')
10
- body = response.body[:get_position_info_response]
11
- doc = Nokogiri::XML(body[:track_meta_data])
12
-
13
- {
14
- title: doc.xpath('//dc:title').inner_text,
15
- artist: doc.xpath('//dc:creator').inner_text,
16
- album: doc.xpath('//upnp:album').inner_text,
17
- playlist_position: body[:track],
18
- track_duration: body[:track_duration],
19
- current_position: body[:rel_time],
20
- uri: body[:track_uri],
21
- album_art: "http://#{self.ip}:#{PORT}#{doc.xpath('//upnp:albumArtURI').inner_text}"
22
- }
23
- end
24
-
25
- # Pause the currently playing track.
26
- def pause
27
- send_transport_message('Pause')
28
- end
29
-
30
- # Play the currently selected track or play a stream.
31
- # @param [String] optional uri of the track to play. Leaving this blank, plays the current track.
32
- def play(uri = nil)
33
- # Play a song from the uri
34
- set_av_transport_uri(uri) and return if uri
35
-
36
- # Play the currently selected track
37
- send_transport_message('Play')
38
- end
39
-
40
- # Stop playing.
41
- def stop
42
- send_transport_message('Stop')
43
- end
44
-
45
- # Play the next track.
46
- def next
47
- send_transport_message('Next')
48
- end
49
-
50
- # Play the previous track.
51
- def previous
52
- send_transport_message('Previous')
53
- end
54
-
55
- # Clear the queue
56
- def clear_queue
57
- send_transport_message('RemoveAllTracksFromQueue')
58
- end
59
-
60
- # Save queue
61
- def save_queue(title)
62
- name = 'SaveQueue'
63
- action = "#{TRANSPORT_XMLNS}##{name}"
64
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Title>#{title}</Title><ObjectID></ObjectID></u:#{name}>}
65
- transport_client.call(name, soap_action: action, message: message)
66
- end
67
-
68
- private
69
-
70
- # Play a stream.
71
- def set_av_transport_uri(uri)
72
- name = 'SetAVTransportURI'
73
- action = "#{TRANSPORT_XMLNS}##{name}"
74
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><CurrentURI>#{uri}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData></u:#{name}>}
75
- transport_client.call(name, soap_action: action, message: message)
76
- self.play
77
- end
78
-
79
- def transport_client
80
- @transport_client ||= Savon.client endpoint: "http://#{self.ip}:#{PORT}#{TRANSPORT_ENDPOINT}", namespace: NAMESPACE
81
- end
82
-
83
- def send_transport_message(name)
84
- action = "#{TRANSPORT_XMLNS}##{name}"
85
- message = %Q{<u:#{name} xmlns:u="#{TRANSPORT_XMLNS}"><InstanceID>0</InstanceID><Speed>1</Speed></u:#{name}>}
86
- transport_client.call(name, soap_action: action, message: message)
87
- end
88
- end
89
- end