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 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