rupnp 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rupnp.rb CHANGED
@@ -6,7 +6,7 @@ require 'eventmachine-le'
6
6
  module RUPNP
7
7
 
8
8
  # RUPNP version
9
- VERSION = '0.1.0'
9
+ VERSION = '0.2.0'
10
10
 
11
11
  @logdev = STDERR
12
12
  @log_level = :info
@@ -59,4 +59,4 @@ require_relative 'rupnp/cp/event_server'
59
59
  require_relative 'rupnp/cp/event_subscriber'
60
60
  require_relative 'rupnp/ssdp'
61
61
 
62
- #require_relative 'rupnp/device'
62
+ require_relative 'rupnp/device'
@@ -60,9 +60,13 @@ module RUPNP
60
60
  # @return [void]
61
61
  def start
62
62
  search_devices_and_listen @search_target, @search_options
63
- yield @new_device_channel, @bye_device_channel
63
+ yield @new_device_channel, @bye_device_channel if block_given?
64
64
  end
65
65
 
66
+ # Start a search for devices. No listen for update is made.
67
+ #
68
+ # Found devices are accessible through {#devices}.
69
+ # @return [void]
66
70
  def search_only
67
71
  options = @search_options.dup
68
72
  options[:search_only] = true
@@ -73,8 +77,8 @@ module RUPNP
73
77
  # @param [Integer] port port to listen for
74
78
  # @return [void]
75
79
  def start_event_server(port=EVENT_SUB_DEFAULT_PORT)
76
- @event_port = port
77
- @add_event_url = EM::Channel.new
80
+ @event_port ||= port
81
+ @add_event_url ||= EM::Channel.new
78
82
  @event_server ||= EM.start_server('0.0.0.0', port, CP::EventServer,
79
83
  @add_event_url)
80
84
  end
@@ -83,7 +87,9 @@ module RUPNP
83
87
  # @see #start_event_server
84
88
  # @return [void]
85
89
  def stop_event_server
90
+ @event_port = nil
86
91
  EM.stop_server @event_server
92
+ @event_server = nil
87
93
  end
88
94
 
89
95
  # Add a device to the control point
@@ -129,14 +135,19 @@ module RUPNP
129
135
  listener = SSDP.listen
130
136
 
131
137
  listener.notifications.subscribe do |notification|
138
+ udn = usn2udn(notification['usn'])
139
+
132
140
  case notification['nts']
133
- when 'ssdp:alive'
134
- create_device notification
141
+ when 'ssdp:alive', 'ssdp:update'
142
+ d = @devices.find { |d| d.udn == udn }
143
+ if d
144
+ d.update notification
145
+ else
146
+ create_device notification
147
+ end
135
148
  when 'ssdp:byebye'
136
- udn = usn2udn(notification['usn'])
137
149
  log :info, "byebye notification sent by device #{udn}"
138
- rejected = @devices.reject! { |d| d.udn == udn }
139
- log :info, "#{rejected.udn} device removed" if rejected
150
+ @devices.reject! { |d| d.udn == udn }
140
151
  else
141
152
  log :warn, "Unknown notification type: #{notification['nts']}"
142
153
  end
@@ -7,6 +7,12 @@ module RUPNP
7
7
  # A device is a UPnP service provider.
8
8
  # @author Sylvain Daubert.
9
9
  class CP::RemoteDevice < CP::Base
10
+
11
+ # Number of seconds the advertisement is valid.
12
+ # Used when +ssdp:update+ advertisement is received but no
13
+ # previuous +ssdp:alive+ was received.
14
+ DEFAULT_MAX_AGE = 600
15
+
10
16
  # Get control point which controls this device
11
17
  # @return [ControlPoint]
12
18
  attr_reader :control_point
@@ -34,6 +40,12 @@ module RUPNP
34
40
  # Expiration time for the advertisement
35
41
  # @return [Time]
36
42
  attr_reader :expiration
43
+ # BOOTID.UPNP.ORG field value
44
+ # @return [Integer]
45
+ attr_reader :boot_id
46
+ # CONFIGID.UPNP.ORG field value
47
+ # @return [nil, Integer]
48
+ attr_reader :config_id
37
49
 
38
50
  # UPnP version used by the device
39
51
  # @return [String]
@@ -106,6 +118,16 @@ module RUPNP
106
118
  # Get device from its description
107
119
  # @return [void]
108
120
  def fetch
121
+ if @notification['nextbootid.upnp.org']
122
+ @boot_id = @notification['nextbootid.upnp.org'].to_i
123
+ elsif @notification['bootid.upnp.org']
124
+ @boot_id = @notification['bootid.upnp.org'].to_i
125
+ else
126
+ fail self, 'no BOOTID.UPNP.ORG field. Message discarded.'
127
+ end
128
+ @config_id = @notification['confgid.upnp.org']
129
+ @config_id = @config_id.to_i if @config_id
130
+
109
131
  description_getter = EM::DefaultDeferrable.new
110
132
 
111
133
  description_getter.errback do
@@ -143,6 +165,17 @@ module RUPNP
143
165
  end
144
166
  end
145
167
 
168
+ # Update a device from a ssdp:update notification
169
+ # @param [String] notification
170
+ # @return [void]
171
+ def update(notification)
172
+ update_expiration notification
173
+ @boot_id = notification['nextbootid.upnp.org'].to_i
174
+ if notification['configid.upnp.org']
175
+ @config_id = notification['configid.upnp.org'].to_i
176
+ end
177
+ end
178
+
146
179
 
147
180
  private
148
181
 
@@ -153,15 +186,8 @@ module RUPNP
153
186
  @server = @notification['server']
154
187
  @location = @notification['location']
155
188
  @ext = @notification['ext']
156
- @date = @notification['date'] || ''
157
- @cache_control = @notification['cache-control'] || ''
158
189
 
159
- max_age = @cache_control.match(/max-age\s*=\s*(\d+)/)[1].to_i
160
- @expiration = if @date.empty?
161
- Time.now + max_age
162
- else
163
- Time.parse(@date) + max_age
164
- end
190
+ update_expiration @notification
165
191
 
166
192
  if @location
167
193
  get_description @location, getter
@@ -204,12 +230,13 @@ module RUPNP
204
230
  @model_number = device[:model_number] || ''
205
231
  @model_url = device[:model_url] || ''
206
232
  @serial_umber = device[:serial_number] || ''
207
- @udn = device[:udn]
233
+ @udn = device[:udn].gsub(/uuid:/, '')
208
234
  @upc = device[:upc] || ''
209
235
  @presentation_url = device[:presentation_url] || ''
210
236
  end
211
237
 
212
238
  def extract_icons
239
+ return unless @description[:root][:device][:icon_list]
213
240
  @description[:root][:device][:icon_list][:icon].each do |h|
214
241
  icon = OpenStruct.new(h)
215
242
  icon.url = build_url(@url_base, icon.url)
@@ -266,6 +293,18 @@ module RUPNP
266
293
  end
267
294
  end
268
295
 
296
+ def update_expiration(notification)
297
+ @date = @notification['date'] || ''
298
+ @cache_control = @notification['cache-control'] || ''
299
+
300
+ if @notification['nts'] == 'ssdp:alive'
301
+ max_age = @cache_control.match(/max-age\s*=\s*(\d+)/)[1].to_i
302
+ else
303
+ max_age = DEFAULT_MAX_AGE
304
+ end
305
+ @expiration = (@date.empty? ? Time.now : Time.parse(@date)) + max_age
306
+ end
307
+
269
308
  end
270
309
 
271
310
  end
@@ -0,0 +1,143 @@
1
+ require_relative 'spec_helper'
2
+
3
+ module RUPNP
4
+
5
+ describe ControlPoint do
6
+ include EM::SpecHelper
7
+
8
+ let(:cp) { ControlPoint.new(:all, :response_wait_time => 1) }
9
+ let(:notify_options) { {
10
+ max_age: 10,
11
+ ip: '127.0.0.1',
12
+ port: 1234,
13
+ uuid: UUID.generate,
14
+ boot_id: 1,
15
+ config_id: 1,
16
+ u_search_port: DISCOVERY_PORT,
17
+ try_number: 1
18
+ } }
19
+
20
+ it 'should initialize a new instance' do
21
+ expect(cp.devices).to be_a(Array)
22
+ expect(cp.devices).to be_empty
23
+ end
24
+
25
+ [:search_only, :start].each do |meth|
26
+ it "##{meth} should detect devices" do
27
+ em do
28
+ uuid1 = UUID.generate
29
+ generate_search_responder uuid1, 1234
30
+ generate_search_responder uuid1, 1234
31
+ uuid2 = UUID.generate
32
+ generate_search_responder uuid2, 1235
33
+
34
+ stub_request(:get, '127.0.0.1:1234').to_return :headers => {
35
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
36
+ }, :body => generate_xml_device_description(uuid1)
37
+ stub_request(:get, '127.0.0.1:1235').to_return :headers => {
38
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
39
+ }, :body => generate_xml_device_description(uuid2)
40
+
41
+ cp.send meth
42
+
43
+ EM.add_timer(1) do
44
+ expect(cp.devices).to have(2).item
45
+ done
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ it '#search_only should not register devices after wait time is expired' do
52
+ em do
53
+ uuid = UUID.generate
54
+ stub_request(:get, '127.0.0.1:1234').to_return :headers => {
55
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
56
+ }, :body => generate_xml_device_description(uuid)
57
+
58
+ cp.search_only
59
+
60
+ EM.add_timer(2) do
61
+ expect(cp.devices).to be_empty
62
+ generate_search_responder uuid, 1234
63
+ EM.add_timer(1) do
64
+ expect(cp.devices).to be_empty
65
+ done
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ it '#start should listen for alive notifications' do
72
+ em do
73
+ cp.start
74
+ stub_request(:get, '127.0.0.1:1234/root_description.xml').
75
+ to_return :headers => {
76
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
77
+ }, :body => generate_xml_device_description(notify_options[:uuid])
78
+
79
+ EM.add_timer(2) do
80
+ expect(cp.devices).to be_empty
81
+ SSDP.notify :root, :alive, notify_options
82
+ EM.add_timer(1) do
83
+ expect(cp.devices).to have(1).item
84
+ expect(cp.devices[0].udn).to eq(notify_options[:uuid])
85
+ done
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ it '#start should listen for update notifications' do
92
+ em do
93
+ cp.start
94
+ stub_request(:get, '127.0.0.1:1234/root_description.xml').
95
+ to_return :headers => {
96
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
97
+ }, :body => generate_xml_device_description(notify_options[:uuid])
98
+
99
+ EM.add_timer(2) do
100
+ expect(cp.devices).to be_empty
101
+ SSDP.notify :root, :alive, notify_options
102
+ EM.add_timer(1) do
103
+ SSDP.notify :root, :update, notify_options.merge(boot_id: 2)
104
+ EM.add_timer(1) do
105
+ expect(cp.devices).to have(1).item
106
+ expect(cp.devices[0].udn).to eq(notify_options[:uuid])
107
+ expect(cp.devices[0].boot_id).to eq(2)
108
+ done
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ it '#start should listen for byebye notifications' do
116
+ em do
117
+ cp.start
118
+ stub_request(:get, '127.0.0.1:1234/root_description.xml').
119
+ to_return :headers => {
120
+ 'SERVER' => 'OS/1.0 UPnP/1.1 TEST/1.0'
121
+ }, :body => generate_xml_device_description(notify_options[:uuid])
122
+
123
+ EM.add_timer(2) do
124
+ expect(cp.devices).to be_empty
125
+ SSDP.notify :root, :alive, notify_options
126
+ EM.add_timer(1) do
127
+ expect(cp.devices).to have(1).item
128
+ expect(cp.devices[0].udn).to eq(notify_options[:uuid])
129
+ SSDP.notify :root, :byebye, notify_options
130
+ EM.add_timer(1) do
131
+ expect(cp.devices).to be_empty
132
+ done
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ it '#find_device_by_udn should get known devices'
140
+ end
141
+
142
+ end
143
+
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,14 @@
1
+ require 'simplecov'
2
+ SimpleCov.start do
3
+ add_filter "/spec/"
4
+ end
5
+
1
6
  $:.unshift '../lib'
2
7
  require 'rupnp'
3
8
  require 'em-spec/rspec'
9
+ require 'webmock/rspec'
10
+
11
+ RUPNP.log_level = :failure
4
12
 
5
13
 
6
14
  class FakeMulticast < RUPNP::SSDP::MulticastConnection
@@ -25,31 +33,80 @@ class FakeMulticast < RUPNP::SSDP::MulticastConnection
25
33
  end
26
34
 
27
35
 
36
+ def generate_search_responder(uuid, port)
37
+ responder = EM.open_datagram_socket(RUPNP::MULTICAST_IP,
38
+ RUPNP::DISCOVERY_PORT,
39
+ FakeMulticast)
40
+ responder.onmessage do |data|
41
+ data =~ /ST: (.*)\r\n/
42
+ target = $1
43
+ response =<<EOR
44
+ HTTP/1.1 200 OK\r
45
+ CACHE-CONTROL: max-age = 1800\r
46
+ DATE: #{Time.now}\r
47
+ EXT:\r
48
+ LOCATION: http://127.0.0.1:#{port}\r
49
+ SERVER: OS/1.0 UPnP/1.1 Test/1.0\r
50
+ ST: #{target}\r
51
+ USN: uuid:#{uuid}::upnp:rootdevice\r
52
+ BOOTID.UPNP.ORG: 1\r
53
+ CONFIGID.UPNP.ORG: 1\r
54
+ \r
55
+ EOR
56
+ responder.send_data response
57
+ end
58
+ end
59
+
60
+
61
+ def generate_xml_device_description(uuid)
62
+ <<EOD
63
+ <?xml version="1.0"?>
64
+ <root xmlns="urn:schemas-upnp-org:device-1-0" configId="1">
65
+ <specVersion>
66
+ <major>1</major>
67
+ <minor>1</minor>
68
+ </specVersion>
69
+ <device>
70
+ <deviceType>urn:schemas-upnp-org:device:Base:1-0</deviceType>
71
+ <friendlyName>Friendly name</friendlyName>
72
+ <manufacturer>RUPNP</manufacturer>
73
+ <modelName>Model name</modelName>
74
+ <UDN>uuid:#{uuid}</UDN>
75
+ </device>
76
+ </root>
77
+ EOD
78
+ end
79
+
80
+
28
81
  NOTIFY_REGEX = {
29
- :alive => [/^NOTIFY \* HTTP\/1.1\r\n/,
30
- /HOST: 239\.255\.255\.250:1900\r\n/,
82
+ :common => [
83
+ /^NOTIFY \* HTTP\/1.1\r\n/,
84
+ /HOST: 239\.255\.255\.250:1900\r\n/,
85
+ /NT: [0-9A-Za-z:-]+\r\n/,
86
+ /USN: uuid:(.*)\r\n/,
87
+ /BOOTID.UPNP.ORG: \d+\r\n/,
88
+ /CONFIGID.UPNP.ORG: \d+\r\n/,
89
+ ].freeze,
90
+ :alive => [
31
91
  /CACHE-CONTROL:\s+max-age\s+=\s+\d+\r\n/,
32
92
  /LOCATION: http:\/\/(.*)\r\n/,
33
- /NT: [0-9A-Za-z:-]+\r\n/,
34
- /NTS: ssdp:(alive|byebye)\r\n/,
93
+ /NTS: ssdp:alive\r\n/,
35
94
  /SERVER: (.*)\r\n/,
36
- /USN: uuid:(.*)\r\n/,
37
- /BOOTID.UPNP.ORG: \d+\r\n/,
38
- /CONFIGID.UPNP.ORG: \d+\r\n/,
39
- ].freeze,
40
- :byebye => [/^NOTIFY \* HTTP\/1.1\r\n/,
41
- /HOST: 239\.255\.255\.250:1900\r\n/,
42
- /NT: [0-9A-Za-z:-]+\r\n/,
43
- /NTS: ssdp:(alive|byebye)\r\n/,
44
- /USN: uuid:(.*)\r\n/,
45
- /BOOTID.UPNP.ORG: \d+\r\n/,
46
- /CONFIGID.UPNP.ORG: \d+\r\n/,
47
95
  ].freeze,
96
+ :byebye => [
97
+ /NTS: ssdp:byebye\r\n/,
98
+ ].freeze,
99
+ :update => [
100
+ /LOCATION: http:\/\/(.*)\r\n/,
101
+ /NTS: ssdp:update\r\n/,
102
+ /NEXTBOOTID.UPNP.ORG: \d+\r\n/,
103
+ ].freeze
48
104
  }
49
105
 
50
106
  RSpec::Matchers.define :be_a_notify_packet do |type|
51
107
  match do |packet|
52
- success = NOTIFY_REGEX[type].all? { |r| packet.match(r) }
108
+ success = NOTIFY_REGEX[:common].all? { |r| packet.match(r) }
109
+ success &&= NOTIFY_REGEX[type].all? { |r| packet.match(r) }
53
110
  success && packet[-4..-1] == "\r\n\r\n"
54
111
  end
55
112
  end
@@ -68,3 +125,4 @@ RSpec::Matchers.define :be_a_msearch_packet do
68
125
  success && packet[-4..-1] == "\r\n\r\n"
69
126
  end
70
127
  end
128
+
@@ -16,7 +16,7 @@ module RUPNP
16
16
  u_search_port: 1900}
17
17
  end
18
18
 
19
- [:alive, :byebye].each do |type|
19
+ [:alive, :update, :byebye].each do |type|
20
20
  it "should send 2 #{type} notify packets" do
21
21
  em do
22
22
  receiver = EM.open_datagram_socket(MULTICAST_IP, DISCOVERY_PORT,
@@ -47,6 +47,37 @@ module RUPNP
47
47
  end
48
48
  end
49
49
 
50
+ it "should discard and log bad responses" do
51
+ reader, writer = IO.pipe
52
+ begin
53
+ RUPNP.logdev = writer
54
+ em do
55
+ receiver = EM.open_datagram_socket(MULTICAST_IP, DISCOVERY_PORT,
56
+ FakeMulticast)
57
+ receiver.onmessage do
58
+ receiver.send_data "HTTP/1.1 404 Not Found\r\n\r\n"
59
+ receiver.send_data "this is not an HTTP message!"
60
+ end
61
+ searcher = RUPNP::SSDP.search(:all, config.merge(:try_number => 1))
62
+ searcher.discovery_responses.subscribe do |n|
63
+ next unless n['server'] =~ /RUPNP/
64
+ fail
65
+ end
66
+
67
+ EM.add_timer(1) do
68
+ expect(reader.readline).to match(/\[error\] bad HTTP response/)
69
+ expect(reader.readline).to match(/404/)
70
+ reader.readline
71
+ expect(reader.readline).to match(/\[error\] bad HTTP response/)
72
+ expect(reader.readline).to match(/not/)
73
+ done
74
+ end
75
+ end
76
+ ensure
77
+ reader.close
78
+ writer.close
79
+ end
80
+ end
50
81
  end
51
82
 
52
83
  end
data/tasks/gem.rake CHANGED
@@ -19,7 +19,7 @@ EOF
19
19
  # For now, device is not in gem.
20
20
  files -= ['lib/rupnp/device.rb', 'spec/device_spec.rb']
21
21
  s.files = files
22
- s.executables = "discover"
22
+ s.executables = ["discover"]
23
23
 
24
24
  s.add_dependency 'uuid', '~>2.3.0'
25
25
  s.add_dependency 'eventmachine-le', '~> 1.1.6'
@@ -30,6 +30,8 @@ EOF
30
30
 
31
31
  s.add_development_dependency 'rspec', '~>2.14.7'
32
32
  s.add_development_dependency 'em-spec', '~>0.2.6'
33
+ s.add_development_dependency 'simplecov', '~>0.8.2'
34
+ s.add_development_dependency 'webmock', '~>1.16.1'
33
35
  end
34
36
 
35
37
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rupnp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-01-08 00:00:00.000000000 Z
12
+ date: 2014-01-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: uuid
@@ -139,6 +139,38 @@ dependencies:
139
139
  - - ~>
140
140
  - !ruby/object:Gem::Version
141
141
  version: 0.2.6
142
+ - !ruby/object:Gem::Dependency
143
+ name: simplecov
144
+ requirement: !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ~>
148
+ - !ruby/object:Gem::Version
149
+ version: 0.8.2
150
+ type: :development
151
+ prerelease: false
152
+ version_requirements: !ruby/object:Gem::Requirement
153
+ none: false
154
+ requirements:
155
+ - - ~>
156
+ - !ruby/object:Gem::Version
157
+ version: 0.8.2
158
+ - !ruby/object:Gem::Dependency
159
+ name: webmock
160
+ requirement: !ruby/object:Gem::Requirement
161
+ none: false
162
+ requirements:
163
+ - - ~>
164
+ - !ruby/object:Gem::Version
165
+ version: 1.16.1
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ none: false
170
+ requirements:
171
+ - - ~>
172
+ - !ruby/object:Gem::Version
173
+ version: 1.16.1
142
174
  description: ! 'RUPNP is a Ruby UPnP framework. For now, only control points (clients)
143
175
 
144
176
  are supported. Devices (servers) will be later.
@@ -153,6 +185,7 @@ files:
153
185
  - spec/ssdp/listener_spec.rb
154
186
  - spec/ssdp/notifier_spec.rb
155
187
  - spec/ssdp/searcher_spec.rb
188
+ - spec/control_point_spec.rb
156
189
  - spec/spec_helper.rb
157
190
  - lib/rupnp.rb
158
191
  - lib/rupnp/constants.rb