rupnp 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|