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