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,13 @@
1
+ module Playful
2
+ class ControlPoint
3
+ class Error < StandardError
4
+ #
5
+ end
6
+
7
+ # Indicates an error occurred when performing a UPnP action while controlling
8
+ # a device. See section 3.2 of the UPnP spec.
9
+ class ActionError < StandardError
10
+ #
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,404 @@
1
+ require 'savon'
2
+ require_relative 'base'
3
+ require_relative 'error'
4
+ require_relative '../../core_ext/hash_patch'
5
+
6
+ require 'em-http'
7
+ HTTPI.adapter = :em_http
8
+
9
+
10
+ module Playful
11
+ class ControlPoint
12
+
13
+ # An object of this type functions as somewhat of a proxy to a UPnP device's
14
+ # service. The object sort of defines itself when you call #fetch; it
15
+ # gets the description file from the device, parses it, populates its
16
+ # attributes (as accessors) and defines singleton methods from the list of
17
+ # actions that the service defines.
18
+ #
19
+ # After the fetch is done, you can call Ruby methods on the service and
20
+ # expect a Ruby Hash back as a return value. The methods will look just
21
+ # the SOAP actions and will always return a Hash, where key/value pairs are
22
+ # converted from the SOAP response; values are converted to the according
23
+ # Ruby type based on <dataType> in the <serviceStateTable>.
24
+ #
25
+ # Types map like:
26
+ # * Integer
27
+ # * ui1
28
+ # * ui2
29
+ # * ui4
30
+ # * i1
31
+ # * i2
32
+ # * i4
33
+ # * int
34
+ # * Float
35
+ # * r4
36
+ # * r8
37
+ # * number
38
+ # * fixed.14.4
39
+ # * float
40
+ # * String
41
+ # * char
42
+ # * string
43
+ # * uuid
44
+ # * TrueClass
45
+ # * 1
46
+ # * true
47
+ # * yes
48
+ # * FalseClass
49
+ # * 0
50
+ # * false
51
+ # * no
52
+ #
53
+ # @example No "in" params
54
+ # my_service.GetSystemUpdateID # => { "Id" => 1 }
55
+ #
56
+ class Service < Base
57
+ include EventMachine::Deferrable
58
+ include LogSwitch::Mixin
59
+
60
+ #vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
61
+ # Passed in by +service_list_info+
62
+ #
63
+
64
+ # @return [String] UPnP service type, including URN.
65
+ attr_reader :service_type
66
+
67
+ # @return [String] Service identifier, unique within this service's devices.
68
+ attr_reader :service_id
69
+
70
+ # @return [URI::HTTP] Service description URL.
71
+ attr_reader :scpd_url
72
+
73
+ # @return [URI::HTTP] Control URL.
74
+ attr_reader :control_url
75
+
76
+ # @return [URI::HTTP] Eventing URL.
77
+ attr_reader :event_sub_url
78
+
79
+ #
80
+ # DONE +service_list_info+
81
+ #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
82
+
83
+ #vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
84
+ # Determined by service description file
85
+ #
86
+
87
+ # @return [String]
88
+ attr_reader :xmlns
89
+
90
+ # @return [String]
91
+ attr_reader :spec_version
92
+
93
+ # @return [Array<Hash>]
94
+ attr_reader :action_list
95
+
96
+ # Probably don't need to keep this long-term; just adding for testing.
97
+ attr_reader :service_state_table
98
+
99
+ #
100
+ # DONE description
101
+ #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
102
+
103
+ # @return [Hash] The whole description... just in case.
104
+ attr_reader :description
105
+
106
+ # @param [String] device_base_url URL given (or otherwise determined) by
107
+ # <URLBase> from the device that owns the service.
108
+ # @param [Hash] service_list_info Info given in the <serviceList> section
109
+ # of the device description.
110
+ def initialize(device_base_url, service_list_info)
111
+ @service_list_info = service_list_info
112
+ @action_list = []
113
+ @xmlns = ''
114
+ extract_service_list_info(device_base_url)
115
+ configure_savon
116
+ end
117
+
118
+ # Fetches the service description file, parses it, extracts attributes
119
+ # into accessors, and defines Ruby methods from SOAP actions. Since this
120
+ # is a long-ish process, this is done using EventMachine Deferrable
121
+ # behavior.
122
+ def fetch
123
+ if @scpd_url.empty?
124
+ log 'NO SCPDURL to get the service description from. Returning.'
125
+ set_deferred_success self
126
+ return
127
+ end
128
+
129
+ description_getter = EventMachine::DefaultDeferrable.new
130
+ log "Fetching service description with #{description_getter.object_id}"
131
+ get_description(@scpd_url, description_getter)
132
+
133
+ description_getter.errback do
134
+ msg = 'Failed getting service description.'
135
+ log "#{msg}", :error
136
+ # @todo Should this return self? or should it succeed?
137
+ set_deferred_status(:failed, msg)
138
+
139
+ if ControlPoint.raise_on_remote_error
140
+ raise ControlPoint::Error, msg
141
+ end
142
+ end
143
+
144
+ description_getter.callback do |description|
145
+ log "Service description received for #{description_getter.object_id}."
146
+ @description = description
147
+ @xmlns = @description[:scpd][:@xmlns]
148
+ extract_spec_version
149
+ extract_service_state_table
150
+
151
+ if @description[:scpd][:actionList]
152
+ log "Defining methods from action_list using [#{description_getter.object_id}]"
153
+ define_methods_from_actions(@description[:scpd][:actionList][:action])
154
+ end
155
+
156
+ set_deferred_status(:succeeded, self)
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ # Extracts all of the basic service information from the information
163
+ # handed over from the device description about the service. The actual
164
+ # service description info gathering is *not* done here.
165
+ #
166
+ # @param [String] device_base_url The URLBase from the device. Used to
167
+ # build absolute URLs for the service.
168
+ def extract_service_list_info(device_base_url)
169
+ @control_url = if @service_list_info[:controlURL]
170
+ build_url(device_base_url, @service_list_info[:controlURL])
171
+ else
172
+ log 'Required controlURL attribute is blank.'
173
+ ''
174
+ end
175
+
176
+ @event_sub_url = if @service_list_info[:eventSubURL]
177
+ build_url(device_base_url, @service_list_info[:eventSubURL])
178
+ else
179
+ log 'Required eventSubURL attribute is blank.'
180
+ ''
181
+ end
182
+
183
+ @service_type = @service_list_info[:serviceType]
184
+ @service_id = @service_list_info[:serviceId]
185
+
186
+ @scpd_url = if @service_list_info[:SCPDURL]
187
+ build_url(device_base_url, @service_list_info[:SCPDURL])
188
+ else
189
+ log 'Required SCPDURL attribute is blank.'
190
+ ''
191
+ end
192
+ end
193
+
194
+ def extract_spec_version
195
+ "#{@description[:scpd][:specVersion][:major]}.#{@description[:scpd][:specVersion][:minor]}"
196
+ end
197
+
198
+ def extract_service_state_table
199
+ @service_state_table = if @description[:scpd][:serviceStateTable].is_a? Hash
200
+ @description[:scpd][:serviceStateTable][:stateVariable]
201
+ elsif @description[:scpd][:serviceStateTable].is_a? Array
202
+ @description[:scpd][:serviceStateTable].map do |state|
203
+ state[:stateVariable]
204
+ end
205
+ end
206
+ end
207
+
208
+ # Determines if <actionList> from the service description contains a
209
+ # single action or multiple actions and delegates to create Ruby methods
210
+ # accordingly.
211
+ #
212
+ # @param [Hash,Array] action_list The value from <scpd><actionList><action>
213
+ # from the service description.
214
+ def define_methods_from_actions(action_list)
215
+ log "Defining methods; action list: #{action_list}"
216
+
217
+ if action_list.is_a? Hash
218
+ @action_list << action_list
219
+ define_method_from_action(action_list[:name].to_sym,
220
+ action_list[:argumentList][:argument])
221
+ elsif action_list.is_a? Array
222
+ action_list.each do |action|
223
+ =begin
224
+ in_args_count = action[:argumentList][:argument].find_all do |arg|
225
+ arg[:direction] == 'in'
226
+ end.size
227
+ =end
228
+ @action_list << action
229
+ args = action[:argumentList] ? action[:argumentList][:argument] : {}
230
+ define_method_from_action(action[:name].to_sym, args)
231
+ end
232
+ else
233
+ log "Got actionList that's not an Array or Hash."
234
+ end
235
+ end
236
+
237
+ # Defines a Ruby method from the SOAP action.
238
+ #
239
+ # All resulting methods will either take no arguments or a single Hash as
240
+ # an argument, whatever the SOAP action describes as its "in" arguments.
241
+ # If the action describes "in" arguments, then you must provide a Hash
242
+ # where keys are argument names and values are the values for those
243
+ # arguments.
244
+ #
245
+ # For example, the GetCurrentConnectionInfo action from the
246
+ # "ConnectionManager:1" service describes an "in" argument named
247
+ # "ConnectionID" whose dataType is "i4". To call this action via the
248
+ # Ruby method, it'd look like:
249
+ #
250
+ # connection_manager.GetCurrentConnectionInfo({ "ConnectionID" => 42 })
251
+ #
252
+ # There is currently no type checking for these "in" arguments.
253
+ #
254
+ # Calling that Ruby method will, in turn, call the SOAP action by the same
255
+ # name, with the body set to:
256
+ #
257
+ # <connectionID>42</connectionID>
258
+ #
259
+ # The UPnP device providing the service will reply with a SOAP
260
+ # response--either a fault or with good data--and that will get converted
261
+ # to a Hash. This Hash will contain key/value pairs defined by the "out"
262
+ # argument names and values. Each value is converted to an associated
263
+ # Ruby type, determined from the serviceStateTable. If no return data
264
+ # is relevant for the request you made, some devices may return an empty
265
+ # body.
266
+ #
267
+ # @param [Symbol] action_name The extracted value from <actionList>
268
+ # <action><name> from the spec.
269
+ # @param [Hash,Array] argument_info The extracted values from
270
+ # <actionList><action><argumentList><argument> from the spec.
271
+ def define_method_from_action(action_name, argument_info)
272
+ # Do this here because, for some reason, @service_type is out of scope
273
+ # in the #request block below.
274
+ st = @service_type
275
+
276
+ define_singleton_method(action_name) do |params|
277
+ begin
278
+ response = @soap_client.call(action_name.to_s) do |locals|
279
+ locals.message_tags 'xmlns:u' => @service_type
280
+ locals.soap_action "#{st}##{action_name}"
281
+ #soap.namespaces["s:encodingStyle"] = "http://schemas.xmlsoap.org/soap/encoding/"
282
+
283
+ unless params.nil?
284
+ raise ArgumentError,
285
+ 'Method only accepts Hashes' unless params.is_a? Hash
286
+ soap.body = params.symbolize_keys!
287
+ end
288
+ end
289
+ rescue Savon::SOAPFault, Savon::HTTPError => ex
290
+ hash = xml_parser.parse(ex.http.body)
291
+ msg = <<-MSG
292
+ SOAP request failure!
293
+ HTTP response code: #{ex.http.code}
294
+ HTTP headers: #{ex.http.headers}
295
+ HTTP body: #{ex.http.body}
296
+ HTTP body as Hash: #{hash}
297
+ MSG
298
+
299
+ log "#{msg}"
300
+ raise(ActionError, msg) if ControlPoint.raise_on_remote_error
301
+
302
+ if hash.empty?
303
+ return ex.http.body
304
+ else
305
+ return hash[:Envelope][:Body]
306
+ end
307
+ end
308
+
309
+ return_value = if argument_info.is_a?(Hash) && argument_info[:direction] == 'out'
310
+ return_ruby_from_soap(action_name, response, argument_info)
311
+ elsif argument_info.is_a? Array
312
+ argument_info.map do |arg|
313
+ if arg[:direction] == 'out'
314
+ return_ruby_from_soap(action_name, response, arg)
315
+ end
316
+ end
317
+ else
318
+ log "No args with direction 'out'"
319
+ {}
320
+ end
321
+
322
+ return_value
323
+ end
324
+
325
+ log "Defined method: #{action_name}"
326
+ end
327
+
328
+ # Uses the serviceStateTable to look up the output from the SOAP response
329
+ # for the given action, then converts it to the according Ruby data type.
330
+ #
331
+ # @param [String] action_name The name of the SOAP action that was called
332
+ # for which this will get the response from.
333
+ # @param [Savon::SOAP::Response] soap_response The response from making
334
+ # the SOAP call.
335
+ # @param [Hash] out_argument The Hash that tells out the "out" argument
336
+ # which tells what data type to return.
337
+ # @return [Hash] Key will be the "out" argument name as a Symbol and the
338
+ # key will be the value as its converted Ruby type.
339
+ def return_ruby_from_soap(action_name, soap_response, out_argument)
340
+ out_arg_name = out_argument[:name]
341
+ #puts "out arg name: #{out_arg_name}"
342
+
343
+ related_state_variable = out_argument[:relatedStateVariable]
344
+ #puts "related state var: #{related_state_variable}"
345
+
346
+ state_variable = @service_state_table.find do |state_var_hash|
347
+ state_var_hash[:name] == related_state_variable
348
+ end
349
+
350
+ #puts "state var: #{state_variable}"
351
+
352
+ int_types = %w[ui1 ui2 ui4 i1 i2 i4 int]
353
+ float_types = %w[r4 r8 number fixed.14.4 float]
354
+ string_types = %w[char string uuid]
355
+ true_types = %w[1 true yes]
356
+ false_types = %w[0 false no]
357
+
358
+ if soap_response.success? && soap_response.to_xml.empty?
359
+ log 'Got successful but empty soap response!'
360
+ return {}
361
+ end
362
+
363
+ if int_types.include? state_variable[:dataType]
364
+ {
365
+ out_arg_name.to_sym => soap_response.
366
+ hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_i
367
+ }
368
+ elsif string_types.include? state_variable[:dataType]
369
+ {
370
+ out_arg_name.to_sym => soap_response.
371
+ hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_s
372
+ }
373
+ elsif float_types.include? state_variable[:dataType]
374
+ {
375
+ out_arg_name.to_sym => soap_response.
376
+ hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_f
377
+ }
378
+ elsif true_types.include? state_variable[:dataType]
379
+ {
380
+ out_arg_name.to_sym => true
381
+ }
382
+ elsif false_types.include? state_variable[:dataType]
383
+ {
384
+ out_arg_name.to_sym => false
385
+ }
386
+ else
387
+ log "Got SOAP response that I dunno what to do with: #{soap_response.hash}"
388
+ end
389
+ end
390
+
391
+ def configure_savon
392
+ @soap_client = Savon.client do |globals|
393
+ globals.endpoint @control_url
394
+ globals.namespace @service_type
395
+
396
+ globals.convert_request_keys_to :camelcase
397
+ globals.namespace_identifier :u
398
+ globals.env_namespace :s
399
+ globals.log true
400
+ end
401
+ end
402
+ end
403
+ end
404
+ end
@@ -0,0 +1,28 @@
1
+ require 'thin'
2
+ require 'em-synchrony'
3
+ require 'rack'
4
+ require 'rack/lobster'
5
+
6
+ module Playful
7
+ class Device
8
+
9
+ # Multicasts discovery messages to advertise its root device, any embedded
10
+ # devices, and any services.
11
+ def start
12
+ EM.synchrony do
13
+ web_server = Thin::Server.start('0.0.0.0', 3000) do
14
+ use Rack::CommonLogger
15
+ use Rack::ShowExceptions
16
+
17
+ map '/presentation' do
18
+ use Rack::Lint
19
+ run Rack::Lobster.new
20
+ end
21
+ end
22
+
23
+ # Do advertisement
24
+ # Listen for subscribers
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,8 @@
1
+ require 'log_switch'
2
+
3
+
4
+ module Playful
5
+ extend LogSwitch
6
+ end
7
+
8
+ Playful.log_class_name = true