UPnP 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data.tar.gz.sig +0 -0
- data/.autotest +30 -0
- data/History.txt +6 -0
- data/Manifest.txt +18 -0
- data/README.txt +95 -0
- data/Rakefile +12 -0
- data/bin/upnp_discover +82 -0
- data/bin/upnp_listen +26 -0
- data/lib/UPnP.rb +35 -0
- data/lib/UPnP/SSDP.rb +495 -0
- data/lib/UPnP/control.rb +11 -0
- data/lib/UPnP/control/device.rb +238 -0
- data/lib/UPnP/control/service.rb +461 -0
- data/test/test_UPnP_SSDP.rb +237 -0
- data/test/test_UPnP_SSDP_notification.rb +74 -0
- data/test/test_UPnP_SSDP_response.rb +30 -0
- data/test/test_UPnP_control_device.rb +72 -0
- data/test/test_UPnP_control_service.rb +108 -0
- data/test/utilities.rb +1028 -0
- metadata +109 -0
- metadata.gz.sig +1 -0
data.tar.gz.sig
ADDED
Binary file
|
data/.autotest
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# vim: filetype=ruby
|
2
|
+
|
3
|
+
Autotest.add_hook :initialize do |at|
|
4
|
+
|
5
|
+
at.add_mapping %r%^lib/UPnP/SSDP.rb$% do
|
6
|
+
['test/test_UPnP_SSDP.rb',
|
7
|
+
'test/test_UPnP_SSDP_notification.rb',
|
8
|
+
'test/test_UPnP_SSDP_response.rb',
|
9
|
+
]
|
10
|
+
end
|
11
|
+
|
12
|
+
at.add_mapping %r%^lib/UPnP/control/(\w+).rb$% do |_,m|
|
13
|
+
"test/test_UPnP_Control_#{m[1].capitalize}.rb"
|
14
|
+
end
|
15
|
+
|
16
|
+
at.add_mapping %r%^test/utilities.rb$% do
|
17
|
+
at.known_files
|
18
|
+
end
|
19
|
+
|
20
|
+
at.extra_class_map["TestUPnPControlDevice"] =
|
21
|
+
'test/test_UPnP_control_device.rb'
|
22
|
+
at.extra_class_map["TestUPnPSSDP"] = 'test/test_UPnP_SSDP.rb'
|
23
|
+
at.extra_class_map["TestUPnPSSDPNotification"] =
|
24
|
+
'test/test_UPnP_SSDP_notification.rb'
|
25
|
+
at.extra_class_map["TestUPnPSSDPResponse"] =
|
26
|
+
'test/test_UPnP_SSDP_response.rb'
|
27
|
+
at.extra_class_map["TestUPnPControlService"] =
|
28
|
+
'test/test_UPnP_control_service.rb'
|
29
|
+
end
|
30
|
+
|
data/History.txt
ADDED
data/Manifest.txt
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
.autotest
|
2
|
+
History.txt
|
3
|
+
Manifest.txt
|
4
|
+
README.txt
|
5
|
+
Rakefile
|
6
|
+
bin/upnp_discover
|
7
|
+
bin/upnp_listen
|
8
|
+
lib/UPnP.rb
|
9
|
+
lib/UPnP/SSDP.rb
|
10
|
+
lib/UPnP/control.rb
|
11
|
+
lib/UPnP/control/device.rb
|
12
|
+
lib/UPnP/control/service.rb
|
13
|
+
test/test_UPnP_SSDP.rb
|
14
|
+
test/test_UPnP_SSDP_notification.rb
|
15
|
+
test/test_UPnP_SSDP_response.rb
|
16
|
+
test/test_UPnP_control_device.rb
|
17
|
+
test/test_UPnP_control_service.rb
|
18
|
+
test/utilities.rb
|
data/README.txt
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
= UPnP
|
2
|
+
|
3
|
+
* http://seattlerb.org/UPnP
|
4
|
+
* http://upnp.org
|
5
|
+
* Bugs: http://rubyforge.org/tracker/?atid=5921&group_id=1513
|
6
|
+
|
7
|
+
== DESCRIPTION:
|
8
|
+
|
9
|
+
An implementation of the UPnP protocol
|
10
|
+
|
11
|
+
== FEATURES/PROBLEMS:
|
12
|
+
|
13
|
+
* Discovers UPnP devices and services via SSDP, see UPnP::SSDP
|
14
|
+
* Creates a SOAP RPC driver for discovered services, see UPnP::Control::Service
|
15
|
+
* Creates concrete UPnP device and service classes that may be extended with
|
16
|
+
utility methods, see UPnP::Control::Device::create,
|
17
|
+
UPnP::Control::Service::create and the UPnP-IGD gem.
|
18
|
+
* Eventing not implemented
|
19
|
+
* Servers not implemented
|
20
|
+
|
21
|
+
== SYNOPSIS:
|
22
|
+
|
23
|
+
Print out information about UPnP devices nearby:
|
24
|
+
|
25
|
+
upnp_discover
|
26
|
+
|
27
|
+
Listen for UPnP resource notifications:
|
28
|
+
|
29
|
+
upnp_listen
|
30
|
+
|
31
|
+
Search for root UPnP devices and print out their description URLs:
|
32
|
+
|
33
|
+
require 'UPnP/SSDP'
|
34
|
+
|
35
|
+
resources = UPnP::SSDP.new.search :root
|
36
|
+
locations = resources.map { |resource| resource.location }
|
37
|
+
puts locations.join("\n")
|
38
|
+
|
39
|
+
Create a UPnP::Control::Device from the first discovered root device:
|
40
|
+
|
41
|
+
require 'UPnP/control/device'
|
42
|
+
|
43
|
+
device = UPnP::Control::Device.create locations.first
|
44
|
+
|
45
|
+
Enumerate actions on all services on the device:
|
46
|
+
|
47
|
+
service_names = device.services.map do |service|
|
48
|
+
service.methods(false)
|
49
|
+
end
|
50
|
+
|
51
|
+
puts service_names.sort.join("\n")
|
52
|
+
|
53
|
+
Assuming the root device is an InternetGatewayDevice with a WANIPConnection
|
54
|
+
service, print out the external IP address for the gateway:
|
55
|
+
|
56
|
+
wic = device.services.find { |service| service.type =~ /WANIPConnection/ }
|
57
|
+
puts wic.GetExternalIPAddress
|
58
|
+
|
59
|
+
== REQUIREMENTS:
|
60
|
+
|
61
|
+
* UPnP devices
|
62
|
+
|
63
|
+
== INSTALL:
|
64
|
+
|
65
|
+
sudo gem install UPnP
|
66
|
+
|
67
|
+
== LICENSE:
|
68
|
+
|
69
|
+
All code copyright 2008 Eric Hodel. All rights reserved.
|
70
|
+
|
71
|
+
Redistribution and use in source and binary forms, with or without
|
72
|
+
modification, are permitted provided that the following conditions
|
73
|
+
are met:
|
74
|
+
|
75
|
+
1. Redistributions of source code must retain the above copyright
|
76
|
+
notice, this list of conditions and the following disclaimer.
|
77
|
+
2. Redistributions in binary form must reproduce the above copyright
|
78
|
+
notice, this list of conditions and the following disclaimer in the
|
79
|
+
documentation and/or other materials provided with the distribution.
|
80
|
+
3. Neither the names of the authors nor the names of their contributors
|
81
|
+
may be used to endorse or promote products derived from this software
|
82
|
+
without specific prior written permission.
|
83
|
+
|
84
|
+
THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS
|
85
|
+
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
86
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
87
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE
|
88
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
89
|
+
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
|
90
|
+
OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
91
|
+
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
92
|
+
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
93
|
+
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
94
|
+
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
95
|
+
|
data/Rakefile
ADDED
data/bin/upnp_discover
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'UPnP/SSDP'
|
4
|
+
require 'UPnP/control'
|
5
|
+
|
6
|
+
ssdp = UPnP::SSDP.new
|
7
|
+
timeout = ARGV.shift
|
8
|
+
timeout = if timeout then
|
9
|
+
begin
|
10
|
+
timeout = Integer timeout
|
11
|
+
rescue
|
12
|
+
abort <<-EOF
|
13
|
+
Usage: #{$0} [timeout]
|
14
|
+
|
15
|
+
Prints information about UPnP internet gateway devices
|
16
|
+
EOF
|
17
|
+
end
|
18
|
+
else
|
19
|
+
1
|
20
|
+
end
|
21
|
+
|
22
|
+
ssdp.timeout = timeout
|
23
|
+
|
24
|
+
devices = ssdp.search(:root).map do |resource|
|
25
|
+
UPnP::Control::Device.new resource.location
|
26
|
+
end
|
27
|
+
|
28
|
+
if devices.empty? then
|
29
|
+
puts 'No UPnP devices found'
|
30
|
+
exit
|
31
|
+
end
|
32
|
+
|
33
|
+
def print_device(device, indent = ' ')
|
34
|
+
out = []
|
35
|
+
|
36
|
+
out << "Friendly name: #{device.friendly_name}"
|
37
|
+
out << "Presentation URL: #{device.presentation_url}"
|
38
|
+
out << nil
|
39
|
+
out << "Unique device name: #{device.name}"
|
40
|
+
out << nil
|
41
|
+
out << "Manufacturer: #{device.manufacturer}"
|
42
|
+
out << "Manufacturer URL: #{device.manufacturer_url}"
|
43
|
+
out << nil
|
44
|
+
out << "Model name: #{device.model_name}"
|
45
|
+
out << "Model description: #{device.model_description}"
|
46
|
+
out << "Model URL: #{device.model_url}"
|
47
|
+
out << "UPC: #{device.upc}"
|
48
|
+
out << "Serial number: #{device.serial_number}"
|
49
|
+
out << nil
|
50
|
+
|
51
|
+
puts indent + out.join("\n#{indent}")
|
52
|
+
end
|
53
|
+
|
54
|
+
devices.each do |device|
|
55
|
+
type = device.type.sub "#{UPnP::DEVICE_SCHEMA_PREFIX}:", ''
|
56
|
+
puts "#{device.url}: #{type}"
|
57
|
+
print_device device
|
58
|
+
|
59
|
+
device.devices.each do |sub_device|
|
60
|
+
type = sub_device.type.sub "#{UPnP::DEVICE_SCHEMA_PREFIX}:", ''
|
61
|
+
puts " Sub-device #{type}:"
|
62
|
+
print_device sub_device, ' '
|
63
|
+
end
|
64
|
+
|
65
|
+
device.services.each do |service|
|
66
|
+
type = service.type.sub("#{UPnP::DEVICE_SCHEMA_PREFIX}:", '')
|
67
|
+
puts " Service: #{type}"
|
68
|
+
puts " Id: #{service.id}"
|
69
|
+
puts " Type: #{service.type}"
|
70
|
+
puts " SCPD URL: #{service.scpd_url}"
|
71
|
+
puts " Control URL: #{service.control_url}"
|
72
|
+
puts " Event subscription URL: #{service.event_sub_url}"
|
73
|
+
puts
|
74
|
+
puts " Actions:"
|
75
|
+
service.driver.methods(false).sort.each do |method|
|
76
|
+
puts " #{method}"
|
77
|
+
end
|
78
|
+
|
79
|
+
puts
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
data/bin/upnp_listen
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'UPnP/SSDP'
|
4
|
+
require 'UPnP/control'
|
5
|
+
|
6
|
+
ssdp = UPnP::SSDP.new
|
7
|
+
|
8
|
+
ssdp.discover do |notification|
|
9
|
+
schemas = Regexp.union UPnP::DEVICE_SCHEMA_PREFIX, UPnP::SERVICE_SCHEMA_PREFIX
|
10
|
+
|
11
|
+
type = notification.type.sub(/#{schemas}:/, '')
|
12
|
+
|
13
|
+
if notification.alive? then
|
14
|
+
puts "#{type} is alive"
|
15
|
+
puts "Description: #{notification.location}"
|
16
|
+
expiration = notification.expiration.strftime '%c'
|
17
|
+
puts "Valid until #{expiration}"
|
18
|
+
else
|
19
|
+
puts "#{type} says byebye"
|
20
|
+
end
|
21
|
+
|
22
|
+
puts "USN: #{notification.name}"
|
23
|
+
|
24
|
+
puts
|
25
|
+
end
|
26
|
+
|
data/lib/UPnP.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
$KCODE = 'u'
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
gem 'soap4r'
|
5
|
+
|
6
|
+
##
|
7
|
+
# An implementation of the Universal Plug and Play protocol.
|
8
|
+
#
|
9
|
+
# http://upnp.org/
|
10
|
+
|
11
|
+
module UPnP
|
12
|
+
|
13
|
+
##
|
14
|
+
# UPnP device schema prefix
|
15
|
+
|
16
|
+
DEVICE_SCHEMA_PREFIX = 'urn:schemas-upnp-org:device'
|
17
|
+
|
18
|
+
##
|
19
|
+
# UPnP service schema prefix
|
20
|
+
|
21
|
+
SERVICE_SCHEMA_PREFIX = 'urn:schemas-upnp-org:service'
|
22
|
+
|
23
|
+
##
|
24
|
+
# The version of UPnP you are using
|
25
|
+
|
26
|
+
VERSION = '1.0.0'
|
27
|
+
|
28
|
+
##
|
29
|
+
# UPnP error base class
|
30
|
+
|
31
|
+
class Error < RuntimeError
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
|
data/lib/UPnP/SSDP.rb
ADDED
@@ -0,0 +1,495 @@
|
|
1
|
+
require 'ipaddr'
|
2
|
+
require 'socket'
|
3
|
+
require 'thread'
|
4
|
+
require 'time'
|
5
|
+
require 'uri'
|
6
|
+
|
7
|
+
require 'UPnP'
|
8
|
+
require 'UPnP/control'
|
9
|
+
|
10
|
+
##
|
11
|
+
# Simple Service Discovery Protocol for the UPnP Device Architecture.
|
12
|
+
#
|
13
|
+
# Currently SSDP only handles the discovery portions of SSDP.
|
14
|
+
#
|
15
|
+
# To listen for SSDP notifications from UPnP devices:
|
16
|
+
#
|
17
|
+
# ssdp = SSDP.new
|
18
|
+
# notifications = ssdp.listen
|
19
|
+
#
|
20
|
+
# To discover all devices and services:
|
21
|
+
#
|
22
|
+
# ssdp = SSDP.new
|
23
|
+
# resources = ssdp.search
|
24
|
+
#
|
25
|
+
# After a device has been found you can create a Device object for it:
|
26
|
+
#
|
27
|
+
# UPnP::Control::Device.create resource.location
|
28
|
+
#
|
29
|
+
# Based on code by Kazuhiro NISHIYAMA (zn@mbf.nifty.com)
|
30
|
+
|
31
|
+
class UPnP::SSDP
|
32
|
+
|
33
|
+
##
|
34
|
+
# SSDP Error class
|
35
|
+
|
36
|
+
class Error < UPnP::Error
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# Abstract class for SSDP advertisements
|
41
|
+
|
42
|
+
class Advertisement
|
43
|
+
|
44
|
+
##
|
45
|
+
# Expiration time of this advertisement
|
46
|
+
|
47
|
+
def expiration
|
48
|
+
date + max_age
|
49
|
+
end
|
50
|
+
|
51
|
+
##
|
52
|
+
# True if this advertisement has expired
|
53
|
+
|
54
|
+
def expired?
|
55
|
+
Time.now > expiration
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
##
|
61
|
+
# Holds information about a NOTIFY message. For an alive notification, all
|
62
|
+
# fields will be present. For a byebye notification, location, max_age and
|
63
|
+
# server will be nil.
|
64
|
+
|
65
|
+
class Notification < Advertisement
|
66
|
+
|
67
|
+
##
|
68
|
+
# Date the notification was received
|
69
|
+
|
70
|
+
attr_reader :date
|
71
|
+
|
72
|
+
##
|
73
|
+
# Host the notification was sent from
|
74
|
+
|
75
|
+
attr_reader :host
|
76
|
+
|
77
|
+
##
|
78
|
+
# Port the notification was sent from
|
79
|
+
|
80
|
+
attr_reader :port
|
81
|
+
|
82
|
+
##
|
83
|
+
# Location of the advertised service or device
|
84
|
+
|
85
|
+
attr_reader :location
|
86
|
+
|
87
|
+
##
|
88
|
+
# Maximum age the advertisement is valid for
|
89
|
+
|
90
|
+
attr_reader :max_age
|
91
|
+
|
92
|
+
##
|
93
|
+
# Unique Service Name of the advertisement
|
94
|
+
|
95
|
+
attr_reader :name
|
96
|
+
|
97
|
+
##
|
98
|
+
# Type of the advertised service or device
|
99
|
+
|
100
|
+
attr_reader :type
|
101
|
+
|
102
|
+
##
|
103
|
+
# Server name and version of the advertised service or device
|
104
|
+
|
105
|
+
attr_reader :server
|
106
|
+
|
107
|
+
##
|
108
|
+
# \Notification sub-type
|
109
|
+
|
110
|
+
attr_reader :sub_type
|
111
|
+
|
112
|
+
##
|
113
|
+
# Parses a NOTIFY advertisement into its component pieces
|
114
|
+
|
115
|
+
def self.parse(advertisement)
|
116
|
+
advertisement = advertisement.gsub "\r", ''
|
117
|
+
|
118
|
+
advertisement =~ /^host:\s*(\S*)/i
|
119
|
+
host, port = $1.split ':'
|
120
|
+
|
121
|
+
advertisement =~ /^nt:\s*(\S*)/i
|
122
|
+
type = $1
|
123
|
+
|
124
|
+
advertisement =~ /^nts:\s*(\S*)/i
|
125
|
+
sub_type = $1
|
126
|
+
|
127
|
+
advertisement =~ /^usn:\s*(\S*)/i
|
128
|
+
name = $1
|
129
|
+
|
130
|
+
if sub_type == 'ssdp:alive' then
|
131
|
+
advertisement =~ /^cache-control:\s*max-age\s*=\s*(\d+)/i
|
132
|
+
max_age = Integer $1
|
133
|
+
|
134
|
+
advertisement =~ /^location:\s*(\S*)/i
|
135
|
+
location = URI.parse $1
|
136
|
+
|
137
|
+
advertisement =~ /^server:\s*(.*)/i
|
138
|
+
server = $1.strip
|
139
|
+
end
|
140
|
+
|
141
|
+
new Time.now, max_age, host, port, location, type, sub_type, server, name
|
142
|
+
end
|
143
|
+
|
144
|
+
##
|
145
|
+
# Creates a \new Notification
|
146
|
+
|
147
|
+
def initialize(date, max_age, host, port, location, type, sub_type,
|
148
|
+
server, name)
|
149
|
+
@date = date
|
150
|
+
@max_age = max_age
|
151
|
+
@host = host
|
152
|
+
@port = port
|
153
|
+
@location = location
|
154
|
+
@type = type
|
155
|
+
@sub_type = sub_type
|
156
|
+
@server = server
|
157
|
+
@name = name
|
158
|
+
end
|
159
|
+
|
160
|
+
##
|
161
|
+
# Returns true if this is a notification for a resource being alive
|
162
|
+
|
163
|
+
def alive?
|
164
|
+
sub_type == 'ssdp:alive'
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
# Returns true if this is a notification for a resource going away
|
169
|
+
|
170
|
+
def byebye?
|
171
|
+
sub_type == 'ssdp:byebye'
|
172
|
+
end
|
173
|
+
|
174
|
+
##
|
175
|
+
# A friendlier inspect
|
176
|
+
|
177
|
+
def inspect
|
178
|
+
location = " #{@location}" if @location
|
179
|
+
"#<#{self.class}:0x#{object_id.to_s 16} #{@type} #{@sub_type}#{location}>"
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
183
|
+
|
184
|
+
##
|
185
|
+
# Holds information about a M-SEARCH response
|
186
|
+
|
187
|
+
class Response < Advertisement
|
188
|
+
|
189
|
+
##
|
190
|
+
# Date response was created or received
|
191
|
+
|
192
|
+
attr_reader :date
|
193
|
+
|
194
|
+
##
|
195
|
+
# true if MAN header was understood
|
196
|
+
|
197
|
+
attr_reader :ext
|
198
|
+
|
199
|
+
##
|
200
|
+
# URI where this device or service is described
|
201
|
+
|
202
|
+
attr_reader :location
|
203
|
+
|
204
|
+
##
|
205
|
+
# Maximum age this advertisement is valid for
|
206
|
+
|
207
|
+
attr_reader :max_age
|
208
|
+
|
209
|
+
##
|
210
|
+
# Unique Service Name
|
211
|
+
|
212
|
+
attr_reader :name
|
213
|
+
|
214
|
+
##
|
215
|
+
# Server version string
|
216
|
+
|
217
|
+
attr_reader :server
|
218
|
+
|
219
|
+
##
|
220
|
+
# Search target
|
221
|
+
|
222
|
+
attr_reader :target
|
223
|
+
|
224
|
+
##
|
225
|
+
# Creates a new Response by parsing the text in +response+
|
226
|
+
|
227
|
+
def self.parse(response)
|
228
|
+
response =~ /^cache-control:\s*max-age\s*=\s*(\d+)/i
|
229
|
+
max_age = Integer $1
|
230
|
+
|
231
|
+
response =~ /^date:\s*(.*)/i
|
232
|
+
date = $1 ? Time.parse($1) : Time.now
|
233
|
+
|
234
|
+
ext = !!(response =~ /^ext:/i)
|
235
|
+
|
236
|
+
response =~ /^location:\s*(\S*)/i
|
237
|
+
location = URI.parse $1.strip
|
238
|
+
|
239
|
+
response =~ /^server:\s*(.*)/i
|
240
|
+
server = $1.strip
|
241
|
+
|
242
|
+
response =~ /^st:\s*(\S*)/i
|
243
|
+
target = $1.strip
|
244
|
+
|
245
|
+
response =~ /^usn:\s*(\S*)/i
|
246
|
+
name = $1.strip
|
247
|
+
|
248
|
+
new date, max_age, location, server, target, name, ext
|
249
|
+
end
|
250
|
+
|
251
|
+
##
|
252
|
+
# Creates a new Response
|
253
|
+
|
254
|
+
def initialize(date, max_age, location, server, target, name, ext)
|
255
|
+
@date = date
|
256
|
+
@max_age = max_age
|
257
|
+
@location = location
|
258
|
+
@server = server
|
259
|
+
@target = target
|
260
|
+
@name = name
|
261
|
+
@ext = ext
|
262
|
+
end
|
263
|
+
|
264
|
+
##
|
265
|
+
# A friendlier inspect
|
266
|
+
|
267
|
+
def inspect
|
268
|
+
"#<#{self.class}:0x#{object_id.to_s 16} #{target} #{location}>"
|
269
|
+
end
|
270
|
+
|
271
|
+
end
|
272
|
+
|
273
|
+
##
|
274
|
+
# Default broadcast address
|
275
|
+
|
276
|
+
BROADCAST = '239.255.255.250'
|
277
|
+
|
278
|
+
##
|
279
|
+
# Default port
|
280
|
+
|
281
|
+
PORT = 1900
|
282
|
+
|
283
|
+
##
|
284
|
+
# Default timeout
|
285
|
+
|
286
|
+
TIMEOUT = 1
|
287
|
+
|
288
|
+
##
|
289
|
+
# Default packet time to live (hops)
|
290
|
+
|
291
|
+
TTL = 4
|
292
|
+
|
293
|
+
##
|
294
|
+
# Broadcast address to use when sending searches and listening for
|
295
|
+
# notifications
|
296
|
+
|
297
|
+
attr_accessor :broadcast
|
298
|
+
|
299
|
+
##
|
300
|
+
# Listener accessor for tests.
|
301
|
+
|
302
|
+
attr_accessor :listener # :nodoc:
|
303
|
+
|
304
|
+
##
|
305
|
+
# Port to use for SSDP searching and listening
|
306
|
+
|
307
|
+
attr_accessor :port
|
308
|
+
|
309
|
+
##
|
310
|
+
# Queue accessor for tests
|
311
|
+
|
312
|
+
attr_accessor :queue # :nodoc:
|
313
|
+
|
314
|
+
##
|
315
|
+
# Socket accessor for tests
|
316
|
+
|
317
|
+
attr_accessor :socket # :nodoc:
|
318
|
+
|
319
|
+
##
|
320
|
+
# Time to wait for SSDP responses
|
321
|
+
|
322
|
+
attr_accessor :timeout
|
323
|
+
|
324
|
+
##
|
325
|
+
# TTL for SSDP packets
|
326
|
+
|
327
|
+
attr_accessor :ttl
|
328
|
+
|
329
|
+
##
|
330
|
+
# Creates a new SSDP object. Use the accessors to override broadcast, port,
|
331
|
+
# timeout or ttl.
|
332
|
+
|
333
|
+
def initialize
|
334
|
+
@broadcast = BROADCAST
|
335
|
+
@port = PORT
|
336
|
+
@timeout = TIMEOUT
|
337
|
+
@ttl = TTL
|
338
|
+
|
339
|
+
@listener = nil
|
340
|
+
@queue = Queue.new
|
341
|
+
end
|
342
|
+
|
343
|
+
##
|
344
|
+
# Discovers UPnP devices sending NOTIFY broadcasts.
|
345
|
+
#
|
346
|
+
# If given a block, yields each Notification as it is received and never
|
347
|
+
# returns. Otherwise, discover waits for timeout seconds and returns all
|
348
|
+
# notifications received in that time.
|
349
|
+
|
350
|
+
def discover
|
351
|
+
membership = IPAddr.new(@broadcast).hton + IPAddr.new('0.0.0.0').hton
|
352
|
+
|
353
|
+
@socket ||= UDPSocket.new
|
354
|
+
|
355
|
+
@socket.setsockopt Socket::IPPROTO_IP, Socket::IP_TTL, [@ttl].pack('i')
|
356
|
+
@socket.setsockopt Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership
|
357
|
+
|
358
|
+
@socket.bind Socket::INADDR_ANY, @port
|
359
|
+
|
360
|
+
listen
|
361
|
+
|
362
|
+
if block_given? then
|
363
|
+
loop do
|
364
|
+
notification = @queue.pop
|
365
|
+
|
366
|
+
yield notification
|
367
|
+
end
|
368
|
+
else
|
369
|
+
sleep @timeout
|
370
|
+
|
371
|
+
notifications = []
|
372
|
+
notifications << @queue.pop until @queue.empty?
|
373
|
+
notifications
|
374
|
+
end
|
375
|
+
ensure
|
376
|
+
stop_listening
|
377
|
+
@socket.close if @socket and not @socket.closed?
|
378
|
+
@socket = nil
|
379
|
+
end
|
380
|
+
|
381
|
+
##
|
382
|
+
# Listens for UDP packets from devices in a Thread and enqueues them for
|
383
|
+
# processing. Requires a socket from search or discover.
|
384
|
+
|
385
|
+
def listen
|
386
|
+
return @listener if @listener and @listener.alive?
|
387
|
+
|
388
|
+
@listener = Thread.start do
|
389
|
+
loop do
|
390
|
+
response = @socket.recvfrom(1024).first
|
391
|
+
|
392
|
+
begin
|
393
|
+
@queue << parse(response)
|
394
|
+
rescue
|
395
|
+
puts $!.message
|
396
|
+
puts $!.backtrace
|
397
|
+
end
|
398
|
+
end
|
399
|
+
end
|
400
|
+
end
|
401
|
+
|
402
|
+
##
|
403
|
+
# Returns a Notification or Response created from +response+.
|
404
|
+
|
405
|
+
def parse(response)
|
406
|
+
case response
|
407
|
+
when /\ANOTIFY/ then
|
408
|
+
Notification.parse response
|
409
|
+
when /\AHTTP/ then
|
410
|
+
Response.parse response
|
411
|
+
else
|
412
|
+
raise Error, "Unknown response #{response[/\A.*$/]}"
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
##
|
417
|
+
# Broadcasts M-SEARCH requests looking for +targets+. Waits timeout seconds
|
418
|
+
# for responses then returns the collected responses.
|
419
|
+
#
|
420
|
+
# Supply no arguments to search for all devices and services.
|
421
|
+
#
|
422
|
+
# Supply <tt>:root</tt> to search for root devices only.
|
423
|
+
#
|
424
|
+
# Supply <tt>[:device, 'device_type:version']</tt> to search for a specific
|
425
|
+
# device type.
|
426
|
+
#
|
427
|
+
# Supply <tt>[:service, 'service_type:version']</tt> to search for a
|
428
|
+
# specific service type.
|
429
|
+
#
|
430
|
+
# Supply <tt>"uuid:..."</tt> to search for a UUID.
|
431
|
+
#
|
432
|
+
# Supply <tt>"urn:..."</tt> to search for a URN.
|
433
|
+
|
434
|
+
def search(*targets)
|
435
|
+
@socket ||= UDPSocket.new
|
436
|
+
|
437
|
+
@socket.setsockopt Socket::IPPROTO_IP, Socket::IP_TTL, [@ttl].pack('i')
|
438
|
+
|
439
|
+
if targets.empty? then
|
440
|
+
send_search 'ssdp:all'
|
441
|
+
else
|
442
|
+
targets.each do |target|
|
443
|
+
if target == :root then
|
444
|
+
send_search 'upnp:rootdevice'
|
445
|
+
elsif Array === target and target.first == :device then
|
446
|
+
target = [UPnP::DEVICE_SCHEMA_PREFIX, target.last]
|
447
|
+
send_search target.join(':')
|
448
|
+
elsif Array === target and target.first == :service then
|
449
|
+
target = [UPnP::SERVICE_SCHEMA_PREFIX, target.last]
|
450
|
+
send_search target.join(':')
|
451
|
+
elsif String === target and target =~ /\A(urn|uuid|ssdp):/ then
|
452
|
+
send_search target
|
453
|
+
end
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
listen
|
458
|
+
sleep @timeout
|
459
|
+
|
460
|
+
responses = []
|
461
|
+
responses << @queue.pop until @queue.empty?
|
462
|
+
responses
|
463
|
+
ensure
|
464
|
+
stop_listening
|
465
|
+
@socket.close if @socket and not @socket.closed?
|
466
|
+
@socket = nil
|
467
|
+
end
|
468
|
+
|
469
|
+
##
|
470
|
+
# Builds and sends an M-SEARCH request looking for +search_target+.
|
471
|
+
|
472
|
+
def send_search(search_target)
|
473
|
+
http_request = <<HTTP_REQUEST
|
474
|
+
M-SEARCH * HTTP/1.1\r
|
475
|
+
HOST: #{@broadcast}:#{@port}\r
|
476
|
+
MAN: "ssdp:discover"\r
|
477
|
+
MX: #{@timeout}\r
|
478
|
+
ST: #{search_target}\r
|
479
|
+
\r
|
480
|
+
HTTP_REQUEST
|
481
|
+
|
482
|
+
@socket.send http_request, 0, @broadcast, @port
|
483
|
+
end
|
484
|
+
|
485
|
+
##
|
486
|
+
# Stops and clears the listen thread.
|
487
|
+
|
488
|
+
def stop_listening
|
489
|
+
@listener.kill if @listener
|
490
|
+
@queue = Queue.new
|
491
|
+
@listener = nil
|
492
|
+
end
|
493
|
+
|
494
|
+
end
|
495
|
+
|