rupnp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,3 @@
1
+ Dir.glob('tasks/*.rake').each do |file|
2
+ load file
3
+ end
data/bin/discover ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require 'rupnp/discover'
3
+
4
+ RUPNP::Discover.run
@@ -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