playful 0.1.0.alpha.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/.gemtest +0 -0
  3. data/.gitignore +19 -0
  4. data/.rspec +1 -0
  5. data/.travis.yml +5 -0
  6. data/Gemfile +6 -0
  7. data/History.rdoc +3 -0
  8. data/LICENSE.rdoc +22 -0
  9. data/README.rdoc +194 -0
  10. data/Rakefile +20 -0
  11. data/features/control_point.feature +13 -0
  12. data/features/device.feature +22 -0
  13. data/features/device_discovery.feature +9 -0
  14. data/features/step_definitions/control_point_steps.rb +19 -0
  15. data/features/step_definitions/device_discovery_steps.rb +40 -0
  16. data/features/step_definitions/device_steps.rb +28 -0
  17. data/features/support/common.rb +9 -0
  18. data/features/support/env.rb +17 -0
  19. data/features/support/fake_upnp_device_collection.rb +108 -0
  20. data/features/support/world_extensions.rb +15 -0
  21. data/lib/core_ext/hash_patch.rb +5 -0
  22. data/lib/core_ext/socket_patch.rb +16 -0
  23. data/lib/core_ext/to_upnp_s.rb +65 -0
  24. data/lib/playful.rb +5 -0
  25. data/lib/playful/control_point.rb +175 -0
  26. data/lib/playful/control_point/base.rb +74 -0
  27. data/lib/playful/control_point/device.rb +511 -0
  28. data/lib/playful/control_point/error.rb +13 -0
  29. data/lib/playful/control_point/service.rb +404 -0
  30. data/lib/playful/device.rb +28 -0
  31. data/lib/playful/logger.rb +8 -0
  32. data/lib/playful/ssdp.rb +195 -0
  33. data/lib/playful/ssdp/broadcast_searcher.rb +114 -0
  34. data/lib/playful/ssdp/error.rb +6 -0
  35. data/lib/playful/ssdp/listener.rb +38 -0
  36. data/lib/playful/ssdp/multicast_connection.rb +112 -0
  37. data/lib/playful/ssdp/network_constants.rb +17 -0
  38. data/lib/playful/ssdp/notifier.rb +41 -0
  39. data/lib/playful/ssdp/searcher.rb +87 -0
  40. data/lib/playful/version.rb +3 -0
  41. data/lib/rack/upnp_control_point.rb +70 -0
  42. data/playful.gemspec +38 -0
  43. data/spec/spec_helper.rb +16 -0
  44. data/spec/support/search_responses.rb +134 -0
  45. data/spec/unit/core_ext/to_upnp_s_spec.rb +105 -0
  46. data/spec/unit/playful/control_point/device_spec.rb +7 -0
  47. data/spec/unit/playful/control_point_spec.rb +45 -0
  48. data/spec/unit/playful/ssdp/listener_spec.rb +29 -0
  49. data/spec/unit/playful/ssdp/multicast_connection_spec.rb +157 -0
  50. data/spec/unit/playful/ssdp/notifier_spec.rb +76 -0
  51. data/spec/unit/playful/ssdp/searcher_spec.rb +110 -0
  52. data/spec/unit/playful/ssdp_spec.rb +214 -0
  53. data/tasks/control_point.html +30 -0
  54. data/tasks/control_point.thor +43 -0
  55. data/tasks/search.thor +128 -0
  56. data/tasks/test_js/FABridge.js +1425 -0
  57. data/tasks/test_js/WebSocketMain.swf +807 -0
  58. data/tasks/test_js/swfobject.js +825 -0
  59. data/tasks/test_js/web_socket.js +1133 -0
  60. data/test/test_ssdp.rb +298 -0
  61. data/test/test_ssdp_notification.rb +74 -0
  62. data/test/test_ssdp_response.rb +31 -0
  63. data/test/test_ssdp_search.rb +23 -0
  64. metadata +339 -0
@@ -0,0 +1,195 @@
1
+ require_relative '../core_ext/socket_patch'
2
+ require 'eventmachine'
3
+ require 'em-synchrony'
4
+ require_relative '../core_ext/to_upnp_s'
5
+ require_relative 'logger'
6
+ require_relative 'ssdp/error'
7
+ require_relative 'ssdp/network_constants'
8
+ require_relative 'ssdp/listener'
9
+ require_relative 'ssdp/searcher'
10
+ require_relative 'ssdp/notifier'
11
+
12
+ require_relative 'ssdp/broadcast_searcher'
13
+
14
+ module Playful
15
+
16
+ # This is the main class for doing SSDP stuff. You can have a look at child
17
+ # classes, but you'll probably want to just use these methods here.
18
+ #
19
+ # SSDP is "Simple Service Discovery Protocol", which lets you find and learn
20
+ # about UPnP devices on your network. Of the six "steps" of UPnP (given in
21
+ # the UPnP spec--that's counting step 0), SSDP is what provides step 1, or the
22
+ # "discovery" step.
23
+ #
24
+ # Before you can do anything with any of the UPnP devices on your network, you
25
+ # need to +search+ your network to see what devices are available. Once you've
26
+ # found what's available, you can then decide device(s) you'd like to control
27
+ # (that's where Control Points come in; take a look at Playful::ControlPoint).
28
+ # After searching, you should then +listen+ to the activity on your network.
29
+ # New devices on your network may come online (via +ssdp:alive+) and devices
30
+ # that you care about may go offline (via +ssdp:byebye+), in which case you
31
+ # probably shouldn't try to talk to them anymore.
32
+ #
33
+ # @todo Add docs for Playful::Device perspective.
34
+ class SSDP
35
+ include LogSwitch::Mixin
36
+ include NetworkConstants
37
+
38
+ # Opens a multicast UDP socket on 239.255.255.250:1900 and listens for
39
+ # alive and byebye notifications from devices.
40
+ #
41
+ # @param [Fixnum] ttl The TTL to use on the UDP socket.
42
+ #
43
+ # @return [Hash<Array>,Playful::SSDP::Listener] If the EventMachine reactor is
44
+ # _not_ running, it returns two key/value pairs--one for
45
+ # alive_notifications, one for byebye_notifications. If the reactor _is_
46
+ # running, it returns a Playful::SSDP::Listener so that that object can be
47
+ # used however desired. The latter method is used in Playful::ControlPoints
48
+ # so that an object of that type can keep track of devices it cares about.
49
+ def self.listen(ttl=TTL)
50
+ alive_notifications = Set.new
51
+ byebye_notifications = Set.new
52
+
53
+ listener = proc do
54
+ l = EM.open_datagram_socket(MULTICAST_IP, MULTICAST_PORT,
55
+ Playful::SSDP::Listener, ttl)
56
+ i = 0
57
+ EM.add_periodic_timer(5) { i += 5; Playful.log "Listening for #{i}\n" }
58
+ l
59
+ end
60
+
61
+ if EM.reactor_running?
62
+ return listener.call
63
+ else
64
+ EM.synchrony do
65
+ l = listener.call
66
+
67
+ alive_getter = Proc.new do |notification|
68
+ alive_notifications << notification
69
+ EM.next_tick { l.alive_notifications.pop(&live_getter) }
70
+ end
71
+ l.alive_notifications.pop(&alive_getter)
72
+
73
+ byebye_getter = Proc.new do |notification|
74
+ byebye_notifications << notification
75
+ EM.next_tick { l.byebye_notifications.pop(&byebye_getter) }
76
+ end
77
+ l.byebye_notifications.pop(&byebye_getter)
78
+
79
+ trap_signals
80
+ end
81
+ end
82
+
83
+ {
84
+ alive_notifications: alive_notifications.to_a.flatten,
85
+ byebye_notifications: byebye_notifications.to_a.flatten
86
+ }
87
+ end
88
+
89
+ # Opens a UDP socket on 0.0.0.0, on an ephemeral port, has Playful::SSDP::Searcher
90
+ # build and send the search request, then receives the responses. The search
91
+ # will stop after +response_wait_time+.
92
+ #
93
+ # @param [String] search_target
94
+ #
95
+ # @param [Hash] options
96
+ #
97
+ # @option options [Fixnum] response_wait_time
98
+ # @option options [Fixnum] ttl
99
+ # @option options [Fixnum] m_search_count
100
+ # @option options [Boolean] do_broadcast_search Tells the search call to also send
101
+ # a M-SEARCH over 255.255.255.255. This is *NOT* part of the UPnP spec;
102
+ # it's merely a hack for working with some types of devices that don't
103
+ # properly implement the UPnP spec.
104
+ #
105
+ # @return [Array<Hash>,Playful::SSDP::Searcher] Returns a Hash that represents
106
+ # the headers from the M-SEARCH response. Each one of these can be passed
107
+ # in to Playful::ControlPoint::Device.new to download the device's
108
+ # description file, parse it, and interact with the device's devices
109
+ # and/or services. If the reactor is already running this will return a
110
+ # a Playful::SSDP::Searcher which will make its accessors available so you
111
+ # can get responses in real time.
112
+ def self.search(search_target=:all, options = {})
113
+ response_wait_time = options[:response_wait_time] || 5
114
+ ttl = options[:ttl] || TTL
115
+ do_broadcast_search = options[:do_broadcast_search]
116
+
117
+ searcher_options = options
118
+ searcher_options.delete :do_broadcast_search
119
+
120
+ responses = []
121
+ search_target = search_target.to_upnp_s
122
+
123
+ multicast_searcher = proc do
124
+ EM.open_datagram_socket('0.0.0.0', 0, Playful::SSDP::Searcher,
125
+ search_target, searcher_options)
126
+ end
127
+
128
+ broadcast_searcher = proc do
129
+ EM.open_datagram_socket('0.0.0.0', 0, Playful::SSDP::BroadcastSearcher,
130
+ search_target, response_wait_time, ttl)
131
+ end
132
+
133
+ if EM.reactor_running?
134
+ return multicast_searcher.call
135
+ else
136
+ EM.synchrony do
137
+ ms = multicast_searcher.call
138
+
139
+ ms.discovery_responses.subscribe do |notification|
140
+ responses << notification
141
+ end
142
+
143
+ if do_broadcast_search
144
+ bs = broadcast_searcher.call
145
+
146
+ bs.discovery_responses.subscribe do |notification|
147
+ responses << notification
148
+ end
149
+ end
150
+
151
+ EM.add_timer(response_wait_time) { EM.stop }
152
+ trap_signals
153
+ end
154
+ end
155
+
156
+ responses.flatten
157
+ end
158
+
159
+ # @todo This is for Playful::Devices, which aren't implemented yet, and thus
160
+ # this may not be working.
161
+ def self.notify(notification_type, usn, ddf_url, valid_for_duration=1800)
162
+ responses = []
163
+ notification_type = notification_type.to_upnp_s
164
+
165
+ EM.synchrony do
166
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
167
+ EM.add_shutdown_hook { responses = s.discovery_responses }
168
+
169
+ EM.add_periodic_timer(valid_for_duration) do
170
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
171
+ end
172
+
173
+ trap_signals
174
+ end
175
+
176
+ responses
177
+ end
178
+
179
+ # @todo This is for Playful::Devices, which aren't implemented yet, and thus
180
+ # this may not be working.
181
+ def self.send_notification(notification_type, usn, ddf_url, valid_for_duration)
182
+ EM.open_datagram_socket('0.0.0.0', 0, Playful::SSDP::Notifier, notification_type,
183
+ usn, ddf_url, valid_for_duration)
184
+ end
185
+
186
+ private
187
+
188
+ # Traps INT, TERM, and HUP signals and stops the reactor.
189
+ def self.trap_signals
190
+ trap('INT') { EM.stop }
191
+ trap('TERM') { EM.stop }
192
+ trap('HUP') { EM.stop } if RUBY_PLATFORM !~ /mswin|mingw/
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,114 @@
1
+ require_relative '../../core_ext/socket_patch'
2
+ require_relative '../logger'
3
+ require_relative 'network_constants'
4
+ require 'ipaddr'
5
+ require 'socket'
6
+ require 'eventmachine'
7
+
8
+
9
+ # TODO: DRY this up!! (it's mostly the same as Playful::SSDP::MulticastConnection)
10
+ module Playful
11
+ class SSDP
12
+ class BroadcastSearcher < EventMachine::Connection
13
+ include LogSwitch::Mixin
14
+ include EventMachine::Deferrable
15
+ include Playful::SSDP::NetworkConstants
16
+
17
+ # @return [Array] The list of responses from the current discovery request.
18
+ attr_reader :discovery_responses
19
+
20
+ attr_reader :available_responses
21
+ attr_reader :byebye_responses
22
+
23
+ def initialize(search_target, response_wait_time, ttl=TTL)
24
+ @ttl = ttl
25
+ @discovery_responses = []
26
+ @alive_notifications = []
27
+ @byebye_notifications = []
28
+
29
+ setup_broadcast_socket
30
+
31
+ @search = m_search(search_target, response_wait_time)
32
+ end
33
+
34
+ def post_init
35
+ if send_datagram(@search, BROADCAST_IP, MULTICAST_PORT) > 0
36
+ log "Sent broadcast datagram search:\n#{@search}"
37
+ end
38
+ end
39
+
40
+ def m_search(search_target, response_wait_time)
41
+ <<-MSEARCH
42
+ M-SEARCH * HTTP/1.1\r
43
+ HOST: #{MULTICAST_IP}:#{MULTICAST_PORT}\r
44
+ MAN: "ssdp:discover"\r
45
+ MX: #{response_wait_time}\r
46
+ ST: #{search_target}\r
47
+ \r
48
+ MSEARCH
49
+ end
50
+
51
+ # Gets the IP and port from the peer that just sent data.
52
+ #
53
+ # @return [Array<String,Fixnum>] The IP and port.
54
+ def peer_info
55
+ peer_bytes = get_peername[2, 6].unpack('nC4')
56
+ port = peer_bytes.first.to_i
57
+ ip = peer_bytes[1, 4].join('.')
58
+
59
+ [ip, port]
60
+ end
61
+
62
+ def receive_data(response)
63
+ ip, port = peer_info
64
+ log "Response from #{ip}:#{port}:\n#{response}\n"
65
+ parsed_response = parse(response)
66
+
67
+ if parsed_response.has_key? :nts
68
+ if parsed_response[:nts] == 'ssdp:alive'
69
+ @alive_notifications << parsed_response
70
+ elsif parsed_response[:nts] == 'ssdp:bye-bye'
71
+ @byebye_notifications << parsed_response
72
+ else
73
+ raise "Unknown NTS value: #{parsed_response[:nts]}"
74
+ end
75
+ else
76
+ @discovery_responses << parsed_response
77
+ end
78
+ end
79
+
80
+ # Converts the headers to a set of key-value pairs.
81
+ #
82
+ # @param [String] data The data to convert.
83
+ # @return [Hash] The converted data. Returns an empty Hash if it didn't
84
+ # know how to parse.
85
+ def parse(data)
86
+ new_data = {}
87
+
88
+ unless data =~ /\n/
89
+ log 'Received response as a single-line String. Discarding.'
90
+ log "Bad response looked like:\n#{data}"
91
+ return new_data
92
+ end
93
+
94
+ data.each_line do |line|
95
+ line =~ /(\S*):(.*)/
96
+
97
+ unless $1.nil?
98
+ key = $1
99
+ value = $2
100
+ key = key.gsub('-', '_').downcase.to_sym
101
+ new_data[key] = value.strip
102
+ end
103
+ end
104
+
105
+ new_data
106
+ end
107
+
108
+ # Sets Socket options to allow for brodcasting.
109
+ def setup_broadcast_socket
110
+ set_sock_opt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,6 @@
1
+ module Playful
2
+ class SSDP
3
+ class Error < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ require_relative 'multicast_connection'
2
+
3
+
4
+ class Playful::SSDP::Listener < Playful::SSDP::MulticastConnection
5
+ include LogSwitch::Mixin
6
+
7
+ # @return [EventMachine::Channel] Provides subscribers with notifications
8
+ # from devices that have come online (sent +ssdp:alive+ notifications).
9
+ attr_reader :alive_notifications
10
+
11
+ # @return [EventMachine::Channel] Provides subscribers with notifications
12
+ # from devices that have gone offline (sent +ssd:byebye+ notifications).
13
+ attr_reader :byebye_notifications
14
+
15
+ # This is the callback called by EventMachine when it receives data on the
16
+ # socket that's been opened for this connection. In this case, the method
17
+ # parses the SSDP notifications into Hashes and adds them to the
18
+ # appropriate EventMachine::Channel (provided as accessor methods). This
19
+ # effectively means that in each Channel, you get a Hash that represents
20
+ # the headers for each notification that comes in on the socket.
21
+ #
22
+ # @param [String] response The data received on this connection's socket.
23
+ def receive_data(response)
24
+ ip, port = peer_info
25
+ log "Response from #{ip}:#{port}:\n#{response}\n"
26
+ parsed_response = parse(response)
27
+
28
+ return unless parsed_response.has_key? :nts
29
+
30
+ if parsed_response[:nts] == 'ssdp:alive'
31
+ @alive_notifications << parsed_response
32
+ elsif parsed_response[:nts] == 'ssdp:byebye'
33
+ @byebye_notifications << parsed_response
34
+ else
35
+ raise "Unknown NTS value: #{parsed_response[:nts]}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,112 @@
1
+ require_relative '../../core_ext/socket_patch'
2
+ require_relative 'network_constants'
3
+ require_relative '../logger'
4
+ require_relative 'error'
5
+ require 'ipaddr'
6
+ require 'socket'
7
+ require 'eventmachine'
8
+ require 'em-synchrony'
9
+
10
+
11
+ module Playful
12
+ class SSDP
13
+ class MulticastConnection < EventMachine::Connection
14
+ include Playful::SSDP::NetworkConstants
15
+ include LogSwitch::Mixin
16
+
17
+ # @param [Fixnum] ttl The TTL value to use when opening the UDP socket
18
+ # required for SSDP actions.
19
+ def initialize(ttl=TTL)
20
+ @ttl = ttl
21
+
22
+ @discovery_responses = EM::Channel.new
23
+ @alive_notifications = EM::Channel.new
24
+ @byebye_notifications = EM::Channel.new
25
+
26
+ setup_multicast_socket
27
+ end
28
+
29
+ # Gets the IP and port from the peer that just sent data.
30
+ #
31
+ # @return [Array<String,Fixnum>] The IP and port.
32
+ def peer_info
33
+ peer_bytes = get_peername[2, 6].unpack('nC4')
34
+ port = peer_bytes.first.to_i
35
+ ip = peer_bytes[1, 4].join('.')
36
+
37
+ [ip, port]
38
+ end
39
+
40
+ # Converts the headers to a set of key-value pairs.
41
+ #
42
+ # @param [String] data The data to convert.
43
+ # @return [Hash] The converted data. Returns an empty Hash if it didn't
44
+ # know how to parse.
45
+ def parse(data)
46
+ new_data = {}
47
+
48
+ unless data =~ /\n/
49
+ log 'Received response as a single-line String. Discarding.'
50
+ log "Bad response looked like:\n#{data}"
51
+ return new_data
52
+ end
53
+
54
+ data.each_line do |line|
55
+ line =~ /(\S+):(.*)/
56
+
57
+ unless $1.nil?
58
+ key = $1
59
+ value = $2
60
+ key = key.gsub('-', '_').downcase.to_sym
61
+ new_data[key] = value.strip
62
+ end
63
+ end
64
+
65
+ new_data
66
+ end
67
+
68
+ # Sets Socket options to allow for multicasting. If ENV["RUBY_UPNP_ENV"] is
69
+ # equal to "testing", then it doesn't turn off multicast looping.
70
+ def setup_multicast_socket
71
+ set_membership(IPAddr.new(MULTICAST_IP).hton +
72
+ IPAddr.new('0.0.0.0').hton)
73
+ set_multicast_ttl(@ttl)
74
+ set_ttl(@ttl)
75
+
76
+ unless ENV['RUBY_UPNP_ENV'] == 'testing'
77
+ switch_multicast_loop :off
78
+ end
79
+ end
80
+
81
+ # @param [String] membership The network byte ordered String that represents
82
+ # the IP(s) that should join the membership group.
83
+ def set_membership(membership)
84
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership)
85
+ end
86
+
87
+ # @param [Fixnum] ttl TTL to set IP_MULTICAST_TTL to.
88
+ def set_multicast_ttl(ttl)
89
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL,
90
+ [ttl].pack('i'))
91
+ end
92
+
93
+ # @param [Fixnum] ttl TTL to set IP_TTL to.
94
+ def set_ttl(ttl)
95
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_TTL, [ttl].pack('i'))
96
+ end
97
+
98
+ # @param [Symbol] on_off Turn on/off multicast looping. Supply :on or :off.
99
+ def switch_multicast_loop(on_off)
100
+ hex_value = case on_off
101
+ when :on then "\001"
102
+ when "\001" then "\001"
103
+ when :off then "\000"
104
+ when "\000" then "\000"
105
+ else raise SSDP::Error, "Can't switch IP_MULTICAST_LOOP to '#{on_off}'"
106
+ end
107
+
108
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_LOOP, hex_value)
109
+ end
110
+ end
111
+ end
112
+ end