UPnP 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,11 @@
1
+ require 'UPnP'
2
+
3
+ ##
4
+ # Namespace for UPnP control points
5
+
6
+ module UPnP::Control
7
+ end
8
+
9
+ require 'UPnP/control/device'
10
+ require 'UPnP/control/service'
11
+
@@ -0,0 +1,238 @@
1
+ require 'open-uri'
2
+ require 'rexml/document'
3
+ require 'uri'
4
+
5
+ require 'UPnP'
6
+ require 'UPnP/SSDP'
7
+ require 'UPnP/control/service'
8
+
9
+ ##
10
+ # A device on a UPnP control point.
11
+ #
12
+ # A Device holds information about a device and its associated sub-devices and
13
+ # services.
14
+ #
15
+ # Devices should be created using ::create instead of ::new. This allows a
16
+ # subclass of Device to be automatically instantiated.
17
+ #
18
+ # When creating a device subclass, it must have a URN_version constant set to
19
+ # the schema URN for that version.
20
+ #
21
+ # For details on UPnP devices, see http://www.upnp.org/resources/documents.asp
22
+
23
+ class UPnP::Control::Device
24
+
25
+ ##
26
+ # All embedded devices
27
+
28
+ attr_reader :devices
29
+
30
+ ##
31
+ # Short device description for the end user
32
+
33
+ attr_reader :friendly_name
34
+
35
+ ##
36
+ # Manufacturer's name
37
+
38
+ attr_reader :manufacturer
39
+
40
+ ##
41
+ # Manufacturer's web site
42
+
43
+ attr_reader :manufacturer_url
44
+
45
+ ##
46
+ # Long model description for the end user
47
+
48
+ attr_reader :model_description
49
+
50
+ ##
51
+ # Model name
52
+
53
+ attr_reader :model_name
54
+
55
+ ##
56
+ # Model number
57
+
58
+ attr_reader :model_number
59
+
60
+ ##
61
+ # Web site for model
62
+
63
+ attr_reader :model_url
64
+
65
+ ##
66
+ # Unique Device Name (UDN), a universally unique identifier for the device
67
+ # whether root or embedded.
68
+
69
+ attr_reader :name
70
+
71
+ ##
72
+ # URL for device control via a browser
73
+
74
+ attr_reader :presentation_url
75
+
76
+ ##
77
+ # Serial number
78
+
79
+ attr_reader :serial_number
80
+
81
+ ##
82
+ # All services provided by this device and its sub-devices.
83
+
84
+ attr_reader :services
85
+
86
+ ##
87
+ # Devices embedded directly into this device.
88
+
89
+ attr_reader :sub_devices
90
+
91
+ ##
92
+ # Services provided directly by this device.
93
+
94
+ attr_reader :sub_services
95
+
96
+ ##
97
+ # Type of UPnP device (URN)
98
+
99
+ attr_reader :type
100
+
101
+ ##
102
+ # Universal Product Code
103
+
104
+ attr_reader :upc
105
+
106
+ ##
107
+ # Base URL for this device
108
+
109
+ attr_reader :url
110
+
111
+ ##
112
+ # If a concrete class exists for +description+ it is used to instantiate the
113
+ # device, otherwise a concrete class is created subclassing Device and
114
+ # used.
115
+
116
+ def self.create(device_url)
117
+ description = REXML::Document.new open(device_url)
118
+ url = device_url + '/'
119
+
120
+ type = description.elements['root/device/deviceType'].text.strip
121
+ klass_name = type.sub(/#{UPnP::DEVICE_SCHEMA_PREFIX}:([^:]+):.*/, '\1')
122
+
123
+ begin
124
+ klass = const_get klass_name
125
+ rescue NameError
126
+ klass = const_set klass_name, Class.new(self)
127
+ klass.const_set :URN_1, "#{UPnP::DEVICE_SCHEMA_PREFIX}:#{klass.name}:1"
128
+ end
129
+
130
+ klass.new description.elements['root/device'], url
131
+ end
132
+
133
+ ##
134
+ # Searches for devices using +ssdp+ and instantiates Device objects for
135
+ # them. By calling this method on a subclass only devices of that type will
136
+ # be returned.
137
+
138
+ def self.search(ssdp = UPnP::SSDP.new)
139
+ responses = if self == UPnP::Control::Service then
140
+ ssdp.search.select do |response|
141
+ response.type =~ /^#{UPnP::DEVICE_SCHEMA_PREFIX}/
142
+ end
143
+ else
144
+ urns = constants.select { |name| name =~ /^URN_/ }
145
+ devices = urns.map { |name| const_get name }
146
+ ssdp.search(*devices)
147
+ end
148
+
149
+ responses.map { |response| create response.location }
150
+ end
151
+
152
+ ##
153
+ # Creates a new Device from +device+ which can be an REXML::Element
154
+ # describing the device or a URI for the device's description. If an XML
155
+ # description is provided, the parent device's +url+ must also be provided.
156
+
157
+ def initialize(device, url = nil)
158
+ @devices = []
159
+ @sub_devices = []
160
+
161
+ @services = []
162
+ @sub_services = []
163
+
164
+ case device
165
+ when URI::Generic then
166
+ description = REXML::Document.new open(device)
167
+
168
+ @url = description.elements['root/URLBase']
169
+ @url = @url ? URI.parse(@url.text.strip) : device + '/'
170
+
171
+ device = parse_device description.elements['root/device']
172
+ when REXML::Element then
173
+ raise ArgumentError, 'url not provided with REXML::Element' if url.nil?
174
+ @url = url
175
+ parse_device device
176
+ else
177
+ raise ArgumentError, 'must be a URI or an REXML::Element'
178
+ end
179
+ end
180
+
181
+ ##
182
+ # Parses the REXML::Element +description+ and fills in various attributes,
183
+ # sub-devices and sub-services
184
+
185
+ def parse_device(description)
186
+ @friendly_name = description.elements['friendlyName'].text.strip
187
+
188
+ @manufacturer = description.elements['manufacturer'].text.strip
189
+
190
+ manufacturer_url = description.elements['manufacturerURL']
191
+ @manufacturer_url = URI.parse manufacturer_url.text.strip if
192
+ manufacturer_url
193
+
194
+ model_description = description.elements['modelDescription']
195
+ @model_description = model_description.text.strip if model_description
196
+
197
+ @model_name = description.elements['modelName'].text.strip
198
+
199
+ model_number = description.elements['modelNumber']
200
+ @model_number = model_number.text.strip if model_number
201
+
202
+ model_url = description.elements['modelURL']
203
+ @model_url = URI.parse model_url.text.strip if model_url
204
+
205
+ @name = description.elements['UDN'].text.strip
206
+
207
+ presentation_url = description.elements['presentationURL']
208
+ @presentation_url = URI.parse presentation_url.text.strip if
209
+ presentation_url
210
+
211
+ serial_number = description.elements['serialNumber']
212
+ @serial_number = serial_number.text.strip if serial_number
213
+
214
+ @type = description.elements['deviceType'].text.strip
215
+
216
+ upc = description.elements['UPC']
217
+ @upc = upc.text.strip if upc
218
+
219
+ description.each_element 'deviceList/device' do |sub_device_description|
220
+ sub_device = UPnP::Control::Device.new sub_device_description, @url
221
+ @sub_devices << sub_device
222
+ end
223
+
224
+ @devices = @sub_devices.map do |device|
225
+ [device, device.devices]
226
+ end.flatten
227
+
228
+ description.each_element 'serviceList/service' do |service_description|
229
+ service = UPnP::Control::Service.create service_description, @url
230
+ @sub_services << service
231
+ end
232
+
233
+ @services = (@sub_services +
234
+ @devices.map { |device| device.services }.flatten).uniq
235
+ end
236
+
237
+ end
238
+
@@ -0,0 +1,461 @@
1
+ require 'UPnP/control'
2
+
3
+ require 'date'
4
+ require 'open-uri'
5
+ require 'rexml/document'
6
+ require 'soap/rpc/driver'
7
+ require 'time'
8
+ require 'uri'
9
+
10
+ ##
11
+ # A service on a UPnP control point.
12
+ #
13
+ # A Service exposes the UPnP actions as ordinary ruby methods, which are
14
+ # handled via method_missing. A Service responds appropriately to respond_to?
15
+ # and methods to make introspection easy.
16
+ #
17
+ # Services should be created using ::create instead of ::new. This allows a
18
+ # subclass of Service to be automatically instantiated.
19
+ #
20
+ # When creating a service subclass, it must have a URN_version constant set to
21
+ # the schema URN for that version.
22
+ #
23
+ # For details on UPnP services, see http://www.upnp.org/resources/documents.asp
24
+
25
+ class UPnP::Control::Service
26
+
27
+ ##
28
+ # Service error class
29
+
30
+ class Error < UPnP::Error
31
+ end
32
+
33
+ ##
34
+ # Error raised when there was an error while calling an action
35
+
36
+ class UPnPError < Error
37
+
38
+ ##
39
+ # The UPnP fault code
40
+
41
+ attr_accessor :code
42
+
43
+ ##
44
+ # The UPnP fault description
45
+
46
+ attr_accessor :description
47
+
48
+ ##
49
+ # Creates a new UPnP error using +description+ and +code+
50
+
51
+ def initialize(description, code)
52
+ @code = code
53
+ @description = description
54
+ end
55
+
56
+ ##
57
+ # Error string including code and description
58
+
59
+ def to_s
60
+ "#{@description} (#{@code})"
61
+ end
62
+
63
+ end
64
+
65
+ ##
66
+ # UPnP relies on the client to do type conversions. This registry casts the
67
+ # SOAPString returned by the service into the real SOAP type so soap4r can
68
+ # make the conversion.
69
+
70
+ class Registry < SOAP::Mapping::EncodedRegistry
71
+
72
+ ##
73
+ # If +node+ is a simple type, cast it to the real type from the registered
74
+ # definition and super, otherwise just let EncodedRegistry do the work.
75
+
76
+ def soap2obj(node, klass = nil)
77
+ case node
78
+ when XSD::XSDAnySimpleType then
79
+ definition = find_node_definition node
80
+
81
+ return super if definition.nil?
82
+
83
+ new_class = definition.class_for
84
+ new_node = new_class.new node.data
85
+
86
+ return super(new_node, klass)
87
+ when SOAP::SOAPStruct then
88
+ return '' if node.members.empty?
89
+ end
90
+
91
+ super
92
+ end
93
+
94
+ end
95
+
96
+ ##
97
+ # Namespace for UPnP type extensions
98
+
99
+ module Types
100
+
101
+ ##
102
+ # A one-byte string
103
+
104
+ class Char < SOAP::SOAPString
105
+
106
+ ##
107
+ # Ensures the string is only one character long.
108
+
109
+ def screen_data(value)
110
+ super
111
+
112
+ if value.sub(/./mu, '').length > 1 then
113
+ raise ValueSpaceError, "#{type}: cannot accept '#{value}'."
114
+ end
115
+
116
+ value
117
+ end
118
+
119
+ end
120
+
121
+ ##
122
+ # A Universally Unique Identifier
123
+
124
+ class UUID < SOAP::SOAPString
125
+
126
+ def screen_data(data)
127
+ super
128
+
129
+ unless value.gsub('-', '') =~ /\A[a-f\d]{32}\z/ then
130
+ raise ValueSpaceError, "#{type}: cannot accept '#{value}'."
131
+ end
132
+
133
+ value
134
+ end
135
+
136
+ end
137
+
138
+ ##
139
+ # Map UPnP data types to SOAP data types
140
+
141
+ MAP = {
142
+ 'ui1' => SOAP::SOAPUnsignedByte,
143
+ 'ui2' => SOAP::SOAPUnsignedShort,
144
+ 'ui4' => SOAP::SOAPUnsignedInt,
145
+
146
+ 'i1' => SOAP::SOAPByte,
147
+ 'i2' => SOAP::SOAPShort,
148
+ 'i4' => SOAP::SOAPInt,
149
+ 'int' => SOAP::SOAPInt,
150
+
151
+ 'r4' => SOAP::SOAPFloat,
152
+ 'r8' => SOAP::SOAPDouble,
153
+ 'number' => SOAP::SOAPDouble,
154
+ 'float' => SOAP::SOAPDecimal,
155
+ 'fixed.14.4' => SOAP::SOAPDouble, # HACK not accurate
156
+
157
+ 'char' => Char,
158
+ 'string' => SOAP::SOAPString,
159
+
160
+ 'date' => SOAP::SOAPDate,
161
+ 'dateTime' => SOAP::SOAPDateTime,
162
+ 'dateTime.tz' => SOAP::SOAPDateTime,
163
+ 'time' => SOAP::SOAPTime,
164
+ 'time.tz' => SOAP::SOAPTime,
165
+
166
+ 'boolean' => SOAP::SOAPBoolean,
167
+
168
+ 'bin.base64' => SOAP::SOAPBase64,
169
+ 'bin.hex' => SOAP::SOAPHexBinary,
170
+
171
+ 'uri' => SOAP::SOAPAnyURI,
172
+
173
+ 'uuid' => UUID,
174
+ }
175
+
176
+ end
177
+
178
+ ##
179
+ # Control URL
180
+
181
+ attr_reader :control_url
182
+
183
+ ##
184
+ # SOAP driver for this service
185
+
186
+ attr_reader :driver
187
+
188
+ ##
189
+ # Eventing URL
190
+
191
+ attr_reader :event_sub_url
192
+
193
+ ##
194
+ # Service identifier, unique within this service's devices
195
+
196
+ attr_reader :id
197
+
198
+ ##
199
+ # Service description URL
200
+
201
+ attr_reader :scpd_url
202
+
203
+ ##
204
+ # UPnP service type
205
+
206
+ attr_reader :type
207
+
208
+ ##
209
+ # Base URL for this service's device
210
+
211
+ attr_reader :url
212
+
213
+ ##
214
+ # If a concrete class exists for +description+ it is used to instantiate the
215
+ # service, otherwise a concrete class is created subclassing Service and
216
+ # used.
217
+
218
+ def self.create(description, url)
219
+ type = description.elements['serviceType'].text.strip
220
+ klass_name = type.sub(/#{UPnP::SERVICE_SCHEMA_PREFIX}:([^:]+):.*/, '\1')
221
+
222
+ begin
223
+ klass = const_get klass_name
224
+ rescue NameError
225
+ klass = const_set klass_name, Class.new(self)
226
+ klass.const_set :URN_1, "#{UPnP::SERVICE_SCHEMA_PREFIX}:#{klass.name}:1"
227
+ end
228
+
229
+ klass.new description, url
230
+ end
231
+
232
+ ##
233
+ # Creates a new service from REXML::Element +description+ and +url+. The
234
+ # description must be a service fragment from a device description.
235
+
236
+ def initialize(description, url)
237
+ @url = url
238
+
239
+ @type = description.elements['serviceType'].text.strip
240
+ @id = description.elements['serviceId'].text.strip
241
+ @control_url = @url + description.elements['controlURL'].text.strip
242
+ @event_sub_url = @url + description.elements['eventSubURL'].text.strip
243
+ @scpd_url = @url + description.elements['SCPDURL'].text.strip
244
+
245
+ create_driver
246
+ end
247
+
248
+ ##
249
+ # Creates the SOAP driver from description at scpd_url
250
+
251
+ def create_driver
252
+ parse_service_description
253
+
254
+ @driver = SOAP::RPC::Driver.new @control_url, @type
255
+
256
+ mapping_registry = Registry.new
257
+
258
+ @actions.each do |name, arguments|
259
+ soapaction = "#{@type}##{name}"
260
+ qname = XSD::QName.new @type, soapaction
261
+
262
+ # TODO map ranges, enumerations
263
+ arguments = arguments.map do |direction, arg_name, variable|
264
+ type, = @variables[variable]
265
+
266
+ schema_name = XSD::QName.new nil, arg_name
267
+
268
+ mapping_registry.register :class => type, :schema_name => schema_name
269
+
270
+ [direction, arg_name, @variables[variable].first]
271
+ end
272
+
273
+ @driver.proxy.add_rpc_method qname, soapaction, name, arguments
274
+ @driver.send :add_rpc_method_interface, name, arguments
275
+ end
276
+
277
+ @driver.mapping_registry = mapping_registry
278
+
279
+ @actions = nil
280
+ @variables = nil
281
+ end
282
+
283
+ ##
284
+ # Handles this service's actions
285
+
286
+ def method_missing(message, *arguments)
287
+ return super unless respond_to? message
288
+
289
+ begin
290
+ @driver.send(message, *arguments)
291
+ rescue SOAP::FaultError => e
292
+ backtrace = caller 0
293
+
294
+ fault_code = e.faultcode.data
295
+ fault_string = e.faultstring.data
296
+
297
+ detail = e.detail[fault_string]
298
+ code = detail['errorCode'].to_i
299
+ description = detail['errorDescription']
300
+
301
+ backtrace.first.gsub!(/:(\d+):in `([^']+)'/) do
302
+ line = $1.to_i - 2
303
+ ":#{line}:in `#{message}' (method_missing)"
304
+ end
305
+
306
+ e = UPnPError.new description, code
307
+ e.set_backtrace backtrace
308
+ raise e
309
+ end
310
+ end
311
+
312
+ ##
313
+ # Includes this service's actions
314
+
315
+ def methods(include_ancestors = true)
316
+ super + @driver.methods(false)
317
+ end
318
+
319
+ ##
320
+ # Extracts arguments for an action from +argument_list+
321
+
322
+ def parse_action_arguments(argument_list)
323
+ arguments = []
324
+
325
+ argument_list.each_element 'argument' do |argument|
326
+ name = argument.elements['name'].text.strip
327
+
328
+ direction = argument.elements['direction'].text.strip.upcase
329
+ direction = 'RETVAL' if argument.elements['retval']
330
+ direction = SOAP::RPC::SOAPMethod.const_get direction
331
+ variable = argument.elements['relatedStateVariable'].text.strip
332
+
333
+ arguments << [direction, name, variable]
334
+ end if argument_list
335
+
336
+ arguments
337
+ end
338
+
339
+ ##
340
+ # Extracts service actions from +action_list+
341
+
342
+ def parse_actions(action_list)
343
+ @actions = {}
344
+
345
+ action_list.each_element 'action' do |action|
346
+ name = action.elements['name'].text.strip
347
+
348
+ raise Error, "insecure action name #{name}" unless name =~ /\A\w*\z/
349
+
350
+
351
+ @actions[name] = parse_action_arguments action.elements['argumentList']
352
+ end
353
+ end
354
+
355
+ ##
356
+ # Extracts a list of allowed values from +state_variable+
357
+
358
+ def parse_allowed_value_list(state_variable)
359
+ list = state_variable.elements['allowedValueList']
360
+
361
+ return nil unless list
362
+
363
+ values = []
364
+
365
+ list.each_element 'allowedValue' do |value|
366
+ value = value.text.strip
367
+ raise Error, "insecure allowed value #{value}" unless value =~ /\A\w*\z/
368
+ values << value
369
+ end
370
+
371
+ values
372
+ end
373
+
374
+ ##
375
+ # Extracts an allowed value range from +state_variable+
376
+
377
+ def parse_allowed_value_range(state_variable)
378
+ range = state_variable.elements['allowedValueRange']
379
+
380
+ return nil unless range
381
+
382
+ minimum = range.elements['minimum']
383
+ maximum = range.elements['maximum']
384
+ step = range.elements['step']
385
+
386
+ range = [minimum, maximum, step]
387
+
388
+ range.map do |value|
389
+ value =~ /\./ ? Float(value) : Integer(value)
390
+ end
391
+ end
392
+
393
+ ##
394
+ # Parses a service description from the scpd_url
395
+
396
+ def parse_service_description
397
+ description = REXML::Document.new open(@scpd_url)
398
+
399
+ validate_scpd description
400
+
401
+ parse_actions description.elements['scpd/actionList']
402
+
403
+ service_state_table = description.elements['scpd/serviceStateTable']
404
+ parse_service_state_table service_state_table
405
+ end
406
+
407
+ ##
408
+ # Extracts state variables from +service_state_table+
409
+
410
+ def parse_service_state_table(service_state_table)
411
+ @variables = {}
412
+
413
+ service_state_table.each_element 'stateVariable' do |var|
414
+ name = var.elements['name'].text.strip
415
+ data_type = Types::MAP[var.elements['dataType'].text.strip]
416
+ default = var.elements['defaultValue']
417
+
418
+ if default then
419
+ default = default.text.strip
420
+ raise Error, "insecure default value #{default}" unless
421
+ default =~ /\A\w*\z/
422
+ end
423
+
424
+ allowed_value_list = parse_allowed_value_list var
425
+ allowed_value_range = parse_allowed_value_range var
426
+
427
+ @variables[name] = [
428
+ data_type,
429
+ default,
430
+ allowed_value_list,
431
+ allowed_value_range
432
+ ]
433
+ end
434
+ end
435
+
436
+ ##
437
+ # Returns true for this service's actions as well as the usual behavior
438
+
439
+ def respond_to?(message)
440
+ @driver.methods(false).include? message.to_s || super
441
+ end
442
+
443
+ ##
444
+ # Ensures +service_description+ has the correct namespace, root element, and
445
+ # version numbers. Raises an exception if the service isn't valid.
446
+
447
+ def validate_scpd(service_description)
448
+ namespace = service_description.elements["//scpd"].namespace
449
+
450
+ raise Error, "invalid namespace #{namespace}" unless
451
+ namespace == 'urn:schemas-upnp-org:service-1-0'
452
+
453
+ major = service_description.elements["//scpd/specVersion/major"].text.strip
454
+ minor = service_description.elements["//scpd/specVersion/minor"].text.strip
455
+
456
+ raise Error, "invalid version #{major}.#{minor}" unless
457
+ major == '1' and minor == '0'
458
+ end
459
+
460
+ end
461
+