rupnp 0.1.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,271 @@
1
+ require 'nori'
2
+ require 'ostruct'
3
+
4
+
5
+ module RUPNP
6
+
7
+ # A device is a UPnP service provider.
8
+ # @author Sylvain Daubert.
9
+ class CP::RemoteDevice < CP::Base
10
+ # Get control point which controls this device
11
+ # @return [ControlPoint]
12
+ attr_reader :control_point
13
+
14
+ # Get search target.
15
+ # @return [String]
16
+ attr_reader :st
17
+ # Get Unique Service Name
18
+ # @return [String]
19
+ attr_reader :usn
20
+ # Get SERVER string
21
+ # @return [String]
22
+ attr_reader :server
23
+ # URL to the UPnP description of the root device
24
+ # @return [String]
25
+ attr_reader :location
26
+ # @return [String]
27
+ attr_reader :ext
28
+ # Date when response was generated
29
+ # @return [String]
30
+ attr_reader :date
31
+ # Contains +max-age+ directive used to specify advertisement validity
32
+ # @return [String]
33
+ attr_reader :cache_control
34
+ # Expiration time for the advertisement
35
+ # @return [Time]
36
+ attr_reader :expiration
37
+
38
+ # UPnP version used by the device
39
+ # @return [String]
40
+ attr_reader :upnp_version
41
+ # XML namespace for device description
42
+ # @return [String]
43
+ attr_reader :xmlns
44
+ # URL base for device access
45
+ # @return [String]
46
+ attr_reader :url_base
47
+ # Device type
48
+ # @return [String]
49
+ attr_reader :type
50
+ # Short description for end users
51
+ # @return [String]
52
+ attr_reader :friendly_name
53
+ # Manufacturer's name
54
+ # @return [String]
55
+ attr_reader :manufacturer
56
+ # Web site for manufacturer
57
+ # @return [String]
58
+ attr_reader :manufacturer_url
59
+ # Long decription for end user
60
+ # @return [String]
61
+ attr_reader :model_description
62
+ # Model name
63
+ # @return [String]
64
+ attr_reader :model_name
65
+ # Model number
66
+ # @return [String]
67
+ attr_reader :model_number
68
+ # Web site for model
69
+ # @return [String]
70
+ attr_reader :model_url
71
+ # Serial number
72
+ # @return [String]
73
+ attr_reader :serial_umber
74
+ # Unique Device Name
75
+ # @return [String]
76
+ attr_reader :udn
77
+ # Universal Product Code
78
+ # @return [String]
79
+ attr_reader :upc
80
+ # URL to presentation for device
81
+ # @return [String]
82
+ attr_reader :presentation_url
83
+ # Array of icons to depict device in control point UI
84
+ # @return [Array<OpenStruct>]
85
+ attr_reader :icons
86
+ # List of device's services
87
+ # @return [Array<Service>]
88
+ attr_reader :services
89
+ # List of embedded devices
90
+ # @return [Array<Device>]
91
+ attr_reader :devices
92
+
93
+
94
+ # @param [ControlPoint] control_point
95
+ # @param [Hash] notification
96
+ def initialize(control_point, notification)
97
+ super()
98
+ @control_point = control_point
99
+ @notification = notification
100
+
101
+ @icons = []
102
+ @services = []
103
+ @devices = []
104
+ end
105
+
106
+ # Get device from its description
107
+ # @return [void]
108
+ def fetch
109
+ description_getter = EM::DefaultDeferrable.new
110
+
111
+ description_getter.errback do
112
+ msg = "Failed getting description"
113
+ log :error, "Fetching device: #{msg}"
114
+ fail self, msg
115
+ end
116
+
117
+ extract_from_ssdp_notification description_getter
118
+
119
+ description_getter.callback do |description|
120
+ @description = description
121
+ unless description
122
+ fail self, 'Blank description returned'
123
+ next
124
+ end
125
+
126
+ if bad_description?
127
+ fail self, "Bad description returned: #@description"
128
+ next
129
+ end
130
+
131
+ extract_url_base
132
+ extract_device_info
133
+ extract_icons
134
+
135
+ @services_extracted = @devices_extracted = false
136
+ extract_services
137
+ extract_devices
138
+
139
+ tick_loop = EM.tick_loop do
140
+ :stop if @services_extracted and @devices_extracted
141
+ end
142
+ tick_loop.on_stop { succeed self }
143
+ end
144
+ end
145
+
146
+
147
+ private
148
+
149
+
150
+ def extract_from_ssdp_notification(getter)
151
+ @st = @notification['st']
152
+ @usn = @notification['usn']
153
+ @server = @notification['server']
154
+ @location = @notification['location']
155
+ @ext = @notification['ext']
156
+ @date = @notification['date'] || ''
157
+ @cache_control = @notification['cache-control'] || ''
158
+
159
+ max_age = @cache_control.match(/max-age\s*=\s*(\d+)/)[1].to_i
160
+ @expiration = if @date.empty?
161
+ Time.now + max_age
162
+ else
163
+ Time.parse(@date) + max_age
164
+ end
165
+
166
+ if @location
167
+ get_description @location, getter
168
+ else
169
+ fail self, 'M-SEARCH response has no location'
170
+ end
171
+ end
172
+
173
+ def bad_description?
174
+ if @description[:root]
175
+ bd = false
176
+ @xmlns = @description[:root][:@xmlns]
177
+ bd |= @xmlns != 'urn:schemas-upnp-org:device-1-0'
178
+ bd |= @description[:root][:spec_version][:major].to_i != 1
179
+ @upnp_version = @description[:root][:spec_version][:major] + '.'
180
+ @upnp_version += @description[:root][:spec_version][:minor]
181
+ bd |= !@description[:root][:device]
182
+ else
183
+ true
184
+ end
185
+ end
186
+
187
+ def extract_url_base
188
+ if @description[:root][:url_base] and @upnp_version != '1.1'
189
+ @url_base = @description[:root][:url_base]
190
+ @url_base += '/' unless @url_base.end_with?('/')
191
+ else
192
+ @url_base = @location.match(/[^\/]*\z/).pre_match
193
+ end
194
+ end
195
+
196
+ def extract_device_info
197
+ device = @description[:root][:device]
198
+ @type = device[:device_type]
199
+ @friendly_name = device[:friendly_name]
200
+ @manufacturer = device[:manufacturer]
201
+ @manufacturer_url = device[:manufacturer_url] || ''
202
+ @model_description = device[:model_description] || ''
203
+ @model_name = device[:model_name]
204
+ @model_number = device[:model_number] || ''
205
+ @model_url = device[:model_url] || ''
206
+ @serial_umber = device[:serial_number] || ''
207
+ @udn = device[:udn]
208
+ @upc = device[:upc] || ''
209
+ @presentation_url = device[:presentation_url] || ''
210
+ end
211
+
212
+ def extract_icons
213
+ @description[:root][:device][:icon_list][:icon].each do |h|
214
+ icon = OpenStruct.new(h)
215
+ icon.url = build_url(@url_base, icon.url)
216
+ @icons << icon
217
+ end
218
+ end
219
+
220
+ def extract_services
221
+ if @description[:root][:device][:service_list] &&
222
+ @description[:root][:device][:service_list][:service]
223
+ sl = @description[:root][:device][:service_list][:service]
224
+
225
+ proc_each = Proc.new do |s, iter|
226
+ service = CP::RemoteService.new(self, @url_base, s)
227
+
228
+ service.errback do |msg|
229
+ log :error, "failed to extract service #{s[:service_id]}: #{msg}"
230
+ iter.next
231
+ end
232
+
233
+ service.callback do |serv|
234
+ @services << serv
235
+ create_method_from_service serv
236
+ iter.next
237
+ end
238
+
239
+ service.fetch
240
+ end
241
+
242
+ proc_after = Proc.new do
243
+ @services_extracted = true
244
+ end
245
+
246
+ EM::Iterator.new(sl).each(proc_each, proc_after)
247
+ else
248
+ @services_extracted = true
249
+ end
250
+ end
251
+
252
+ def extract_devices
253
+ if @description[:root][:device_list]
254
+ if @description[:root][:device_list][:device]
255
+ dl = @description[:root][:device_list][:device]
256
+ ## TODO
257
+ end
258
+ end
259
+ @devices_extracted = true ## TEMP
260
+ end
261
+
262
+ def create_method_from_service(service)
263
+ if service.type =~ /urn:.*:service:(\w+):\d/
264
+ name = snake_case($1).to_sym
265
+ define_singleton_method(name) { service }
266
+ end
267
+ end
268
+
269
+ end
270
+
271
+ end
@@ -0,0 +1,304 @@
1
+ require 'uri'
2
+ require 'savon'
3
+ require_relative 'base'
4
+
5
+ module RUPNP
6
+
7
+ # Service class for device's services.
8
+ #
9
+ # ==Actions
10
+ # This class defines ruby methods from actions defined in
11
+ # service description, as provided by the device.
12
+ #
13
+ # By example, from this description:
14
+ # <action>
15
+ # <name>actionName</name>
16
+ # <argumentList>
17
+ # <argument>
18
+ # <name>argumentNameIn</name>
19
+ # <direction>in</direction>
20
+ # <relatedStateVariable>stateVariableName</relatedStateVariable>
21
+ # </argument>
22
+ # <argument>
23
+ # <name>argumentNameOut</name>
24
+ # <direction>out</direction>
25
+ # <relatedStateVariable>stateVariableName</relatedStateVariable>
26
+ # </argument>
27
+ # </action>
28
+ # a +#action_name+ method is created. This method requires a hash with
29
+ # an element named +argument_name_in+.
30
+ # If no <i>in</i> argument is required, an empty hash (<code>{}</code>)
31
+ # must be passed to the method.
32
+ #
33
+ # A Hash is returned, with a key for each <i>out</i> argument.
34
+ #
35
+ # @author Sylvain Daubert
36
+ class CP::RemoteService < CP::Base
37
+
38
+ # @private
39
+ @@event_sub_count = 0
40
+
41
+ # Get event subscription count for all services
42
+ # (unique ID for subscription)
43
+ # @return [Integer]
44
+ def self.event_sub_count
45
+ @@event_sub_count += 1
46
+ end
47
+
48
+
49
+ # @private SOAP integer types
50
+ INTEGER_TYPES = %w(ui1 ui2 ui4 i1 i2 i4 int).freeze
51
+ # @private SOAP float types
52
+ FLOAT_TYPES = %w(r4 r8 number float).freeze
53
+ # @private SOAP string types
54
+ STRING_TYPES = %w(char string uuid).freeze
55
+ # @private SOAP true values
56
+ TRUE_TYPES = %w(1 true yes).freeze
57
+ # @private SOAP false values
58
+ FALSE_TYPES = %w(0 false no).freeze
59
+
60
+ # Get device to which this service belongs to
61
+ # @return [Device]
62
+ attr_reader :device
63
+
64
+ # Get service type
65
+ # @return [String]
66
+ attr_reader :type
67
+ # URL for service description
68
+ # @return [String]
69
+ attr_reader :scpd_url
70
+ # URL for control
71
+ # @return [String]
72
+ attr_reader :control_url
73
+ # URL for eventing
74
+ # @return [String]
75
+ attr_reader :event_sub_url
76
+
77
+ # XML namespace for device description
78
+ # @return [String]
79
+ attr_reader :xmlns
80
+ # Define architecture on which the service is implemented
81
+ # @return [String]
82
+ attr_reader :spec_version
83
+ # Available actions on this service
84
+ # @return [Array<Hash>]
85
+ attr_reader :actions
86
+ # State table for the service
87
+ # @return [Array<Hash>]
88
+ attr_reader :state_table
89
+
90
+ # @param [Device] device
91
+ # @param [String] url_base
92
+ # @param [Hash] service
93
+ def initialize(device, url_base, service)
94
+ super()
95
+ @device = device
96
+ @description = service
97
+
98
+ @type = service[:service_type].to_s
99
+ @scpd_url = build_url(url_base, service[:scpdurl].to_s)
100
+ @control_url = build_url(url_base, service[:control_url].to_s)
101
+ @event_sub_url = build_url(url_base, service[:event_sub_url].to_s)
102
+ @actions = []
103
+
104
+ initialize_savon
105
+ end
106
+
107
+ # Get service from its description
108
+ # @return [void]
109
+ def fetch
110
+ if @scpd_url.empty?
111
+ fail 'no SCPD URL'
112
+ return
113
+ end
114
+
115
+ scpd_getter = EM::DefaultDeferrable.new
116
+
117
+ scpd_getter.errback do
118
+ fail "cannot get SCPD from #@scpd_url"
119
+ end
120
+
121
+ scpd_getter.callback do |scpd|
122
+ if !scpd or scpd.empty?
123
+ fail "SCPD from #@scpd_url is empty"
124
+ next
125
+ end
126
+
127
+ if bad_description?(scpd)
128
+ fail 'not a UPNP 1.0/1.1 SCPD'
129
+ next
130
+ end
131
+
132
+ extract_service_state_table scpd
133
+ extract_actions scpd
134
+
135
+ succeed self
136
+ end
137
+
138
+ get_description @scpd_url, scpd_getter
139
+ end
140
+
141
+ # Subscribe to event
142
+ # @param [Hash] options
143
+ # @option options [Integer] timeout
144
+ # @yieldparam [Event] event event received
145
+ def subscribe_to_event(options={}, &blk)
146
+ cp = device.control_point
147
+
148
+ cp.start_event_server
149
+
150
+ port = cp.event_port
151
+ num = self.class.event_sub_count
152
+ @callback_url = "http://#{HOST_IP}:#{port}/event#{num}}"
153
+
154
+ uri = URI(@event_sub_url)
155
+ options[:timeout] ||= EVENT_SUB_DEFAULT_TIMEOUT
156
+ subscribe_req = <<EOR
157
+ SUSCRIBE #{uri.path} HTTP/1.1\r
158
+ HOST: #{HOST_IP}:#{port}\r
159
+ USER-AGENT: #{RUPNP::USER_AGENT}\r
160
+ CALLBACK: #@callback_url\r
161
+ NT: upnp:event
162
+ TIMEOUT: Second-#{options[:timeout]}\r
163
+ \r
164
+ EOR
165
+
166
+ server = uri.host
167
+ port = (uri.port || 80).to_i
168
+ ap @event_sub_url
169
+ ap server
170
+ ap port
171
+ con = EM.connect(server, port, CP::EventSubscriber, subscribe_req)
172
+
173
+ con.response.subscribe do |resp|
174
+ if resp[:status_code] != 200
175
+ log :warn, "Cannot subscribe to event #@event_sub_url: #{resp[:status]}"
176
+ else
177
+ event = Event.new(resp[:sid], resp[:timeout].match(/(\d+)/)[1].to_i)
178
+ cp.add_event_url << ["/event#{num}", event]
179
+ event.subscribe(event, blk)
180
+ end
181
+ log :info, 'Close connection to subscribe event URL'
182
+ con.close_connection
183
+ end
184
+ end
185
+
186
+
187
+ private
188
+
189
+ def bad_description?(scpd)
190
+ if scpd[:scpd]
191
+ bd = false
192
+ @xmlns = scpd[:scpd][:@xmlns]
193
+ bd |= @xmlns != "urn:schemas-upnp-org:service-1-0"
194
+ bd |= scpd[:scpd][:spec_version][:major].to_i != 1
195
+ @spec_version = scpd[:scpd][:spec_version][:major] + '.'
196
+ @spec_version += scpd[:scpd][:spec_version][:minor]
197
+ bd |= !scpd[:scpd][:service_state_table]
198
+ bd | scpd[:scpd][:service_state_table].empty?
199
+ else
200
+ true
201
+ end
202
+ end
203
+
204
+ def extract_service_state_table(scpd)
205
+ if scpd[:scpd][:service_state_table][:state_variable]
206
+ @state_table = scpd[:scpd][:service_state_table][:state_variable]
207
+ # ease debug print
208
+ @state_table.each { |s| s.each { |k, v| s[k] = v.to_s } }
209
+ end
210
+ end
211
+
212
+ def extract_actions(scpd)
213
+ if scpd[:scpd][:action_list] and scpd[:scpd][:action_list][:action]
214
+ log :info, "extract actions for service #@type"
215
+ @actions = scpd[:scpd][:action_list][:action]
216
+ @actions.each do |action|
217
+ action[:arguments] = action[:argument_list][:argument]
218
+ action.delete :argument_list
219
+ define_method_from_action action
220
+ end
221
+ end
222
+ end
223
+
224
+ def define_method_from_action(action)
225
+ action[:name] = action[:name].to_s
226
+ action_name = action[:name]
227
+ name = snake_case(action_name).to_sym
228
+ define_singleton_method(name) do |params|
229
+ response = @soap.call(action_name) do |locals|
230
+ locals.attributes 'xmlns:u' => @type
231
+ locals.soap_action "#{type}##{action_name}"
232
+ if params
233
+ unless params.is_a? Hash
234
+ raise ArgumentError, 'only hash arguments are accepted'
235
+ end
236
+ locals.message params
237
+ end
238
+ end
239
+
240
+ if action[:arguments].is_a? Hash
241
+ log :debug, 'only one argument in argument list'
242
+ if action[:arguments][:direction] == 'out'
243
+ process_soap_response name, response, action[:arguments]
244
+ end
245
+ else
246
+ log :debug, 'true argument list'
247
+ action[:arguments].map do |arg|
248
+ if params arg[:direction] == 'out'
249
+ process_soap_response name, response, arg
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
255
+
256
+ def process_soap_response(action, resp, out_arg)
257
+ if resp.success? and resp.to_xml.empty?
258
+ log :debug, 'Successful SOAP request but empty response'
259
+ return {}
260
+ end
261
+
262
+ state_var = @state_table.find do |h|
263
+ h[:name] == out_arg[:related_state_variable]
264
+ end
265
+
266
+ action_response = "#{action}_response".to_sym
267
+ out_arg_name = snake_case(out_arg[:name]).to_sym
268
+ value = resp.hash[:envelope][:body][action_response][out_arg_name]
269
+
270
+ transform_method = if INTEGER_TYPES.include? state_var[:data_type]
271
+ :to_i
272
+ elsif FLOAT_TYPES.include? state_var[:data_type]
273
+ :to_f
274
+ elsif STRING_TYPES.include? state_var[:data_type]
275
+ :to_s
276
+ end
277
+ if transform_method
278
+ { out_arg_name => value.send(transform_method) }
279
+ elsif TRUE_TYPES.include? state_var[:data_type]
280
+ { out_arg_name => true }
281
+ elsif FALSE_TYPES.include? state_var[:data_type]
282
+ { out_arg_name => false }
283
+ else
284
+ log :warn, "SOAP response has an unknown type: #{state_var[:data_type]}"
285
+ {}
286
+ end
287
+ end
288
+
289
+ def initialize_savon
290
+ @soap = Savon.client do |globals|
291
+ globals.log_level :error
292
+ globals.endpoint @control_url
293
+ globals.namespace @type
294
+ globals.convert_request_keys_to :camel_case
295
+ globals.log true
296
+ globals.headers :HOST => "#{HOST_IP}"
297
+ globals.env_namespace 's'
298
+ globals.namespace_identifier 'u'
299
+ end
300
+ end
301
+
302
+ end
303
+
304
+ end
@@ -0,0 +1,55 @@
1
+ require 'eventmachine-le'
2
+ require 'pry'
3
+ require 'rupnp'
4
+
5
+ class RUPNP::Discover
6
+ attr_reader :devices
7
+
8
+ def self.run
9
+ d = new
10
+ d.pry
11
+ end
12
+
13
+ def initialize
14
+ configure_rupnp
15
+ configure_pry
16
+ create_command_set
17
+ end
18
+
19
+
20
+ private
21
+
22
+ def configure_rupnp
23
+ RUPNP.logdev = devnull = File.open('/dev/null', 'w')
24
+ at_exit { devnull.close }
25
+ end
26
+
27
+ def configure_pry
28
+ ::Pry.config.should_load_rc = false
29
+ ::Pry.config.history.should_save = false
30
+ ::Pry.config.history.should_load = false
31
+ ::Pry.config.prompt = [proc { 'discover> ' }, proc { 'discover* ' }]
32
+ end
33
+
34
+ def create_command_set
35
+ discover = self
36
+
37
+ command_set = Pry::CommandSet.new do
38
+ block_command 'search', 'Search for devices' do |target|
39
+ target ||= :all
40
+ cp = RUPNP::ControlPoint.new(target)
41
+ EM.run do
42
+ cp.search_only
43
+ EM.add_timer(RUPNP::ControlPoint::DEFAULT_RESPONSE_WAIT_TIME+2) do
44
+ discover.instance_eval { @devices = cp.devices }
45
+ output.puts "#{discover.devices.size} devices found"
46
+ EM.stop
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ Pry::Commands.import command_set
53
+ end
54
+
55
+ end
@@ -0,0 +1,32 @@
1
+ module RUPNP
2
+
3
+ # Event class to handle events from devices
4
+ # @todo Renewal and cancellation of subscription are not coded
5
+ # @author Sylvain Daubert
6
+ class Event < EM::Channel
7
+
8
+ # Get service ID
9
+ # @return [Integer]
10
+ attr_reader :sid
11
+
12
+ # @param [#to_i] sid
13
+ # @param [Integer] timeout for event (in seconds)
14
+ def initialize(sid, timeout)
15
+ @sid, @timeout = sid, timeout
16
+
17
+ @timeout_timer = EM.add_timer(@timeout) { self << :timeout }
18
+ end
19
+
20
+ # Renew subscription to event
21
+ def renew_subscription
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # Cancel subscription to event
26
+ def cancel_subscription
27
+ raise NotImplementedError
28
+ end
29
+
30
+ end
31
+
32
+ end
@@ -0,0 +1,30 @@
1
+ module RUPNP
2
+
3
+ # Mixin to add log facility to others classes.
4
+ # @author Sylvain Daubert
5
+ module LogMixin
6
+
7
+
8
+ # Log severity levels
9
+ LOG_LEVEL = {
10
+ :failure => 5,
11
+ :error => 4,
12
+ :warn => 3,
13
+ :info => 2,
14
+ :debug => 1
15
+ }
16
+ LOG_LEVEL.default = 0
17
+
18
+ # log a message
19
+ # @param [Symbol] level severity level. May be +:debug+,
20
+ # +:info+, +warn+, or +:error+
21
+ # @param [String] msg message to log
22
+ def log(level, msg='')
23
+ if LOG_LEVEL[level] >= LOG_LEVEL[RUPNP.log_level]
24
+ RUPNP.logdev.puts "[#{level}] #{msg}"
25
+ end
26
+ end
27
+
28
+ end
29
+
30
+ end