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