easy_upnp 1.0.1 → 1.1.1
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 +4 -4
- data/lib/easy_upnp/control_point/device_control_point.rb +68 -17
- data/lib/easy_upnp/events/event_client.rb +126 -0
- data/lib/easy_upnp/events/http_listener.rb +76 -0
- data/lib/easy_upnp/events/subscription_manager.rb +153 -0
- data/lib/easy_upnp/options_base.rb +37 -0
- data/lib/easy_upnp/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b697312acbbe5ad906d385375b5da32f41e99976
|
4
|
+
data.tar.gz: cb2b7666648460e0d7632cdde1022ec73872a7f0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8ecf14d57ea0826ba2a9471ac36914962c84ec4138d1b4f22c04b450e3df8a1c9cf916737d0a353597fb797ea4a0cdab5b6c809586f966e02931490152ac0237
|
7
|
+
data.tar.gz: 222a363cc222047da62f344f2ef554445cab219c661c7f13f3e55a31cb0d2ff829128719cf4d7109347d17ce99a58d3525c56143321c574e2240d2a72d73f49b
|
@@ -2,15 +2,21 @@ require 'nokogiri'
|
|
2
2
|
require 'open-uri'
|
3
3
|
require 'nori'
|
4
4
|
|
5
|
+
require_relative '../options_base'
|
6
|
+
|
5
7
|
require_relative 'validator_provider'
|
6
8
|
require_relative 'client_wrapper'
|
7
9
|
require_relative 'service_method'
|
8
10
|
|
11
|
+
require_relative '../events/event_client'
|
12
|
+
require_relative '../events/http_listener'
|
13
|
+
require_relative '../events/subscription_manager'
|
14
|
+
|
9
15
|
module EasyUpnp
|
10
16
|
class DeviceControlPoint
|
11
|
-
attr_reader :service_endpoint
|
17
|
+
attr_reader :event_vars, :service_endpoint, :events_endpoint
|
12
18
|
|
13
|
-
class Options
|
19
|
+
class Options < EasyUpnp::OptionsBase
|
14
20
|
DEFAULTS = {
|
15
21
|
advanced_typecasting: true,
|
16
22
|
validate_arguments: false,
|
@@ -19,31 +25,31 @@ module EasyUpnp
|
|
19
25
|
call_options: {}
|
20
26
|
}
|
21
27
|
|
22
|
-
attr_reader :options
|
23
|
-
|
24
28
|
def initialize(o = {}, &block)
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
define_singleton_method(k) do
|
29
|
-
@options[k]
|
30
|
-
end
|
29
|
+
super(o, DEFAULTS, &block)
|
30
|
+
end
|
31
|
+
end
|
31
32
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
33
|
+
class EventConfigOptions < EasyUpnp::OptionsBase
|
34
|
+
DEFAULTS = {
|
35
|
+
configure_http_listener: ->(c) { },
|
36
|
+
configure_subscription_manager: ->(c) { }
|
37
|
+
}
|
36
38
|
|
37
|
-
|
39
|
+
def initialize(&block)
|
40
|
+
super({}, DEFAULTS, &block)
|
38
41
|
end
|
39
42
|
end
|
40
43
|
|
41
|
-
def initialize(urn, service_endpoint, definition, options, &block)
|
44
|
+
def initialize(urn, service_endpoint, events_endpoint, definition, options, &block)
|
42
45
|
@urn = urn
|
43
46
|
@service_endpoint = service_endpoint
|
44
47
|
@definition = definition
|
45
48
|
@options = Options.new(options, &block)
|
46
49
|
|
50
|
+
@events_endpoint = events_endpoint
|
51
|
+
@events_client = EasyUpnp::EventClient.new(events_endpoint)
|
52
|
+
|
47
53
|
@client = ClientWrapper.new(
|
48
54
|
service_endpoint,
|
49
55
|
urn,
|
@@ -66,12 +72,18 @@ module EasyUpnp
|
|
66
72
|
# Adds a method to the class
|
67
73
|
define_service_method(method, @client, @validator_provider, @options)
|
68
74
|
end
|
75
|
+
|
76
|
+
@event_vars = definition_xml.
|
77
|
+
xpath('//serviceStateTable/stateVariable[@sendEvents = "yes"]/name').
|
78
|
+
map(&:text).
|
79
|
+
map(&:to_sym)
|
69
80
|
end
|
70
81
|
|
71
82
|
def to_params
|
72
83
|
{
|
73
84
|
urn: @urn,
|
74
85
|
service_endpoint: @service_endpoint,
|
86
|
+
events_endpoint: @events_endpoint,
|
75
87
|
definition: @definition,
|
76
88
|
options: @options.options
|
77
89
|
}
|
@@ -81,6 +93,7 @@ module EasyUpnp
|
|
81
93
|
DeviceControlPoint.new(
|
82
94
|
params[:urn],
|
83
95
|
params[:service_endpoint],
|
96
|
+
params[:events_endpoint],
|
84
97
|
params[:definition],
|
85
98
|
params[:options]
|
86
99
|
)
|
@@ -102,9 +115,14 @@ module EasyUpnp
|
|
102
115
|
service_definition_uri = URI.join(root_uri, service.xpath('service/SCPDURL').text).to_s
|
103
116
|
service_definition = open(service_definition_uri) { |f| f.read }
|
104
117
|
|
118
|
+
endpoint_url = ->(xpath) do
|
119
|
+
URI.join(root_uri, service.xpath(xpath).text).to_s
|
120
|
+
end
|
121
|
+
|
105
122
|
DeviceControlPoint.new(
|
106
123
|
urn,
|
107
|
-
|
124
|
+
endpoint_url.call('service/controlURL'),
|
125
|
+
endpoint_url.call('service/eventSubURL'),
|
108
126
|
service_definition,
|
109
127
|
options,
|
110
128
|
&block
|
@@ -134,6 +152,39 @@ module EasyUpnp
|
|
134
152
|
@service_methods.keys
|
135
153
|
end
|
136
154
|
|
155
|
+
def add_event_callback(url)
|
156
|
+
manager = EasyUpnp::SubscriptionManager.new(@events_client, url)
|
157
|
+
manager.subscribe
|
158
|
+
manager
|
159
|
+
end
|
160
|
+
|
161
|
+
def on_event(callback, &block)
|
162
|
+
raise ArgumentError, 'Must provide block' if callback.nil?
|
163
|
+
|
164
|
+
options = EventConfigOptions.new(&block)
|
165
|
+
|
166
|
+
listener = EasyUpnp::HttpListener.new(callback: callback) do |c|
|
167
|
+
options.configure_http_listener.call(c)
|
168
|
+
end
|
169
|
+
|
170
|
+
# exposing the URL as a lambda allows the subscription manager to get a
|
171
|
+
# new URL should the server stop and start again on a different port.
|
172
|
+
url = ->() { listener.listen }
|
173
|
+
|
174
|
+
manager = EasyUpnp::SubscriptionManager.new(@events_client, url) do |c|
|
175
|
+
options.configure_subscription_manager.call(c)
|
176
|
+
|
177
|
+
user_shutdown = c.on_shutdown
|
178
|
+
c.on_shutdown = ->() do
|
179
|
+
user_shutdown.call if user_shutdown
|
180
|
+
listener.shutdown
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
manager.subscribe
|
185
|
+
manager
|
186
|
+
end
|
187
|
+
|
137
188
|
private
|
138
189
|
|
139
190
|
def define_service_method(method, client, validator_provider, options)
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
|
3
|
+
require_relative '../options_base'
|
4
|
+
|
5
|
+
module EasyUpnp
|
6
|
+
class EventClient
|
7
|
+
class SubscriptionError < StandardError; end
|
8
|
+
|
9
|
+
def initialize(events_endpoint)
|
10
|
+
@events_endpoint = URI(events_endpoint)
|
11
|
+
end
|
12
|
+
|
13
|
+
def subscribe(callback, timeout: 300)
|
14
|
+
req = SubscribeRequest.new(
|
15
|
+
@events_endpoint,
|
16
|
+
callback,
|
17
|
+
timeout
|
18
|
+
)
|
19
|
+
|
20
|
+
response = do_request(req)
|
21
|
+
|
22
|
+
if !response['SID']
|
23
|
+
raise SubscriptionError, "SID header not present in response: #{response.to_hash}"
|
24
|
+
end
|
25
|
+
|
26
|
+
SubscribeResponse.new(response)
|
27
|
+
end
|
28
|
+
|
29
|
+
def unsubscribe(sid)
|
30
|
+
req = UnsubscribeRequest.new(
|
31
|
+
@events_endpoint,
|
32
|
+
sid
|
33
|
+
)
|
34
|
+
do_request(req)
|
35
|
+
true
|
36
|
+
end
|
37
|
+
|
38
|
+
def resubscribe(sid, timeout: 300)
|
39
|
+
req = ResubscribeRequest.new(
|
40
|
+
@events_endpoint,
|
41
|
+
sid,
|
42
|
+
timeout
|
43
|
+
)
|
44
|
+
|
45
|
+
response = do_request(req)
|
46
|
+
|
47
|
+
SubscribeResponse.new(response)
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def do_request(req)
|
53
|
+
uri = URI(@events_endpoint)
|
54
|
+
Net::HTTP.start(uri.host, uri.port) do |http|
|
55
|
+
response = http.request(req)
|
56
|
+
|
57
|
+
if response.code.to_i != 200
|
58
|
+
raise SubscriptionError, "Unexpected response type (#{response.code}): #{response.body}"
|
59
|
+
end
|
60
|
+
|
61
|
+
return response
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
class SubscribeResponse
|
66
|
+
TIMEOUT_HEADER_REGEX = /Second-(\d+)/i
|
67
|
+
|
68
|
+
attr_reader :sid, :timeout
|
69
|
+
|
70
|
+
def initialize(request)
|
71
|
+
@sid = request['SID']
|
72
|
+
@timeout = TIMEOUT_HEADER_REGEX.match(request['TIMEOUT'])[1].to_i
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
class EventRequest < Net::HTTPRequest
|
77
|
+
def timeout=(v)
|
78
|
+
self['TIMEOUT'] = "Second-#{v}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def callback=(v)
|
82
|
+
self['CALLBACK'] = "<#{v}>"
|
83
|
+
self['NT'] = 'upnp:event'
|
84
|
+
end
|
85
|
+
|
86
|
+
def sid=(v)
|
87
|
+
self['SID'] = v
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
class ResubscribeRequest < EventRequest
|
92
|
+
METHOD = 'SUBSCRIBE'
|
93
|
+
REQUEST_HAS_BODY = false
|
94
|
+
RESPONSE_HAS_BODY = true
|
95
|
+
|
96
|
+
def initialize(event_url, sid, timeout)
|
97
|
+
super(URI(event_url))
|
98
|
+
self.timeout = timeout
|
99
|
+
self.sid = sid
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
class SubscribeRequest < EventRequest
|
104
|
+
METHOD = 'SUBSCRIBE'
|
105
|
+
REQUEST_HAS_BODY = false
|
106
|
+
RESPONSE_HAS_BODY = true
|
107
|
+
|
108
|
+
def initialize(event_url, callback_url, timeout)
|
109
|
+
super(URI(event_url))
|
110
|
+
self.timeout = timeout
|
111
|
+
self.callback = callback_url
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class UnsubscribeRequest < EventRequest
|
116
|
+
METHOD = 'UNSUBSCRIBE'
|
117
|
+
REQUEST_HAS_BODY = false
|
118
|
+
RESPONSE_HAS_BODY = true
|
119
|
+
|
120
|
+
def initialize(event_url, sid)
|
121
|
+
super(URI(event_url))
|
122
|
+
self.sid = sid
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'webrick'
|
2
|
+
require 'thread'
|
3
|
+
|
4
|
+
require_relative '../options_base'
|
5
|
+
|
6
|
+
module EasyUpnp
|
7
|
+
class HttpListener
|
8
|
+
class Options < EasyUpnp::OptionsBase
|
9
|
+
DEFAULTS = {
|
10
|
+
# Port to listen on. Default value "0" will cause OS to choose a random
|
11
|
+
# ephemeral port
|
12
|
+
listen_port: 0,
|
13
|
+
|
14
|
+
# Address to bind listener on. Default value binds to all IPv4
|
15
|
+
# interfaces.
|
16
|
+
bind_address: '0.0.0.0',
|
17
|
+
|
18
|
+
# By default, event callback just prints the request body
|
19
|
+
callback: ->(request) { puts request.body }
|
20
|
+
}
|
21
|
+
|
22
|
+
def initialize(options)
|
23
|
+
super(options, DEFAULTS)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize(o = {}, &block)
|
28
|
+
@options = Options.new(o, &block)
|
29
|
+
end
|
30
|
+
|
31
|
+
def listen
|
32
|
+
if !@listen_thread
|
33
|
+
@server = WEBrick::HTTPServer.new(
|
34
|
+
Port: @options.listen_port,
|
35
|
+
AccessLog: [],
|
36
|
+
BindAddress: @options.bind_address
|
37
|
+
)
|
38
|
+
@server.mount('/', NotifyServlet, @options.callback)
|
39
|
+
end
|
40
|
+
|
41
|
+
@listen_thread ||= Thread.new do
|
42
|
+
@server.start
|
43
|
+
end
|
44
|
+
|
45
|
+
url
|
46
|
+
end
|
47
|
+
|
48
|
+
def url
|
49
|
+
if !@listen_thread or !@server
|
50
|
+
raise RuntimeError, 'Server not started'
|
51
|
+
end
|
52
|
+
|
53
|
+
addr = Socket.ip_address_list.detect{|intf| intf.ipv4_private?}
|
54
|
+
port = @server.config[:Port]
|
55
|
+
|
56
|
+
"http://#{addr.ip_address}:#{port}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def shutdown
|
60
|
+
raise RuntimeError, "Illegal state: server is not started" if @listen_thread.nil?
|
61
|
+
|
62
|
+
@listen_thread.kill
|
63
|
+
@listen_thread = nil
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class NotifyServlet < WEBrick::HTTPServlet::AbstractServlet
|
68
|
+
def initialize(_server, block)
|
69
|
+
@callback = block
|
70
|
+
end
|
71
|
+
|
72
|
+
def do_NOTIFY(request, response)
|
73
|
+
@callback.call(request)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
require_relative '../options_base'
|
2
|
+
|
3
|
+
module EasyUpnp
|
4
|
+
class Options < EasyUpnp::OptionsBase
|
5
|
+
DEFAULTS = {
|
6
|
+
##
|
7
|
+
# Number of seconds to request our event subscription be active for. The
|
8
|
+
# server can set it to whatever it wants.
|
9
|
+
requested_timeout: 300,
|
10
|
+
|
11
|
+
##
|
12
|
+
# Number of seconds before a subscription expires before we request that
|
13
|
+
# it be refreshed.
|
14
|
+
resubscription_interval_buffer: 10,
|
15
|
+
|
16
|
+
##
|
17
|
+
# Specifies an existing subscription ID. If non-nil, will attempt to
|
18
|
+
# maintain the existing subscription, creating a new one if there's an
|
19
|
+
# error. If nil, will always create a new subscription.
|
20
|
+
existing_sid: nil,
|
21
|
+
|
22
|
+
logger: Logger.new($stdout),
|
23
|
+
log_level: Logger::WARN,
|
24
|
+
|
25
|
+
on_shutdown: -> { }
|
26
|
+
}
|
27
|
+
|
28
|
+
def initialize(o, &block)
|
29
|
+
super(o, DEFAULTS, &block)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class SubscriptionManager
|
34
|
+
def initialize(event_client, callback_url, options = {}, &block)
|
35
|
+
@options = Options.new(options, &block)
|
36
|
+
@event_client = event_client
|
37
|
+
@callback_url = callback_url
|
38
|
+
@sid = @options.existing_sid
|
39
|
+
|
40
|
+
logger.level = @options.log_level
|
41
|
+
end
|
42
|
+
|
43
|
+
def subscription_id
|
44
|
+
@sid
|
45
|
+
end
|
46
|
+
|
47
|
+
def callback_url
|
48
|
+
if @callback_url.is_a? Proc
|
49
|
+
@callback_url.call
|
50
|
+
else
|
51
|
+
@callback_url
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def subscribe
|
56
|
+
@subscription_thread ||= Thread.new do
|
57
|
+
logger.info "Starting subscription thread..."
|
58
|
+
|
59
|
+
resubscribe_time = start_or_renew_subscription
|
60
|
+
|
61
|
+
begin
|
62
|
+
while true
|
63
|
+
if Time.now >= resubscribe_time
|
64
|
+
resubscribe_time = renew_subscription
|
65
|
+
else
|
66
|
+
sleep 1
|
67
|
+
end
|
68
|
+
end
|
69
|
+
rescue Exception => e
|
70
|
+
logger.error "Caught error: #{e}"
|
71
|
+
raise e
|
72
|
+
end
|
73
|
+
|
74
|
+
logger.info "Ending subscription"
|
75
|
+
end
|
76
|
+
|
77
|
+
true
|
78
|
+
end
|
79
|
+
|
80
|
+
def unsubscribe
|
81
|
+
if @subscription_thread.nil?
|
82
|
+
raise RuntimeError, "Illegal state: no active subscription"
|
83
|
+
end
|
84
|
+
@subscription_thread.kill
|
85
|
+
@subscription_thread = nil
|
86
|
+
|
87
|
+
begin
|
88
|
+
if @sid
|
89
|
+
@event_client.unsubscribe(@sid)
|
90
|
+
@sid = nil
|
91
|
+
end
|
92
|
+
rescue Exception => e
|
93
|
+
logger.error "Error unsubscribing with SID #{@sid}: #{e}"
|
94
|
+
end
|
95
|
+
|
96
|
+
@options.on_shutdown.call
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
def start_or_renew_subscription
|
102
|
+
if !@sid
|
103
|
+
start_subscription
|
104
|
+
else
|
105
|
+
renew_subscription
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def renew_subscription
|
110
|
+
begin
|
111
|
+
logger.info "Refreshing subscription for: #{@sid}"
|
112
|
+
response = @event_client.resubscribe(
|
113
|
+
@sid,
|
114
|
+
timeout: @options.requested_timeout
|
115
|
+
)
|
116
|
+
logger.info "Got resubscribe response: #{response.inspect}"
|
117
|
+
@resubscribe_time = calculate_refresh_time(response)
|
118
|
+
rescue EasyUpnp::EventClient::SubscriptionError => e
|
119
|
+
logger.error "Error renewing subscription; trying to start a new one"
|
120
|
+
start_subscription
|
121
|
+
rescue Exception => e
|
122
|
+
logger.error "Unrecoverable exception renewing subscription: #{e}"
|
123
|
+
raise e
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def start_subscription
|
128
|
+
begin
|
129
|
+
response = @event_client.subscribe(
|
130
|
+
callback_url,
|
131
|
+
timeout: @options.requested_timeout
|
132
|
+
)
|
133
|
+
|
134
|
+
logger.info "Got subscription response: #{response.inspect}"
|
135
|
+
|
136
|
+
@sid = response.sid
|
137
|
+
@resubscribe_time = calculate_refresh_time(response)
|
138
|
+
rescue Exception => e
|
139
|
+
logger.error "Error subscribing to event: #{e}"
|
140
|
+
raise e
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def calculate_refresh_time(response)
|
145
|
+
timeout = response.timeout
|
146
|
+
Time.now + timeout - @options.resubscription_interval_buffer
|
147
|
+
end
|
148
|
+
|
149
|
+
def logger
|
150
|
+
@options.logger
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module EasyUpnp
|
2
|
+
class OptionsBase
|
3
|
+
class Builder
|
4
|
+
attr_reader :options
|
5
|
+
|
6
|
+
def initialize(supported_options)
|
7
|
+
@options = {}
|
8
|
+
|
9
|
+
supported_options.each do |k|
|
10
|
+
define_singleton_method("#{k}=") { |v| @options[k] = v }
|
11
|
+
define_singleton_method("#{k}") do |&block|
|
12
|
+
@options[k] = block if block
|
13
|
+
@options[k]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :options
|
20
|
+
|
21
|
+
def initialize(options, defaults, &block)
|
22
|
+
@options = defaults.merge(options)
|
23
|
+
|
24
|
+
if block
|
25
|
+
block_builder = Builder.new(defaults.keys)
|
26
|
+
block.call(block_builder)
|
27
|
+
@options = @options.merge(block_builder.options)
|
28
|
+
end
|
29
|
+
|
30
|
+
defaults.map do |k, v|
|
31
|
+
define_singleton_method(k) do
|
32
|
+
@options[k]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/lib/easy_upnp/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: easy_upnp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Christopher Mullins
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2016-04-
|
11
|
+
date: 2016-04-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -71,6 +71,10 @@ files:
|
|
71
71
|
- lib/easy_upnp/control_point/device_control_point.rb
|
72
72
|
- lib/easy_upnp/control_point/service_method.rb
|
73
73
|
- lib/easy_upnp/control_point/validator_provider.rb
|
74
|
+
- lib/easy_upnp/events/event_client.rb
|
75
|
+
- lib/easy_upnp/events/http_listener.rb
|
76
|
+
- lib/easy_upnp/events/subscription_manager.rb
|
77
|
+
- lib/easy_upnp/options_base.rb
|
74
78
|
- lib/easy_upnp/ssdp_searcher.rb
|
75
79
|
- lib/easy_upnp/upnp_device.rb
|
76
80
|
- lib/easy_upnp/version.rb
|