rupnp 0.1.0 → 0.2.0

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