playful 0.1.0.alpha.1

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.
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