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.
- checksums.yaml +7 -0
- data/.gemtest +0 -0
- data/.gitignore +19 -0
- data/.rspec +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +6 -0
- data/History.rdoc +3 -0
- data/LICENSE.rdoc +22 -0
- data/README.rdoc +194 -0
- data/Rakefile +20 -0
- data/features/control_point.feature +13 -0
- data/features/device.feature +22 -0
- data/features/device_discovery.feature +9 -0
- data/features/step_definitions/control_point_steps.rb +19 -0
- data/features/step_definitions/device_discovery_steps.rb +40 -0
- data/features/step_definitions/device_steps.rb +28 -0
- data/features/support/common.rb +9 -0
- data/features/support/env.rb +17 -0
- data/features/support/fake_upnp_device_collection.rb +108 -0
- data/features/support/world_extensions.rb +15 -0
- data/lib/core_ext/hash_patch.rb +5 -0
- data/lib/core_ext/socket_patch.rb +16 -0
- data/lib/core_ext/to_upnp_s.rb +65 -0
- data/lib/playful.rb +5 -0
- data/lib/playful/control_point.rb +175 -0
- data/lib/playful/control_point/base.rb +74 -0
- data/lib/playful/control_point/device.rb +511 -0
- data/lib/playful/control_point/error.rb +13 -0
- data/lib/playful/control_point/service.rb +404 -0
- data/lib/playful/device.rb +28 -0
- data/lib/playful/logger.rb +8 -0
- data/lib/playful/ssdp.rb +195 -0
- data/lib/playful/ssdp/broadcast_searcher.rb +114 -0
- data/lib/playful/ssdp/error.rb +6 -0
- data/lib/playful/ssdp/listener.rb +38 -0
- data/lib/playful/ssdp/multicast_connection.rb +112 -0
- data/lib/playful/ssdp/network_constants.rb +17 -0
- data/lib/playful/ssdp/notifier.rb +41 -0
- data/lib/playful/ssdp/searcher.rb +87 -0
- data/lib/playful/version.rb +3 -0
- data/lib/rack/upnp_control_point.rb +70 -0
- data/playful.gemspec +38 -0
- data/spec/spec_helper.rb +16 -0
- data/spec/support/search_responses.rb +134 -0
- data/spec/unit/core_ext/to_upnp_s_spec.rb +105 -0
- data/spec/unit/playful/control_point/device_spec.rb +7 -0
- data/spec/unit/playful/control_point_spec.rb +45 -0
- data/spec/unit/playful/ssdp/listener_spec.rb +29 -0
- data/spec/unit/playful/ssdp/multicast_connection_spec.rb +157 -0
- data/spec/unit/playful/ssdp/notifier_spec.rb +76 -0
- data/spec/unit/playful/ssdp/searcher_spec.rb +110 -0
- data/spec/unit/playful/ssdp_spec.rb +214 -0
- data/tasks/control_point.html +30 -0
- data/tasks/control_point.thor +43 -0
- data/tasks/search.thor +128 -0
- data/tasks/test_js/FABridge.js +1425 -0
- data/tasks/test_js/WebSocketMain.swf +807 -0
- data/tasks/test_js/swfobject.js +825 -0
- data/tasks/test_js/web_socket.js +1133 -0
- data/test/test_ssdp.rb +298 -0
- data/test/test_ssdp_notification.rb +74 -0
- data/test/test_ssdp_response.rb +31 -0
- data/test/test_ssdp_search.rb +23 -0
- 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
|