sonos 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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