rupnp 0.1.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/MIT-LICENSE +34 -0
- data/README.md +45 -0
- data/Rakefile +3 -0
- data/bin/discover +4 -0
- data/lib/rupnp/constants.rb +31 -0
- data/lib/rupnp/control_point.rb +173 -0
- data/lib/rupnp/cp/base.rb +67 -0
- data/lib/rupnp/cp/event_server.rb +52 -0
- data/lib/rupnp/cp/event_subscriber.rb +49 -0
- data/lib/rupnp/cp/remote_device.rb +271 -0
- data/lib/rupnp/cp/remote_service.rb +304 -0
- data/lib/rupnp/discover.rb +55 -0
- data/lib/rupnp/event.rb +32 -0
- data/lib/rupnp/log_mixin.rb +30 -0
- data/lib/rupnp/ssdp/http.rb +45 -0
- data/lib/rupnp/ssdp/listener.rb +45 -0
- data/lib/rupnp/ssdp/msearch_responder.rb +19 -0
- data/lib/rupnp/ssdp/multicast_connection.rb +48 -0
- data/lib/rupnp/ssdp/notifier.rb +93 -0
- data/lib/rupnp/ssdp/search_responder.rb +115 -0
- data/lib/rupnp/ssdp/searcher.rb +69 -0
- data/lib/rupnp/ssdp/usearch_responder.rb +29 -0
- data/lib/rupnp/ssdp.rb +47 -0
- data/lib/rupnp/tools.rb +66 -0
- data/lib/rupnp.rb +62 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/ssdp/listener_spec.rb +102 -0
- data/spec/ssdp/notifier_spec.rb +61 -0
- data/spec/ssdp/searcher_spec.rb +53 -0
- data/tasks/gem.rake +39 -0
- data/tasks/spec.rake +3 -0
- data/tasks/yard.rake +6 -0
- metadata +209 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
Copyright (C) 2013 Sylvain Daubert
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
4
|
+
this software and associated documentation files (the "Software"), to deal in
|
5
|
+
the Software without restriction, including without limitation the rights to
|
6
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
7
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
8
|
+
so, subject to the following conditions:
|
9
|
+
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
11
|
+
copies or substantial portions of the Software.
|
12
|
+
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
+
SOFTWARE.
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
Search part of this library is derived from turboladen/upnp:
|
24
|
+
Copyright (c) 2012 Steve Loveless
|
25
|
+
|
26
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
27
|
+
this software and associated documentation files (the ‘Software’), to deal in
|
28
|
+
the Software without restriction, including without limitation the rights to
|
29
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
30
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
31
|
+
so, subject to the following conditions:
|
32
|
+
|
33
|
+
The above copyright notice and this permission notice shall be included in all
|
34
|
+
copies or substantial portions of the Software.
|
data/README.md
ADDED
@@ -0,0 +1,45 @@
|
|
1
|
+
RUPNP will be a Ruby UPNP framework.
|
2
|
+
|
3
|
+
Its first purpose is to make my first eventmachine development.
|
4
|
+
|
5
|
+
## Create a control point
|
6
|
+
RUPNP will help you to create a UPnP control point (a client) :
|
7
|
+
```ruby
|
8
|
+
require 'rupnp'
|
9
|
+
|
10
|
+
EM.run do
|
11
|
+
# Search for root devices
|
12
|
+
cp = RUPNP::ControlPoint(:root)
|
13
|
+
cp.start do |new_devices, disappeared_devices|
|
14
|
+
new_devices.subscribe do |device|
|
15
|
+
# Do what you want here with new devices
|
16
|
+
# Services are available through device.services
|
17
|
+
end
|
18
|
+
disappeared_devices.subscribe do |device|
|
19
|
+
# Do what you want here with devices which unsubscribe
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
```
|
24
|
+
## Create a device
|
25
|
+
TODO
|
26
|
+
|
27
|
+
## `discover` utility
|
28
|
+
`discover` is a command line utility to act as a control point:
|
29
|
+
```
|
30
|
+
$ discover
|
31
|
+
discover> search ssdp:all
|
32
|
+
1 devices found
|
33
|
+
discover> devices[0].class
|
34
|
+
=> RUPNP::CP::RemoteDevice
|
35
|
+
discover>
|
36
|
+
```
|
37
|
+
|
38
|
+
The `search` command take an argument : the target for a UPnP M-SEARCH. This
|
39
|
+
argument may be:
|
40
|
+
* `ssdp:all`;
|
41
|
+
* `upnp:rootdevice`;
|
42
|
+
* a URN as `upnp:{URN}`.
|
43
|
+
If no argument is given, default to `ssdp:all`.
|
44
|
+
|
45
|
+
`discover` use `pry`. So, in `discover`, you can use the power of Pry.
|
data/Rakefile
ADDED
data/bin/discover
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'socket'
|
2
|
+
|
3
|
+
module RUPNP
|
4
|
+
|
5
|
+
# Multicast IP for UPnP
|
6
|
+
MULTICAST_IP = '239.255.255.250'.freeze
|
7
|
+
|
8
|
+
# Default port for UPnP
|
9
|
+
DISCOVERY_PORT = 1900
|
10
|
+
|
11
|
+
# Default TTL for UPnP
|
12
|
+
DEFAULT_TTL = 2
|
13
|
+
|
14
|
+
# UPnP version
|
15
|
+
UPNP_VERSION = '1.1'.freeze
|
16
|
+
|
17
|
+
# User agent for UPnP messages
|
18
|
+
USER_AGENT = `uname -s`.chomp + "/#{`uname -r `.chomp.gsub(/-.*/, '')} " +
|
19
|
+
"UPnP/#{UPNP_VERSION} rupnp/#{VERSION}".freeze
|
20
|
+
|
21
|
+
# Host IP
|
22
|
+
HOST_IP = Socket.ip_address_list.
|
23
|
+
find_all { |ai| ai.ipv4? && !ai.ipv4_loopback? }.last.ip_address.freeze
|
24
|
+
|
25
|
+
# Default port for listening for events
|
26
|
+
EVENT_SUB_DEFAULT_PORT = 8080
|
27
|
+
|
28
|
+
# Default timeout for event subscription (in seconds)
|
29
|
+
EVENT_SUB_DEFAULT_TIMEOUT = 30 * 60
|
30
|
+
|
31
|
+
end
|
@@ -0,0 +1,173 @@
|
|
1
|
+
module RUPNP
|
2
|
+
|
3
|
+
# This class is the base one for control points (clients in UPnP
|
4
|
+
# terminology).
|
5
|
+
#
|
6
|
+
# To create a control point :
|
7
|
+
# EM.run do
|
8
|
+
# cp = RUPNP::ControlPoint.new(:root)
|
9
|
+
# cp.start do |new_devices, disappeared_devices|
|
10
|
+
# new_devices.subscribe do |device|
|
11
|
+
# puts "New device: #{device.udn}"
|
12
|
+
# end
|
13
|
+
# end
|
14
|
+
# end
|
15
|
+
# @author Sylvain Daubert
|
16
|
+
class ControlPoint
|
17
|
+
include LogMixin
|
18
|
+
include Tools
|
19
|
+
|
20
|
+
# Default response wait time for searching devices. This is set
|
21
|
+
# to the maximum value from UPnP 1.1 specification.
|
22
|
+
DEFAULT_RESPONSE_WAIT_TIME = 5
|
23
|
+
|
24
|
+
# Get event listening port
|
25
|
+
# @return [Integer]
|
26
|
+
attr_reader :event_port
|
27
|
+
# Return channel to add event URL (URL to listen for a specific
|
28
|
+
# event)
|
29
|
+
# @return [EM::Channel]
|
30
|
+
attr_reader :add_event_url
|
31
|
+
# Return remote devices controlled by this control point
|
32
|
+
# @return [Array<CP::RemoteDevice>]
|
33
|
+
attr_reader :devices
|
34
|
+
|
35
|
+
|
36
|
+
# @param [Symbol,String] search_target target to search for.
|
37
|
+
# May be +:all+, +:root+ or a device identifier
|
38
|
+
# @param [Hash] search_options
|
39
|
+
# @option search_options [Integer] :response_wait_time time to wait
|
40
|
+
# for responses from devices
|
41
|
+
# @option search_options [Integer] :try_number number or search
|
42
|
+
# requests to send (specification says that 2 is a minimum)
|
43
|
+
def initialize(search_target, search_options={})
|
44
|
+
@search_target = search_target
|
45
|
+
@search_options = search_options
|
46
|
+
@search_options[:response_wait_time] ||= DEFAULT_RESPONSE_WAIT_TIME
|
47
|
+
|
48
|
+
@devices = []
|
49
|
+
@new_device_channel = EM::Channel.new
|
50
|
+
@bye_device_channel = EM::Channel.new
|
51
|
+
end
|
52
|
+
|
53
|
+
# Start control point.
|
54
|
+
# This methos starts a search for devices. Then, listening is
|
55
|
+
# performed for device notifications.
|
56
|
+
# @yieldparam new_device_channel [EM::Channel]
|
57
|
+
# channel on which new devices are announced
|
58
|
+
# @yieldparam bye_device_channel [EM::Channel]
|
59
|
+
# channel on which +byebye+ device notifications are announced
|
60
|
+
# @return [void]
|
61
|
+
def start
|
62
|
+
search_devices_and_listen @search_target, @search_options
|
63
|
+
yield @new_device_channel, @bye_device_channel
|
64
|
+
end
|
65
|
+
|
66
|
+
def search_only
|
67
|
+
options = @search_options.dup
|
68
|
+
options[:search_only] = true
|
69
|
+
search_devices_and_listen @search_target, options
|
70
|
+
end
|
71
|
+
|
72
|
+
# Start event server for listening for device events
|
73
|
+
# @param [Integer] port port to listen for
|
74
|
+
# @return [void]
|
75
|
+
def start_event_server(port=EVENT_SUB_DEFAULT_PORT)
|
76
|
+
@event_port = port
|
77
|
+
@add_event_url = EM::Channel.new
|
78
|
+
@event_server ||= EM.start_server('0.0.0.0', port, CP::EventServer,
|
79
|
+
@add_event_url)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Stop event server
|
83
|
+
# @see #start_event_server
|
84
|
+
# @return [void]
|
85
|
+
def stop_event_server
|
86
|
+
EM.stop_server @event_server
|
87
|
+
end
|
88
|
+
|
89
|
+
# Add a device to the control point
|
90
|
+
# @param [Device] device device to add
|
91
|
+
# @return [void]
|
92
|
+
def add_device(device)
|
93
|
+
if has_already_device?(device)
|
94
|
+
log :info, "Device already in database: #{device.udn}"
|
95
|
+
existing_device = self.find_device_by_udn(device.udn)
|
96
|
+
if existing_device.expiration < device.expiration
|
97
|
+
log :info, 'update expiration time for device #{device.udn}'
|
98
|
+
@devices.delete existing_device
|
99
|
+
@devices << device
|
100
|
+
end
|
101
|
+
else
|
102
|
+
log :info, "adding device #{device.udn}"
|
103
|
+
@devices << device
|
104
|
+
@new_device_channel << device
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
# Find a device from control point's device list by its UDN
|
110
|
+
# @param [String] udn
|
111
|
+
# @return [Device,nil]
|
112
|
+
def find_device_by_udn(udn)
|
113
|
+
@devices.find { |d| d.udn == udn }
|
114
|
+
end
|
115
|
+
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def search_devices_and_listen(target, options)
|
120
|
+
log :info, 'search for devices'
|
121
|
+
searcher = SSDP.search(target, options)
|
122
|
+
|
123
|
+
EM.add_timer(@search_options[:response_wait_time] + 1) do
|
124
|
+
log :info, 'search timeout'
|
125
|
+
searcher.close_connection
|
126
|
+
|
127
|
+
unless options[:search_only]
|
128
|
+
log :info, 'now listening for device advertisement'
|
129
|
+
listener = SSDP.listen
|
130
|
+
|
131
|
+
listener.notifications.subscribe do |notification|
|
132
|
+
case notification['nts']
|
133
|
+
when 'ssdp:alive'
|
134
|
+
create_device notification
|
135
|
+
when 'ssdp:byebye'
|
136
|
+
udn = usn2udn(notification['usn'])
|
137
|
+
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
|
140
|
+
else
|
141
|
+
log :warn, "Unknown notification type: #{notification['nts']}"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
searcher.discovery_responses.subscribe do |notification|
|
148
|
+
log :debug, 'receive a notification'
|
149
|
+
create_device notification
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def create_device(notification)
|
154
|
+
device = CP::RemoteDevice.new(self, notification)
|
155
|
+
|
156
|
+
device.errback do |device, message|
|
157
|
+
log :warn, message
|
158
|
+
end
|
159
|
+
|
160
|
+
device.callback do |device|
|
161
|
+
add_device device
|
162
|
+
end
|
163
|
+
|
164
|
+
device.fetch
|
165
|
+
end
|
166
|
+
|
167
|
+
def has_already_device?(dev)
|
168
|
+
@devices.any? { |d| d.udn == dev.udn || d.usn == dev.usn }
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'em-http-request'
|
2
|
+
|
3
|
+
|
4
|
+
module RUPNP
|
5
|
+
|
6
|
+
# CP module to group all control point's classes
|
7
|
+
# @author Sylvain Daubert
|
8
|
+
module CP
|
9
|
+
|
10
|
+
# Base class for devices and services
|
11
|
+
# @author Sylvain Daubert
|
12
|
+
class Base
|
13
|
+
include EM::Deferrable
|
14
|
+
include Tools
|
15
|
+
include LogMixin
|
16
|
+
|
17
|
+
# Common HTTP headers for description requests
|
18
|
+
HTTP_COMMON_CONFIG = {
|
19
|
+
:head => {
|
20
|
+
:user_agent => USER_AGENT,
|
21
|
+
:host => "#{HOST_IP}:#{DISCOVERY_PORT}",
|
22
|
+
},
|
23
|
+
}
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@parser = Nori.new(:convert_tags_to => ->(tag){ tag.snakecase.to_sym })
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get description from +location+
|
30
|
+
# @param [String] location
|
31
|
+
# @param [EM::Defferable] getter deferrable to advice about failure
|
32
|
+
# or success. On fail, +getter+ receive a message. On success, it
|
33
|
+
# receive a description (XML Nori hash)
|
34
|
+
# @return [void]
|
35
|
+
def get_description(location, getter)
|
36
|
+
log :info, "getting description for #{location}"
|
37
|
+
http = EM::HttpRequest.new(location).get(HTTP_COMMON_CONFIG)
|
38
|
+
|
39
|
+
http.errback do |error|
|
40
|
+
getter.set_deferred_status :failed, 'Cannot get description'
|
41
|
+
end
|
42
|
+
|
43
|
+
callback = Proc.new do
|
44
|
+
description = @parser.parse(http.response)
|
45
|
+
log :debug, 'Description received'
|
46
|
+
getter.succeed description
|
47
|
+
end
|
48
|
+
|
49
|
+
http.headers do |h|
|
50
|
+
unless h['SERVER'] =~ /UPnP\/1\.\d/
|
51
|
+
log :error, "Not a supported UPnP response : #{h['SERVER']}"
|
52
|
+
http.cancel_callback callback
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
http.callback &callback
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return String
|
60
|
+
def inspect
|
61
|
+
"#<#{self.class}:#{object_id} type=#{type.inspect}>"
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'em-http-server'
|
2
|
+
|
3
|
+
module RUPNP
|
4
|
+
|
5
|
+
# Event server to receive events from services.
|
6
|
+
# @author Sylvain Daubert
|
7
|
+
class CP::EventServer < EM::HttpServer::Server
|
8
|
+
include LogMixin
|
9
|
+
|
10
|
+
# Channel to add url for listening to
|
11
|
+
# @return [EM::Channel]
|
12
|
+
attr_reader :add_url
|
13
|
+
|
14
|
+
|
15
|
+
# @param [EM::Channel] add_url_channel channel for adding url
|
16
|
+
def initialize(add_url_channel)
|
17
|
+
super
|
18
|
+
|
19
|
+
@urls = []
|
20
|
+
@add_url = add_url_channel
|
21
|
+
|
22
|
+
@add_url.subscribe do |url|
|
23
|
+
log :info, "add URL #{url} for eventing"
|
24
|
+
@urls << url
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Process a HTTP request received from a service/device
|
29
|
+
def process_http_request
|
30
|
+
log :debug, 'EventServer: receive request'
|
31
|
+
url, event = @urls.find { |a| a[0] == @http_request_uri }
|
32
|
+
|
33
|
+
if event.is_a? EM::Channel
|
34
|
+
if @http_request_method == 'NOTIFY'
|
35
|
+
if @http[:nt] == 'upnp:event' and @http[:nts] == 'upnp:propchange'
|
36
|
+
event << {
|
37
|
+
:sid => @http[:sid],
|
38
|
+
:seq => @http[:seq],
|
39
|
+
:content => @http_content }
|
40
|
+
else
|
41
|
+
log :warn, 'EventServer: ' +
|
42
|
+
"malformed NOTIFY event message:\n#@http_headers\n#@http_content"
|
43
|
+
end
|
44
|
+
else
|
45
|
+
log :warn, "EventServer: unknown HTTP verb: #@http_request_method"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
module RUPNP
|
2
|
+
|
3
|
+
# Event subscriber to an event's service
|
4
|
+
# @author Sylvain Daubert
|
5
|
+
class CP::EventSubscriber < EM::Connection
|
6
|
+
include LogMixin
|
7
|
+
|
8
|
+
# Response from device
|
9
|
+
# @return [EM::Channel]
|
10
|
+
attr_reader :response
|
11
|
+
|
12
|
+
|
13
|
+
# @param [String] msg message to send for subscribing
|
14
|
+
def initialize(msg)
|
15
|
+
@msg = msg
|
16
|
+
@response = EM::Channel.new
|
17
|
+
end
|
18
|
+
|
19
|
+
# @return [void]
|
20
|
+
def post_init
|
21
|
+
log :debug, "send event subscribe request:\n#@msg"
|
22
|
+
send_data @msg
|
23
|
+
end
|
24
|
+
|
25
|
+
# Receive response from device and send it through {#response}
|
26
|
+
# @param [String] data
|
27
|
+
# @return [void]
|
28
|
+
def receive_data(data)
|
29
|
+
log :debug, "receive data from subscribe event action:\n#{data}"
|
30
|
+
resp = {}
|
31
|
+
io = StringIO.new(data)
|
32
|
+
|
33
|
+
status = io.readline
|
34
|
+
status =~ /HTTP\/1\.1 (\d+) (.+)/
|
35
|
+
resp[:status] = $2
|
36
|
+
resp[:status_code] = $1
|
37
|
+
|
38
|
+
io.each_line do |line|
|
39
|
+
if line =~ /(\w+):\s*(.*)/
|
40
|
+
resp[$1.downcase.to_sym] = $2.chomp
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
@response << resp
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|