rbupnptools 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,316 @@
1
+ require_relative 'upnp_model.rb'
2
+ require_relative 'ssdp.rb'
3
+ require_relative 'upnp_event.rb'
4
+ require "webrick"
5
+ require 'securerandom'
6
+ require 'time'
7
+ require 'digest'
8
+
9
+ class UPnPServerServlet < WEBrick::HTTPServlet::AbstractServlet
10
+ def do_GET(req, res)
11
+ server = @options[0]
12
+ if req.path.end_with? "device.xml"
13
+ device_description = server.get_device_description req.path
14
+ res.status = 200
15
+ res['Content-Type'] = 'text/xml; charset="utf-8"'
16
+ res.body = device_description
17
+ elsif req.path.end_with? "scpd.xml"
18
+ scpd = server.get_scpd req.path
19
+ res.status = 200
20
+ res['Content-Type'] = 'text/xml; charset="utf-8"'
21
+ res.body = scpd
22
+ end
23
+ end
24
+
25
+ def do_POST(req, res)
26
+ server = @options[0]
27
+ soap_req = UPnPSoapRequest.read req.body
28
+ soap_res = server.on_action_request req.path soap_req
29
+ res.status = 200
30
+ res['Content-Type'] = 'text/xml; charset="utf-8"'
31
+ res.body = soap_res.to_s
32
+ end
33
+
34
+ def do_SUBSCRIBE(req, res)
35
+ server = @options[0]
36
+ path = req.path
37
+ if req['SID']
38
+ server.on_renew_subscription req['SID']
39
+ res.status = 200
40
+ else
41
+ callback_urls = req['CALLBACK'].split(' ').map { |elem| elem[2..-1] }
42
+ timeout = Integer(req['TIMEOUT'].split('-')[-1])
43
+ server.on_subscribe req.path timeout, callback_urls
44
+ res.status = 200
45
+ end
46
+ end
47
+
48
+ def do_UNSUBSCRIBE(req, res)
49
+ server = @options[0]
50
+ server.on_unsubscribe req['SID']
51
+ res.status = 200
52
+ end
53
+ end
54
+
55
+
56
+ class UPnPServer
57
+
58
+ def initialize(host='0.0.0.0', port=0)
59
+ @finishing = false
60
+ @devices = {}
61
+ @ssdp_listener = SSDP::SsdpListener.new
62
+ @ssdp_listener.handler = self
63
+ @http_server = WEBrick::HTTPServer.new :BindAddress => host, :Port => port
64
+ @http_server.mount '/', UPnPServerServlet, self
65
+ @action_handler = nil
66
+ @subscriptions = {}
67
+ @interval_timer = 10
68
+ end
69
+
70
+ attr_accessor :action_handler, :devices
71
+
72
+ def register_device(device, scpd_table)
73
+ device.all_services.each do |service|
74
+ hash = get_hash service.service_type
75
+ service.scpdurl = "/#{hash}/scpd.xml"
76
+ service.control_url = "/#{hash}/control.xml"
77
+ service.event_sub_url = "/#{hash}/event.xml"
78
+ service.scpd = scpd_table[service.service_type]
79
+ end
80
+ @devices[device.udn] = device
81
+ end
82
+
83
+
84
+ def unregister_device(device)
85
+ @devices.remove device.udn
86
+ end
87
+
88
+
89
+ def notify_alive_all
90
+ end
91
+
92
+
93
+ def get_device_description(path)
94
+ hash = path.split('/').reject(&:empty?).first
95
+ @devices.each do |udn, device|
96
+ if get_hash(udn) == hash
97
+ return UPnPDevice.to_xml_doc device
98
+ end
99
+ end
100
+ end
101
+
102
+
103
+ def get_scpd(path)
104
+ hash = path.split('/').reject(&:empty?).first
105
+ @devices.values.each do |device|
106
+ device.all_services.each do |service|
107
+ if get_hash(service.service_type) == hash
108
+ return UPnPScpd.to_xml_doc service.scpd
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+
115
+ def on_action_request(path, soap_req)
116
+ hash = path.split('/').reject(&:empty?).first
117
+ @devices.values.each do |device|
118
+ device.all_services.each do |service|
119
+ if get_hash(service.service_type) == hash
120
+ if @action_handler
121
+ return @action_handler.call service, soap_req
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ end
128
+
129
+ def set_property(device, service, props)
130
+ @subscriptions.values.each do |subscription|
131
+ if subscription.device.udn == device.udn and
132
+ subscription.service.service_type == service.service_type
133
+ subscription.callback_urls.each do |url|
134
+ url = URI.parse(url)
135
+ header = {
136
+ 'SID' => subscription.sid
137
+ }
138
+ Net::HTTP.start(url.host, url.port) do |http|
139
+ req = NotifyRequest.new url, initheader = header
140
+ req.body = UPnPEventProperty.to_xml_doc props
141
+ res = http.request req
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ def on_subscribe(path, timeout, callback_urls)
149
+ hash = path.split('/').reject(&:empty?).first
150
+ @devices.values.each do |device|
151
+ device.all_services.each do |service|
152
+ if get_hash(service.service_type) == hash
153
+ sid = 'uuid:' + SecureRandom.uuid
154
+ subscription = UPnPEventSubscription.new device, service, sid, timeout, callback_urls
155
+ @subscriptions[sid] = subscription
156
+ return sid
157
+ end
158
+ end
159
+ end
160
+ nil
161
+ end
162
+
163
+
164
+ def on_renew_subscription(sid)
165
+ subscription = @subscriptions[sid]
166
+ subscription.renew_timeout
167
+ end
168
+
169
+
170
+ def on_unsubscribe(sid)
171
+ @subscriptions.remove sid
172
+ end
173
+
174
+
175
+ def on_ssdp_header(ssdp_header)
176
+ if ssdp_header.msearch?
177
+ ret = on_msearch ssdp_header['st']
178
+ end
179
+ end
180
+
181
+
182
+ def on_msearch(st)
183
+
184
+ # ST can be one of
185
+ # - ssdp:all
186
+ # - upnp:rootdevice
187
+ # - udn
188
+ # - device type
189
+ # - service type
190
+
191
+ # HTTP/1.1 200 OK
192
+ # Cache-Control: max-age=1800
193
+ # HOST: 239.255.255.250:1900
194
+ # Location: http://172.17.0.2:9001/device.xml?udn=e399855c-7ecb-1fff-8000-000000000000
195
+ # ST: e399855c-7ecb-1fff-8000-000000000000
196
+ # Server: Cross-Platform/0.0 UPnP/1.0 App/0.0
197
+ # USN: e399855c-7ecb-1fff-8000-000000000000
198
+ # Ext:
199
+ # Date: Sat, 08 Sep 2018 13:47:14 GMT
200
+
201
+ response_list = []
202
+
203
+ if st == 'ssdp:all'
204
+ devices = @devices.values
205
+ devices.each do |root_device|
206
+
207
+ location = get_device_location root_device
208
+
209
+ response_list << msearch_response('upnp:rootdevice',
210
+ root_device.udn + '::upnp:rootdevice',
211
+ location)
212
+
213
+ root_device.all_devices.each do |device|
214
+ response_list << msearch_response(device.device_type,
215
+ device.usn,
216
+ location)
217
+ end
218
+
219
+ root_device.all_services.each do |service|
220
+ response_list << msearch_response(service.service_type,
221
+ root_device.udn + '::' + service.service_type,
222
+ location)
223
+ end
224
+ end
225
+ elsif st == 'upnp:rootdevice'
226
+ devices = @devices.values
227
+ devices.each do |root_device|
228
+
229
+ location = get_device_location root_device
230
+
231
+ response_list << msearch_response('upnp:rootdevice',
232
+ root_device.udn + '::upnp:rootdevice',
233
+ location)
234
+ end
235
+ else
236
+ @devices.values.each do |root_device|
237
+ location = get_device_location root_device
238
+ root_device.all_devices.each do |device|
239
+ if device.device_type == st
240
+ response_list << msearch_response(st, device.usn, location)
241
+ end
242
+ end
243
+ root_device.all_services.each do |service|
244
+ if service.service_type == st
245
+ response_list << msearch_response(st,
246
+ root_device.udn + '::' + service.service_type,
247
+ location)
248
+ end
249
+ end
250
+ end
251
+ end
252
+
253
+ return response_list
254
+ end
255
+
256
+ def get_device_location(device)
257
+ host = get_ip_address
258
+ port = @http_server.config[:Port]
259
+ return "http://#{host}:#{port}/#{get_hash(device.udn)}/device.xml"
260
+ end
261
+
262
+ def msearch_response(st, usn, location, ext_header = nil)
263
+ header = HttpHeader.new FirstLine.new ['HTTP/1.1', '200', 'OK']
264
+ fields = {
265
+ 'Cache-Control' => 'max-age=1800',
266
+ 'Location' => location,
267
+ 'ST' => st,
268
+ 'USN' => usn,
269
+ 'Ext' => '',
270
+ 'Date' => Time.now.httpdate,
271
+ }
272
+ header.update! fields
273
+ if ext_header != nil
274
+ header.update! ext_header
275
+ end
276
+ return header
277
+ end
278
+
279
+ def get_hash(udn)
280
+ Digest::MD5.hexdigest udn
281
+ end
282
+
283
+ def get_ip_address
284
+ Socket::getaddrinfo(Socket.gethostname, 'echo', Socket::AF_INET)[0][3]
285
+ end
286
+
287
+ def on_timer
288
+ @subscriptions.reject! {|key, value| value.expired?}
289
+ notify_alive_all
290
+ end
291
+
292
+ def timer_loop
293
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
294
+ while not @finishing
295
+ dur = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) - start
296
+ if dur >= @interval_timer
297
+ on_timer
298
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
299
+ end
300
+ end
301
+ end
302
+
303
+ def start
304
+ @finishing = false
305
+ @ssdp_listener_thread = Thread.new { @ssdp_listener.run }
306
+ @http_server_thread = Thread.new { @http_server.start }
307
+ @timer_thread = Thread.new { timer_loop }
308
+ end
309
+
310
+ def stop
311
+ @finishing = true
312
+ @http_server.shutdown
313
+ @http_server_thread.join
314
+ @timer_thread.join
315
+ end
316
+ end
data/lib/upnp_soap.rb ADDED
@@ -0,0 +1,96 @@
1
+ require 'nokogiri'
2
+ require_relative 'upnp_xml.rb'
3
+
4
+ class UPnPSoapRequest < Hash
5
+
6
+ def initialize(service_type = nil, action_name = nil)
7
+ @service_type = service_type
8
+ @action_name = action_name
9
+ end
10
+
11
+ attr_accessor :service_type, :action_name
12
+ def to_xml
13
+ tag = XmlTag.new 's:Envelope'
14
+ tag.attributes = {
15
+ 's:encodingStyle' => "http://schemas.xmlsoap.org/soap/encoding/",
16
+ 'xmlns:s' => "http://schemas.xmlsoap.org/soap/envelope/"
17
+ }
18
+ body = tag.append XmlTag.new('s:Body')
19
+ action = body.append XmlTag.new("u:#{@action_name}")
20
+ action.attributes = {
21
+ 'xmlns:u' => @service_type
22
+ }
23
+ self.each do |k,v|
24
+ prop = action.append XmlTag.new k
25
+ prop.append XmlText.new v
26
+ end
27
+
28
+ return tag.to_s
29
+ end
30
+ end
31
+
32
+ def UPnPSoapRequest.to_xml_doc(soap_req)
33
+ return '<?xml version="1.0" encoding="utf-8"?>' + "\n#{soap_req.to_xml}"
34
+ end
35
+
36
+ def UPnPSoapRequest.read(xml)
37
+ soap_req = UPnPSoapRequest.new
38
+ doc = Nokogiri::XML(xml)
39
+ action_elem = doc.root.first_element_child.first_element_child
40
+ soap_req.service_type = action_elem.namespace.href
41
+ soap_req.action_name = action_elem.name
42
+ action_elem.elements.each do |node|
43
+ name = node.name
44
+ value = node.text
45
+ soap_req[name] = value
46
+ end
47
+ soap_req
48
+ end
49
+
50
+
51
+ class UPnPSoapResponse < Hash
52
+
53
+ def initialize(service_type = nil, action_name = nil)
54
+ @service_type = service_type
55
+ @action_name = action_name
56
+ end
57
+
58
+ attr_accessor :service_type, :action_name
59
+
60
+ def to_xml
61
+ tag = XmlTag.new 's:Envelope'
62
+ tag.attributes = {
63
+ 's:encodingStyle' => "http://schemas.xmlsoap.org/soap/encoding/",
64
+ 'xmlns:s' => "http://schemas.xmlsoap.org/soap/envelope/"
65
+ }
66
+ body = tag.append XmlTag.new('s:Body')
67
+ action = body.append XmlTag.new("u:#{@action_name}Response")
68
+ action.attributes = {
69
+ 'xmlns:u' => @service_type
70
+ }
71
+ self.each do |k,v|
72
+ prop = action.append XmlTag.new(k)
73
+ prop.append XmlText.new(v)
74
+ end
75
+
76
+ return tag.to_s
77
+ end
78
+ end
79
+
80
+ def UPnPSoapResponse.to_xml_doc(soap_res)
81
+ return '<?xml version="1.0" encoding="utf-8"?>' + "\n#{soap_res.to_xml}"
82
+ end
83
+
84
+ def UPnPSoapResponse.read(xml)
85
+ soap_res = UPnPSoapResponse.new
86
+ doc = Nokogiri::XML(xml)
87
+ action_elem = doc.root.first_element_child.first_element_child
88
+ soap_res.service_type = action_elem.namespace.href
89
+ soap_res.action_name = action_elem.name[0..-'Response'.length]
90
+ action_elem.elements.each do |node|
91
+ name = node.name
92
+ value = node.text
93
+ soap_res[name] = value
94
+ end
95
+ soap_res
96
+ end
data/lib/upnp_xml.rb ADDED
@@ -0,0 +1,47 @@
1
+
2
+
3
+ class XmlTag
4
+
5
+ def initialize(name)
6
+ @name = name
7
+ @children = []
8
+ @attributes = {}
9
+ end
10
+
11
+ attr_accessor :name, :attributes, :children
12
+
13
+ def append(tag)
14
+ @children.append(tag)
15
+ return tag
16
+ end
17
+
18
+ def to_s
19
+ elems = [@name] + @attributes.map {|k,v| "#{k}=\"#{v}\""}
20
+
21
+ if @children.any?
22
+ str = "<#{elems.join(' ')}>"
23
+ str += @children.each {|elem| "#{elem}"}.join("")
24
+ str += "</#{@name}>"
25
+ return str
26
+ else
27
+ return "<#{elems.join(' ')} />"
28
+ end
29
+ end
30
+
31
+ end
32
+
33
+
34
+ class XmlText
35
+ def initialize(text)
36
+ @text = "#{text}"
37
+ @text = escape(@text)
38
+ end
39
+
40
+ def escape(text)
41
+ text.gsub('&', '&amp;').gsub('>', '&gt;').gsub('<', '&lt;')
42
+ end
43
+
44
+ def to_s
45
+ @text
46
+ end
47
+ end