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.
- checksums.yaml +7 -0
- data/lib/http_header.rb +84 -0
- data/lib/ssdp.rb +168 -0
- data/lib/upnp_control_point.rb +197 -0
- data/lib/upnp_event.rb +86 -0
- data/lib/upnp_model.rb +419 -0
- data/lib/upnp_server.rb +316 -0
- data/lib/upnp_soap.rb +96 -0
- data/lib/upnp_xml.rb +47 -0
- data/lib/usn.rb +31 -0
- metadata +52 -0
data/lib/upnp_server.rb
ADDED
@@ -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('&', '&').gsub('>', '>').gsub('<', '<')
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
@text
|
46
|
+
end
|
47
|
+
end
|