druzy-upnp 1.0.0 → 2.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.
@@ -1,387 +0,0 @@
1
- require 'savon'
2
- require_relative 'base'
3
- require_relative 'error'
4
- require 'core_ext/hash_patch'
5
-
6
- require 'em-http'
7
- HTTPI.adapter = :em_http
8
-
9
- module Druzy
10
- module Upnp
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
-
59
- #vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
60
- # Passed in by +service_list_info+
61
- #
62
-
63
- # @return [String] UPnP service type, including URN.
64
- attr_reader :service_type
65
-
66
- # @return [String] Service identifier, unique within this service's devices.
67
- attr_reader :service_id
68
-
69
- # @return [URI::HTTP] Service description URL.
70
- attr_reader :scpd_url
71
-
72
- # @return [URI::HTTP] Control URL.
73
- attr_reader :control_url
74
-
75
- # @return [URI::HTTP] Eventing URL.
76
- attr_reader :event_sub_url
77
-
78
- #
79
- # DONE +service_list_info+
80
- #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
81
-
82
- #vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
83
- # Determined by service description file
84
- #
85
-
86
- # @return [String]
87
- attr_reader :xmlns
88
-
89
- # @return [String]
90
- attr_reader :spec_version
91
-
92
- # @return [Array<Hash>]
93
- attr_reader :action_list
94
-
95
- # Probably don't need to keep this long-term; just adding for testing.
96
- attr_reader :service_state_table
97
-
98
- #
99
- # DONE description
100
- #^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
101
-
102
- # @return [Hash] The whole description... just in case.
103
- attr_reader :description
104
-
105
- # @param [String] device_base_url URL given (or otherwise determined) by
106
- # <URLBase> from the device that owns the service.
107
- # @param [Hash] service_list_info Info given in the <serviceList> section
108
- # of the device description.
109
- def initialize(device_base_url, service_list_info)
110
- @service_list_info = service_list_info
111
- @action_list = []
112
- @xmlns = ''
113
- extract_service_list_info(device_base_url)
114
- configure_savon
115
- end
116
-
117
- # Fetches the service description file, parses it, extracts attributes
118
- # into accessors, and defines Ruby methods from SOAP actions. Since this
119
- # is a long-ish process, this is done using EventMachine Deferrable
120
- # behavior.
121
- def fetch
122
- if @scpd_url.empty?
123
- set_deferred_success self
124
- return
125
- end
126
-
127
- description_getter = EventMachine::DefaultDeferrable.new
128
- get_description(@scpd_url, description_getter)
129
-
130
- description_getter.errback do
131
- msg = 'Failed getting service description.'
132
- # @todo Should this return self? or should it succeed?
133
- set_deferred_status(:failed, msg)
134
-
135
- if ControlPoint.raise_on_remote_error
136
- raise ControlPoint::Error, msg
137
- end
138
- end
139
-
140
- description_getter.callback do |description|
141
- @description = description
142
- @xmlns = @description[:scpd][:@xmlns]
143
- extract_spec_version
144
- extract_service_state_table
145
-
146
- if @description[:scpd][:actionList]
147
- define_methods_from_actions(@description[:scpd][:actionList][:action])
148
- end
149
-
150
- set_deferred_status(:succeeded, self)
151
- end
152
- end
153
-
154
- private
155
-
156
- # Extracts all of the basic service information from the information
157
- # handed over from the device description about the service. The actual
158
- # service description info gathering is *not* done here.
159
- #
160
- # @param [String] device_base_url The URLBase from the device. Used to
161
- # build absolute URLs for the service.
162
- def extract_service_list_info(device_base_url)
163
- @control_url = if @service_list_info[:controlURL]
164
- build_url(device_base_url, @service_list_info[:controlURL])
165
- else
166
- ''
167
- end
168
-
169
- @event_sub_url = if @service_list_info[:eventSubURL]
170
- build_url(device_base_url, @service_list_info[:eventSubURL])
171
- else
172
- ''
173
- end
174
-
175
- @service_type = @service_list_info[:serviceType]
176
- @service_id = @service_list_info[:serviceId]
177
-
178
- @scpd_url = if @service_list_info[:SCPDURL]
179
- build_url(device_base_url, @service_list_info[:SCPDURL])
180
- else
181
- ''
182
- end
183
- end
184
-
185
- def extract_spec_version
186
- "#{@description[:scpd][:specVersion][:major]}.#{@description[:scpd][:specVersion][:minor]}"
187
- end
188
-
189
- def extract_service_state_table
190
- @service_state_table = if @description[:scpd][:serviceStateTable].is_a? Hash
191
- @description[:scpd][:serviceStateTable][:stateVariable]
192
- elsif @description[:scpd][:serviceStateTable].is_a? Array
193
- @description[:scpd][:serviceStateTable].map do |state|
194
- state[:stateVariable]
195
- end
196
- end
197
- end
198
-
199
- # Determines if <actionList> from the service description contains a
200
- # single action or multiple actions and delegates to create Ruby methods
201
- # accordingly.
202
- #
203
- # @param [Hash,Array] action_list The value from <scpd><actionList><action>
204
- # from the service description.
205
- def define_methods_from_actions(action_list)
206
-
207
- if action_list.is_a? Hash
208
- @action_list << action_list
209
- define_method_from_action(action_list[:name].to_sym,
210
- action_list[:argumentList][:argument])
211
- elsif action_list.is_a? Array
212
- action_list.each do |action|
213
- =begin
214
- in_args_count = action[:argumentList][:argument].find_all do |arg|
215
- arg[:direction] == 'in'
216
- end.size
217
- =end
218
- @action_list << action
219
- args = action[:argumentList] ? action[:argumentList][:argument] : {}
220
- define_method_from_action(action[:name].to_sym, args)
221
- end
222
- end
223
- end
224
-
225
- # Defines a Ruby method from the SOAP action.
226
- #
227
- # All resulting methods will either take no arguments or a single Hash as
228
- # an argument, whatever the SOAP action describes as its "in" arguments.
229
- # If the action describes "in" arguments, then you must provide a Hash
230
- # where keys are argument names and values are the values for those
231
- # arguments.
232
- #
233
- # For example, the GetCurrentConnectionInfo action from the
234
- # "ConnectionManager:1" service describes an "in" argument named
235
- # "ConnectionID" whose dataType is "i4". To call this action via the
236
- # Ruby method, it'd look like:
237
- #
238
- # connection_manager.GetCurrentConnectionInfo({ "ConnectionID" => 42 })
239
- #
240
- # There is currently no type checking for these "in" arguments.
241
- #
242
- # Calling that Ruby method will, in turn, call the SOAP action by the same
243
- # name, with the body set to:
244
- #
245
- # <connectionID>42</connectionID>
246
- #
247
- # The UPnP device providing the service will reply with a SOAP
248
- # response--either a fault or with good data--and that will get converted
249
- # to a Hash. This Hash will contain key/value pairs defined by the "out"
250
- # argument names and values. Each value is converted to an associated
251
- # Ruby type, determined from the serviceStateTable. If no return data
252
- # is relevant for the request you made, some devices may return an empty
253
- # body.
254
- #
255
- # @param [Symbol] action_name The extracted value from <actionList>
256
- # <action><name> from the spec.
257
- # @param [Hash,Array] argument_info The extracted values from
258
- # <actionList><action><argumentList><argument> from the spec.
259
- def define_method_from_action(action_name, argument_info)
260
- # Do this here because, for some reason, @service_type is out of scope
261
- # in the #request block below.
262
- st = @service_type
263
-
264
- define_singleton_method(action_name) do |params|
265
- begin
266
- response = @soap_client.call(action_name.to_s) do |locals|
267
- locals.message_tags 'xmlns:u' => @service_type
268
- locals.soap_action "#{st}##{action_name}"
269
- #soap.namespaces["s:encodingStyle"] = "http://schemas.xmlsoap.org/soap/encoding/"
270
-
271
- unless params.nil?
272
- raise ArgumentError,
273
- 'Method only accepts Hashes' unless params.is_a? Hash
274
- soap.body = params.symbolize_keys!
275
- end
276
- end
277
- rescue Savon::SOAPFault, Savon::HTTPError => ex
278
- hash = xml_parser.parse(ex.http.body)
279
- msg = <<-MSG
280
- SOAP request failure!
281
- HTTP response code: #{ex.http.code}
282
- HTTP headers: #{ex.http.headers}
283
- HTTP body: #{ex.http.body}
284
- HTTP body as Hash: #{hash}
285
- MSG
286
-
287
- raise(ActionError, msg) if ControlPoint.raise_on_remote_error
288
-
289
- if hash.empty?
290
- return ex.http.body
291
- else
292
- return hash[:Envelope][:Body]
293
- end
294
- end
295
-
296
- return_value = if argument_info.is_a?(Hash) && argument_info[:direction] == 'out'
297
- return_ruby_from_soap(action_name, response, argument_info)
298
- elsif argument_info.is_a? Array
299
- argument_info.map do |arg|
300
- if arg[:direction] == 'out'
301
- return_ruby_from_soap(action_name, response, arg)
302
- end
303
- end
304
- else
305
- {}
306
- end
307
-
308
- return_value
309
- end
310
-
311
- end
312
-
313
- # Uses the serviceStateTable to look up the output from the SOAP response
314
- # for the given action, then converts it to the according Ruby data type.
315
- #
316
- # @param [String] action_name The name of the SOAP action that was called
317
- # for which this will get the response from.
318
- # @param [Savon::SOAP::Response] soap_response The response from making
319
- # the SOAP call.
320
- # @param [Hash] out_argument The Hash that tells out the "out" argument
321
- # which tells what data type to return.
322
- # @return [Hash] Key will be the "out" argument name as a Symbol and the
323
- # key will be the value as its converted Ruby type.
324
- def return_ruby_from_soap(action_name, soap_response, out_argument)
325
- out_arg_name = out_argument[:name]
326
- #puts "out arg name: #{out_arg_name}"
327
-
328
- related_state_variable = out_argument[:relatedStateVariable]
329
- #puts "related state var: #{related_state_variable}"
330
-
331
- state_variable = @service_state_table.find do |state_var_hash|
332
- state_var_hash[:name] == related_state_variable
333
- end
334
-
335
- #puts "state var: #{state_variable}"
336
-
337
- int_types = %w[ui1 ui2 ui4 i1 i2 i4 int]
338
- float_types = %w[r4 r8 number fixed.14.4 float]
339
- string_types = %w[char string uuid]
340
- true_types = %w[1 true yes]
341
- false_types = %w[0 false no]
342
-
343
- if soap_response.success? && soap_response.to_xml.empty?
344
- return {}
345
- end
346
-
347
- if int_types.include? state_variable[:dataType]
348
- {
349
- out_arg_name.to_sym => soap_response.
350
- hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_i
351
- }
352
- elsif string_types.include? state_variable[:dataType]
353
- {
354
- out_arg_name.to_sym => soap_response.
355
- hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_s
356
- }
357
- elsif float_types.include? state_variable[:dataType]
358
- {
359
- out_arg_name.to_sym => soap_response.
360
- hash[:Envelope][:Body]["#{action_name}Response".to_sym][out_arg_name.to_sym].to_f
361
- }
362
- elsif true_types.include? state_variable[:dataType]
363
- {
364
- out_arg_name.to_sym => true
365
- }
366
- elsif false_types.include? state_variable[:dataType]
367
- {
368
- out_arg_name.to_sym => false
369
- }
370
- end
371
- end
372
-
373
- def configure_savon
374
- @soap_client = Savon.client do |globals|
375
- globals.endpoint @control_url
376
- globals.namespace @service_type
377
-
378
- globals.convert_request_keys_to :camelcase
379
- globals.namespace_identifier :u
380
- globals.env_namespace :s
381
- globals.log true
382
- end
383
- end
384
- end
385
- end
386
- end
387
- end
@@ -1,161 +0,0 @@
1
- require 'open-uri'
2
- require 'nori'
3
- require 'em-synchrony'
4
- require_relative 'ssdp'
5
- require_relative 'control_point/service'
6
- require_relative 'control_point/device'
7
- require_relative 'control_point/error'
8
-
9
- module Druzy
10
- module Upnp
11
-
12
- # Allows for controlling a UPnP device as defined in the UPnP spec for control
13
- # points.
14
- #
15
- # It uses +Nori+ for parsing the description XML files, which will use +Nokogiri+
16
- # if you have it installed.
17
- class ControlPoint
18
-
19
- def self.config
20
- yield self
21
- end
22
-
23
- class << self
24
- attr_accessor :raise_on_remote_error
25
- end
26
-
27
- @@raise_on_remote_error ||= true
28
-
29
- attr_reader :devices
30
-
31
- # @param [String] search_target The device(s) to control.
32
- # @param [Hash] search_options Options to pass on to SSDP search and listen calls.
33
- # @option options [Fixnum] response_wait_time
34
- # @option options [Fixnum] m_search_count
35
- # @option options [Fixnum] ttl
36
- def initialize(search_target, search_options = {})
37
- @search_target = search_target
38
- @search_options = search_options
39
- @search_options[:ttl] ||= 4
40
- @devices = []
41
- @new_device_channel = EventMachine::Channel.new
42
- @old_device_channel = EventMachine::Channel.new
43
- end
44
-
45
- # Starts the ControlPoint. If an EventMachine reactor is running already,
46
- # it'll join that reactor, otherwise it'll start the reactor.
47
- #
48
- # @yieldparam [EventMachine::Channel] new_device_channel The means through
49
- # which clients can get notified when a new device has been discovered
50
- # either through SSDP searching or from an +ssdp:alive+ notification.
51
- # @yieldparam [EventMachine::Channel] old_device_channel The means through
52
- # which clients can get notified when a device has gone offline (have sent
53
- # out a +ssdp:byebye+ notification).
54
- def start(&blk)
55
- @stopping = false
56
-
57
- starter = -> do
58
- ssdp_search_and_listen(@search_target, @search_options)
59
- blk.call(@new_device_channel, @old_device_channel)
60
- @running = true
61
- end
62
-
63
- if EM.reactor_running?
64
- starter.call
65
- else
66
- EM.synchrony(&starter)
67
- end
68
- end
69
-
70
- def listen(ttl)
71
- EM.defer do
72
- listener = SSDP.listen(ttl)
73
-
74
- listener.alive_notifications.subscribe do |advertisement|
75
-
76
- if @devices.any? { |d| d.usn == advertisement[:usn] }
77
- else
78
- create_device(advertisement)
79
- end
80
- end
81
-
82
- listener.byebye_notifications.subscribe do |advertisement|
83
-
84
- @devices.reject! do |device|
85
- device.usn == advertisement[:usn]
86
- end
87
-
88
- @old_device_channel << advertisement
89
- end
90
- end
91
- end
92
-
93
- def ssdp_search_and_listen(search_for, options = {})
94
- searcher = SSDP.search(search_for, options)
95
-
96
- searcher.discovery_responses.subscribe do |notification|
97
- create_device(notification)
98
- end
99
-
100
- # Do I need to do this?
101
- EM.add_timer(options[:response_wait_time]) do
102
- searcher.close_connection
103
- listen(options[:ttl])
104
- end
105
-
106
- EM.add_periodic_timer(5) do
107
- @timer_time = Time.now
108
- puts "<#{self.class}> Device count: #{@devices.size}"
109
- puts "<#{self.class}> Device unique: #{@devices.uniq.size}"
110
- end
111
-
112
- trap_signals
113
- end
114
-
115
- def create_device(notification)
116
- deferred_device = Device.new(ssdp_notification: notification)
117
-
118
- deferred_device.errback do |partially_built_device, message|
119
- #add_device(partially_built_device)
120
-
121
- if self.class.raise_on_remote_error
122
- raise ControlPoint::Error, message
123
- end
124
- end
125
-
126
- deferred_device.callback do |built_device|
127
- add_device(built_device)
128
- end
129
-
130
- deferred_device.fetch
131
- end
132
-
133
- def add_device(built_device)
134
- if (@devices.any? { |d| d.usn == built_device.usn }) ||
135
- (@devices.any? { |d| d.udn == built_device.udn })
136
- #if @devices.any? { |d| d.usn == built_device.usn }
137
- else
138
- @new_device_channel << built_device
139
- @devices << built_device
140
- end
141
- end
142
-
143
- def stop
144
- @running = false
145
- @stopping = false
146
-
147
- EM.stop if EM.reactor_running?
148
- end
149
-
150
- def running?
151
- @running
152
- end
153
-
154
- def trap_signals
155
- trap('INT') { stop }
156
- trap('TERM') { stop }
157
- trap('HUP') { stop } if RUBY_PLATFORM !~ /mswin|mingw/
158
- end
159
- end
160
- end
161
- end
@@ -1,31 +0,0 @@
1
- require 'thin'
2
- require 'em-synchrony'
3
- require 'rack'
4
- require 'rack/lobster'
5
-
6
- module Druzy
7
- module Upnp
8
-
9
- class Device
10
-
11
- # Multicasts discovery messages to advertise its root device, any embedded
12
- # devices, and any services.
13
- def start
14
- EM.synchrony do
15
- web_server = Thin::Server.start('0.0.0.0', 3000) do
16
- use Rack::CommonLogger
17
- use Rack::ShowExceptions
18
-
19
- map '/presentation' do
20
- use Rack::Lint
21
- run Rack::Lobster.new
22
- end
23
- end
24
-
25
- # Do advertisement
26
- # Listen for subscribers
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,110 +0,0 @@
1
- require 'core_ext/socket_patch'
2
- require_relative 'network_constants'
3
- require 'ipaddr'
4
- require 'socket'
5
- require 'eventmachine'
6
-
7
-
8
- # TODO: DRY this up!! (it's mostly the same as Playful::SSDP::MulticastConnection)
9
- module Druzy
10
- module Upnp
11
- class SSDP
12
- class BroadcastSearcher < EventMachine::Connection
13
- include EventMachine::Deferrable
14
- include Druzy::Upnp::SSDP::NetworkConstants
15
-
16
- # @return [Array] The list of responses from the current discovery request.
17
- attr_reader :discovery_responses
18
-
19
- attr_reader :available_responses
20
- attr_reader :byebye_responses
21
-
22
- def initialize(search_target, response_wait_time, ttl=TTL)
23
- @ttl = ttl
24
- @discovery_responses = []
25
- @alive_notifications = []
26
- @byebye_notifications = []
27
-
28
- setup_broadcast_socket
29
-
30
- @search = m_search(search_target, response_wait_time)
31
- end
32
-
33
- def post_init
34
- if send_datagram(@search, BROADCAST_IP, MULTICAST_PORT) > 0
35
- end
36
- end
37
-
38
- def m_search(search_target, response_wait_time)
39
- <<-MSEARCH
40
- M-SEARCH * HTTP/1.1\r
41
- HOST: #{MULTICAST_IP}:#{MULTICAST_PORT}\r
42
- MAN: "ssdp:discover"\r
43
- MX: #{response_wait_time}\r
44
- ST: #{search_target}\r
45
- \r
46
- MSEARCH
47
- end
48
-
49
- # Gets the IP and port from the peer that just sent data.
50
- #
51
- # @return [Array<String,Fixnum>] The IP and port.
52
- def peer_info
53
- peer_bytes = get_peername[2, 6].unpack('nC4')
54
- port = peer_bytes.first.to_i
55
- ip = peer_bytes[1, 4].join('.')
56
-
57
- [ip, port]
58
- end
59
-
60
- def receive_data(response)
61
- ip, port = peer_info
62
- parsed_response = parse(response)
63
-
64
- if parsed_response.has_key? :nts
65
- if parsed_response[:nts] == 'ssdp:alive'
66
- @alive_notifications << parsed_response
67
- elsif parsed_response[:nts] == 'ssdp:bye-bye'
68
- @byebye_notifications << parsed_response
69
- else
70
- raise "Unknown NTS value: #{parsed_response[:nts]}"
71
- end
72
- else
73
- @discovery_responses << parsed_response
74
- end
75
- end
76
-
77
- # Converts the headers to a set of key-value pairs.
78
- #
79
- # @param [String] data The data to convert.
80
- # @return [Hash] The converted data. Returns an empty Hash if it didn't
81
- # know how to parse.
82
- def parse(data)
83
- new_data = {}
84
-
85
- unless data =~ /\n/
86
- return new_data
87
- end
88
-
89
- data.each_line do |line|
90
- line =~ /(\S*):(.*)/
91
-
92
- unless $1.nil?
93
- key = $1
94
- value = $2
95
- key = key.gsub('-', '_').downcase.to_sym
96
- new_data[key] = value.strip
97
- end
98
- end
99
-
100
- new_data
101
- end
102
-
103
- # Sets Socket options to allow for brodcasting.
104
- def setup_broadcast_socket
105
- set_sock_opt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
106
- end
107
- end
108
- end
109
- end
110
- end
@@ -1,8 +0,0 @@
1
- module Druzy
2
- module Upnp
3
- class SSDP
4
- class Error < StandardError
5
- end
6
- end
7
- end
8
- end