UPnP 1.0.0

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