easy_upnp 1.0.1 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: a1d0115b9fa09e9153b84782c6e5c79d86fa80d6
4
- data.tar.gz: 3d3383dc43d280a1a5db1e56f7c7479ea9e8eff7
3
+ metadata.gz: b697312acbbe5ad906d385375b5da32f41e99976
4
+ data.tar.gz: cb2b7666648460e0d7632cdde1022ec73872a7f0
5
5
  SHA512:
6
- metadata.gz: 4315c796f88ba6fc46dcf6bbaf0f8fe4c51e80c8601ad10c20afcb81e73e185625a4a5a40b35bbc1d437c8837c8368aa69be2008e08c2c562edc6945fba08dab
7
- data.tar.gz: 226784376fa3b7db72b722260aa722a348a3e5ff02ee585d06e421652d53eb5d8e9e0ef92dd773f790e7a56536a2fd064c242918beb774c9607f61fac7eb9a26
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
- @options = o.merge(DEFAULTS)
26
-
27
- DEFAULTS.map do |k, v|
28
- define_singleton_method(k) do
29
- @options[k]
30
- end
29
+ super(o, DEFAULTS, &block)
30
+ end
31
+ end
31
32
 
32
- define_singleton_method("#{k}=") do |v|
33
- @options[k] = v
34
- end
35
- end
33
+ class EventConfigOptions < EasyUpnp::OptionsBase
34
+ DEFAULTS = {
35
+ configure_http_listener: ->(c) { },
36
+ configure_subscription_manager: ->(c) { }
37
+ }
36
38
 
37
- block.call(self) unless block.nil?
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
- URI.join(root_uri, service.xpath('service/controlURL').text).to_s,
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
@@ -1,3 +1,3 @@
1
1
  module EasyUpnp
2
- VERSION = '1.0.1'
2
+ VERSION = '1.1.1'
3
3
  end
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.0.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-16 00:00:00.000000000 Z
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