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 +24 -17
- data/Readme.markdown +22 -7
- data/bin/sonos +7 -0
- data/lib/sonos.rb +5 -0
- data/lib/sonos/cli.rb +20 -0
- data/lib/sonos/discovery.rb +73 -0
- data/lib/sonos/endpoint.rb +9 -0
- data/lib/sonos/endpoint/content_directory.rb +53 -0
- data/lib/sonos/endpoint/device.rb +23 -0
- data/lib/sonos/endpoint/rendering.rb +99 -0
- data/lib/sonos/endpoint/transport.rb +87 -0
- data/lib/sonos/speaker.rb +9 -9
- data/lib/sonos/topology.rb +35 -0
- data/lib/sonos/version.rb +1 -1
- data/sonos.gemspec +3 -2
- metadata +32 -8
- data/lib/sonos/content_directory.rb +0 -55
- data/lib/sonos/device.rb +0 -25
- data/lib/sonos/rendering.rb +0 -88
- data/lib/sonos/transport.rb +0 -89
data/Changelog.markdown
CHANGED
@@ -1,28 +1,35 @@
|
|
1
|
-
### Version 0.
|
1
|
+
### Version 0.2.0 — December 24, 2012
|
2
2
|
|
3
|
-
*
|
4
|
-
*
|
5
|
-
*
|
6
|
-
*
|
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
|
-
*
|
11
|
-
*
|
12
|
-
*
|
13
|
-
*
|
14
|
-
*
|
15
|
-
*
|
16
|
-
*
|
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
|
-
*
|
24
|
-
*
|
25
|
-
*
|
26
|
-
*
|
27
|
-
*
|
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.
|
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
|
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
|
-
*
|
58
|
-
*
|
59
|
-
*
|
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
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,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 '
|
3
|
-
require 'sonos/
|
4
|
-
require 'sonos/
|
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}
|
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
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.
|
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
|
-
|
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/
|
45
|
-
- lib/sonos/
|
46
|
-
- lib/sonos/
|
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/
|
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: -
|
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: -
|
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
|
data/lib/sonos/rendering.rb
DELETED
@@ -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
|
data/lib/sonos/transport.rb
DELETED
@@ -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
|