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.
@@ -0,0 +1,45 @@
1
+ module RUPNP
2
+ module SSDP
3
+
4
+ # HTTP module to provide some helper methods
5
+ # @author Sylvain Daubert
6
+ module SSDP::HTTP
7
+
8
+ # Return status from HTTP response
9
+ # @param [IO] sock
10
+ # @return [Booelan]
11
+ def is_http_status_ok?(sock)
12
+ sock.readline =~ /\s*HTTP\/1.1 200 OK\r\n\z/i
13
+ end
14
+
15
+ # Get HTTP headers from response
16
+ # @param [IO] sock
17
+ # @return [Hash] keys are downcase header name strings
18
+ def get_http_headers(sock)
19
+ headers = {}
20
+ sock.each_line do |l|
21
+ l =~ /([\w\.-]+):\s*(.*)/
22
+ if $1
23
+ headers[$1.downcase] = $2.strip
24
+ end
25
+ end
26
+ headers
27
+ end
28
+
29
+ # Get HTTP verb from HTTP request
30
+ # @param [IO] sock
31
+ # @return [nil,Hash] keys are +:verb+, +:path+, +:http_version+ and
32
+ # +:cmd+ (all line)
33
+ def get_http_verb(sock)
34
+ str = sock.readline
35
+ if str =~ /([\w-]+)\s+(.*)\s+HTTP\/(\d\.\d)/
36
+ {:verb => $1, :path => $2, :http_version => $3, :cmd => str}
37
+ else
38
+ nil
39
+ end
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,45 @@
1
+ module RUPNP
2
+
3
+ # Listener class for listening for devices' notifications
4
+ # @author Sylvain Daubert
5
+ class SSDP::Listener < SSDP::MulticastConnection
6
+ include SSDP::HTTP
7
+
8
+ # Channel to receive notifications
9
+ # @return [EM::Channel]
10
+ attr_reader :notifications
11
+
12
+ # @param [Hash] options
13
+ # @option options [Integer] :ttl
14
+ def initialize(options={})
15
+ @notifications = EM::Channel.new
16
+
17
+ super options[:ttl]
18
+ end
19
+
20
+ # @private
21
+ def receive_data(data)
22
+ port, ip = peer_info
23
+ log :info, "Receive notification from #{ip}:#{port}"
24
+ log :debug, data
25
+
26
+ io = StringIO.new(data)
27
+ h = get_http_verb(io)
28
+
29
+ if h.nil?
30
+ log :warn, "No HTTP command"
31
+ return
32
+ elsif h[:verb] == 'M-SEARCH'
33
+ return
34
+ elsif !(h[:verb].upcase == 'NOTIFY' and h[:path] == '*' and
35
+ h[:http_version] == '1.1')
36
+ log :warn, "Unknown HTTP command: #{h[:cmd]}"
37
+ return
38
+ end
39
+
40
+ @notifications << get_http_headers(io)
41
+ end
42
+
43
+ end
44
+
45
+ end
@@ -0,0 +1,19 @@
1
+ module RUPNP
2
+
3
+ # M-SEARCH responder for M-SEARCH multicast requests from control points.
4
+ # @author Sylvain Daubert
5
+ class SSDP::MSearchResponder < SSDP::MulticastConnection
6
+ include HTTP
7
+ include SSDP::SearchResponder
8
+
9
+ # @param [Hash] options
10
+ # @option options [Integer] :ttl
11
+ def initialize(device, options={})
12
+ @device = device
13
+ @options = options
14
+ super options[:ttl]
15
+ end
16
+
17
+ end
18
+
19
+ end
@@ -0,0 +1,48 @@
1
+ require 'socket'
2
+ require 'ipaddr'
3
+
4
+ module RUPNP
5
+
6
+ # Base class for multicast connections (mainly SSDP search and listen)
7
+ # @abstract
8
+ class SSDP::MulticastConnection < EM::Connection
9
+ include LogMixin
10
+
11
+ # @param [Integer] ttl
12
+ def initialize(ttl=nil)
13
+ @ttl = ttl || DEFAULT_TTL
14
+ setup_multicast_socket
15
+ end
16
+
17
+ # Get peer info
18
+ # @return [Array] [port, hostname]
19
+ def peer_info
20
+ Socket.unpack_sockaddr_in(get_peername)
21
+ end
22
+
23
+
24
+ private
25
+
26
+ def setup_multicast_socket
27
+ set_membership IPAddr.new(MULTICAST_IP).hton + IPAddr.new('0.0.0.0').hton
28
+ set_ttl
29
+ set_reuse_addr
30
+ end
31
+
32
+ def set_membership(value)
33
+ set_sock_opt Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, value
34
+ end
35
+
36
+ def set_ttl
37
+ value = [@ttl].pack('i')
38
+ set_sock_opt Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, value
39
+ set_sock_opt Socket::IPPROTO_IP, Socket::IP_TTL, value
40
+ end
41
+
42
+ def set_reuse_addr
43
+ set_sock_opt Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true
44
+ end
45
+
46
+ end
47
+
48
+ end
@@ -0,0 +1,93 @@
1
+
2
+ module RUPNP
3
+
4
+ # Searcher class for searching devices
5
+ # @author Sylvain Daubert
6
+ class SSDP::Notifier < SSDP::MulticastConnection
7
+ include SSDP::HTTP
8
+
9
+ # Number of SEARCH datagrams to send
10
+ DEFAULT_NOTIFY_TRY = 2
11
+
12
+ # @param [Hash] options
13
+ # @option options [Integer] :try_number
14
+ # @option options [Integer] :ttl
15
+ # @option options [String] :ip
16
+ def initialize(type, subtype, options={})
17
+ @type = (type == :root) ? 'upnp:rootdevice' : type
18
+ @subtype = subtype
19
+ @notify_count = options[:try_number] || DEFAULT_NOTIFY_TRY
20
+ @options = options
21
+
22
+ super options.delete(:ttl)
23
+ end
24
+
25
+ # @private
26
+ def post_init
27
+ notify = notify_request
28
+ @notify_count.times do
29
+ send_datagram notify, MULTICAST_IP, DISCOVERY_PORT
30
+ log :debug, "#{self.class}: send datagram:\n#{notify}"
31
+ end
32
+ close_connection_after_writing
33
+ end
34
+
35
+ # @private
36
+ def receive_data(data)
37
+ end
38
+
39
+
40
+ private
41
+
42
+ def notify_request
43
+ usn = if @type[0..3] == 'uuid'
44
+ @type
45
+ else
46
+ "uuid:#{@options[:uuid]}::#@type"
47
+ end
48
+ case @subtype
49
+ when :alive
50
+ <<EOD
51
+ NOTIFY * HTTP/1.1\r
52
+ HOST: #{MULTICAST_IP}:#{DISCOVERY_PORT}\r
53
+ CACHE-CONTROL: max-age = #{@options[:max_age]}\r
54
+ LOCATION: http://#{@options[:ip]}:#{@options[:port]}/root_description.xml\r
55
+ NT: #@type\r
56
+ NTS: ssdp:#@subtype\r
57
+ SERVER: #{USER_AGENT}\r
58
+ USN: #{usn}\r
59
+ BOOTID.UPNP.ORG: #{@options[:boot_id]}\r
60
+ CONFIGID.UPNP.ORG: #{@options[:config_id]}\r
61
+ SEARCHPORT.UPNP.ORG: #{@options[:u_search_port]}\r
62
+ \r
63
+ EOD
64
+ when :byebye
65
+ <<EOD
66
+ NOTIFY * HTTP/1.1\r
67
+ HOST: #{MULTICAST_IP}:#{DISCOVERY_PORT}\r
68
+ NT: #@type\r
69
+ NTS: ssdp:#@subtype\r
70
+ USN: #{usn}\r
71
+ BOOTID.UPNP.ORG: #{@options[:boot_id]}\r
72
+ CONFIGID.UPNP.ORG: #{@options[:config_id]}\r
73
+ \r
74
+ EOD
75
+ when :update
76
+ <<EOD
77
+ NOTIFY * HTTP/1.1\r
78
+ HOST: #{MULTICAST_IP}:#{DISCOVERY_PORT}\r
79
+ LOCATION: http://#{@options[:ip]}:#{@options[:port]}/root_description.xml\r
80
+ NT: #@type\r
81
+ NTS: ssdp:#@subtype\r
82
+ USN: #{usn}\r
83
+ BOOTID.UPNP.ORG: #{(@options[:boot_id] - 1) % 2**31}\r
84
+ CONFIGID.UPNP.ORG: #{@options[:config_id]}\r
85
+ NEXTBOOTID.UPNP.ORG: #{@options[:boot_id]}\r
86
+ SEARCHPORT.UPNP.ORG: #{@options[:u_search_port]}\r
87
+ \r
88
+ EOD
89
+ end
90
+ end
91
+
92
+ end
93
+ end
@@ -0,0 +1,115 @@
1
+ module RUPNP
2
+
3
+ # M-SEARCH responder for M-SEARCH multicast requests from control points.
4
+ # @author Sylvain Daubert
5
+ module SSDP::SearchResponder
6
+ include SSDP::HTTP
7
+
8
+ def receive_data(data)
9
+ port, ip = peer_info
10
+ log :debug, "#{self.class}: Receive data from #{ip}:#{port}"
11
+ log :debug, data
12
+
13
+ io = StringIO.new(data)
14
+ h = get_http_verb(io)
15
+
16
+ if h[:verb].upcase == 'NOTIFY'
17
+ return
18
+ end
19
+
20
+ if h.nil? or !(h[:verb].upcase == 'M-SEARCH' and h[:path] == '*' and
21
+ h[:http_version] == '1.1')
22
+ log :warn, "#{self.class}: Unknown HTTP command: #{h[:cmd]}"
23
+ return
24
+ end
25
+
26
+ h = get_http_headers(io)
27
+ if h['man'] != '"ssdp:discover"'
28
+ log :warn, "#{self.class}: Unknown MAN field: #{h['man']}"
29
+ return
30
+ end
31
+
32
+ log :info, "#{self.class}: Receive M-SEARCH request from #{ip}:#{port}"
33
+
34
+ callback = nil
35
+ case h['st']
36
+ when 'ssdp:all'
37
+ callback = Proc.new do
38
+ send_response 'upnp:rootdevice'
39
+ send_response "uuid:#{@device.uuid}"
40
+ send_response "urn:#{@device.urn}"
41
+ @device.services.each do |s|
42
+ send_response "urn:#{s.urn}"
43
+ end
44
+ end
45
+ when 'upnp:rootdevice'
46
+ callback = Proc.new { send_response 'upnp:rootdevice' }
47
+ when /^uuid:([0-9a-fA-F-]+)/
48
+ if $1 and $1 == @device.uuid
49
+ callback = Proc.new { send_response "uuid:#{@device.uuid}" }
50
+ end
51
+ when /^urn:schemas-upnp-org:(\w+):(\w+):(\w+)/
52
+ case $1
53
+ when 'device'
54
+ if urn_are_equivalent?(h['st'], @device.urn)
55
+ callback = Proc.new { send_response "urn:#{@device.urn}" }
56
+ end
57
+ when 'service'
58
+ if @device.services.one? { |s| urn_are_equivalent? h['st'], s.urn }
59
+ callback = Proc.new { send_response h['st'] }
60
+ end
61
+ end
62
+ end
63
+
64
+ if callback
65
+ if self.is_a? SSDP::MulticastConnection
66
+ if h['mx']
67
+ mx = h['mx'].to_i
68
+ # MX MUST not be greater than 5
69
+ mx = 5 if mx > 5
70
+ # Wait for a random time less than MX
71
+ wait_time = rand(mx)
72
+ EM.add_timer wait_time, &callback
73
+ else
74
+ log :warn, "#{self.class}: Multicast M-SEARCH request with no MX" +
75
+ " field. Discarded."
76
+ end
77
+ else
78
+ # Unicast request. Don't bother for MX field.
79
+ callback.call
80
+ end
81
+ else
82
+ log :debug, 'No response sent'
83
+ end
84
+ end
85
+
86
+
87
+ def send_response(st)
88
+ usn = "uuid:#{@device.uuid}"
89
+ usn += case st
90
+ when 'upnp:rootdevice', /^urn/
91
+ "::#{st}"
92
+ else
93
+ ''
94
+ end
95
+ response =<<EOR
96
+ HTTP/1.1 200 OK\r
97
+ CACHE-CONTROL: max-age = #{@options[:max_age]}\r
98
+ DATE: #{Time.now.httpdate}\r
99
+ EXT:\r
100
+ LOCATION: http://#{@options[:ip]}/root_description.xml\r
101
+ SERVER: #{USER_AGENT}\r
102
+ ST: #{st}
103
+ USN: #{usn}\r
104
+ BOOTID.UPNP.ORG: #{@options[:boot_id]}\r
105
+ CONFIGID.UPNP.ORG: #{@options[:config_id]}\r
106
+ SEARCHPORT.UPNP.ORG: #{@options[:u_search_port]}\r
107
+ \r
108
+ EOR
109
+
110
+ send_data response
111
+ end
112
+
113
+ end
114
+
115
+ end
@@ -0,0 +1,69 @@
1
+
2
+ module RUPNP
3
+
4
+ # Searcher class for searching devices
5
+ # @author Sylvain Daubert
6
+ class SSDP::Searcher < SSDP::MulticastConnection
7
+ include SSDP::HTTP
8
+
9
+ # Number of SEARCH datagrams to send
10
+ DEFAULT_M_SEARCH_TRY = 2
11
+
12
+ # Channel to receive discovery responses
13
+ # @return [EM::Channel]
14
+ attr_reader :discovery_responses
15
+
16
+
17
+ # @param [Hash] options
18
+ # @option options [String] :search_target
19
+ # @option options [Integer] :response_wait_time
20
+ # @option options [Integer] :try_number
21
+ # @option options [Integer] :ttl
22
+ def initialize(options={})
23
+ @options = options
24
+ @options[:try_number] ||= DEFAULT_M_SEARCH_TRY
25
+ @discovery_responses = EM::Channel.new
26
+
27
+ super options[:ttl]
28
+ end
29
+
30
+ # @private
31
+ def post_init
32
+ search = search_request
33
+ @options[:try_number].times do
34
+ send_datagram search, MULTICAST_IP, DISCOVERY_PORT
35
+ log :debug, "send datagram:\n#{search}"
36
+ end
37
+ end
38
+
39
+ # @private
40
+ def receive_data(data)
41
+ port, ip = peer_info
42
+ log :debug, "Response from #{ip}:#{port}"
43
+
44
+ response = StringIO.new(data)
45
+ if !is_http_status_ok?(response)
46
+ log :error, "bad HTTP response:\n #{data}"
47
+ return
48
+ end
49
+
50
+ @discovery_responses << get_http_headers(response)
51
+ end
52
+
53
+
54
+ private
55
+
56
+ def search_request
57
+ <<EOR
58
+ M-SEARCH * HTTP/1.1\r
59
+ HOST: #{MULTICAST_IP}:#{DISCOVERY_PORT}\r
60
+ MAN: "ssdp:discover"\r
61
+ MX: #{@options[:response_wait_time]}\r
62
+ ST: #{@options[:search_target]}\r
63
+ USER-AGENT: #{USER_AGENT}\r
64
+ \r
65
+ EOR
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,29 @@
1
+ require 'socket'
2
+
3
+ module RUPNP
4
+
5
+ # M-SEARCH responder for M-SEARCH unicast requests from control points.
6
+ # @author Sylvain Daubert
7
+ class SSDP::USearchResponder < EM::Connection
8
+ include HTTP
9
+ include SSDP::SearchResponder
10
+
11
+ # @param [Hash] options
12
+ # @option options [Integer] :ttl
13
+ def initialize(device, options={})
14
+ @device = device
15
+ @options = options
16
+ set_ttl options[:ttl] || DEFAULT_TTL
17
+ end
18
+
19
+
20
+ private
21
+
22
+ def set_ttl(ttl)
23
+ value = [ttl].pack('i')
24
+ set_sock_opt Socket::IPPROTO_IP, Socket::IP_TTL, value
25
+ end
26
+
27
+ end
28
+
29
+ end
data/lib/rupnp/ssdp.rb ADDED
@@ -0,0 +1,47 @@
1
+ require_relative 'ssdp/http'
2
+ require_relative 'ssdp/multicast_connection'
3
+ require_relative 'ssdp/searcher'
4
+ require_relative 'ssdp/listener'
5
+ require_relative 'ssdp/notifier'
6
+ require_relative 'ssdp/search_responder.rb'
7
+ require_relative 'ssdp/msearch_responder.rb'
8
+ require_relative 'ssdp/usearch_responder.rb'
9
+
10
+
11
+ module RUPNP
12
+
13
+ # SSDP module. This a discovery part of UPnP.
14
+ # @author Sylvain Daubert
15
+ module SSDP
16
+
17
+ # Some shorcut for common targets
18
+ KNOWN_TARGETS = {
19
+ :all => 'ssdp:all',
20
+ :root => 'upnp:rootdevice'
21
+ }
22
+
23
+
24
+ # Search devices
25
+ # @param [Symbol,String] target
26
+ # @param [Hash] options see {SSDP::Searcher#initialize}
27
+ def self.search(target=:all, options={})
28
+ options[:search_target] = KNOWN_TARGETS[target] || target
29
+ EM.open_datagram_socket '0.0.0.0', 0, SSDP::Searcher, options
30
+ end
31
+
32
+ # Listen for devices' announces
33
+ # @param [Hash] options see {SSDP::Listener#initialize}
34
+ def self.listen(options={})
35
+ EM.open_datagram_socket(MULTICAST_IP, DISCOVERY_PORT,
36
+ SSDP::Listener, options)
37
+ end
38
+
39
+ # Notify announces
40
+ # @param [Hash] options see {SSDP::Notifier#initialize}
41
+ def self.notify(type, stype, options={})
42
+ EM.open_datagram_socket(MULTICAST_IP, DISCOVERY_PORT,
43
+ SSDP::Notifier, type, stype, options)
44
+ end
45
+ end
46
+
47
+ end
@@ -0,0 +1,66 @@
1
+ module RUPNP
2
+
3
+ # Helper module
4
+ # @author Sylvain Daubert
5
+ module Tools
6
+
7
+ # Build an url from a base and a relative url
8
+ # @param [String] base
9
+ # @param [String] rest
10
+ # @return [String]
11
+ def build_url(base, rest)
12
+ url = base + (base.end_with?('/') ? '' : '/')
13
+ url + (rest.start_with?('/') ? rest[1..-1] : rest)
14
+ end
15
+
16
+ # Convert a camel cased string to a snake cased one
17
+ # snake_case("iconList") # => "icon_list"
18
+ # snake_case("eventSubURL") # => "event_sub_url"
19
+ # @param [String] str
20
+ # @return [String]
21
+ def snake_case(str)
22
+ g = str.gsub(/([^A-Z_])([A-Z])/,'\1_\2')
23
+ g.downcase || str.downcase
24
+ end
25
+
26
+ # Check if two URN are equivalent. They are equivalent if they have
27
+ # the same name and the same major version.
28
+ # @param [String] urn1
29
+ # @param [String] urn2
30
+ # @return [Boolean]
31
+ def urn_are_equivalent?(urn1, urn2)
32
+ u1 = urn1
33
+ if urn1[0..3] == 'urn:'
34
+ u1 = urn1[4..-1]
35
+ end
36
+ u2 = urn2
37
+ if urn2[0..3] == 'urn:'
38
+ u2 = urn2[4..-1]
39
+ end
40
+
41
+ m1 = u1.match(/(\w+):(\w+):(\w+):([\d-]+)/)
42
+ m2 = u2.match(/(\w+):(\w+):(\w+):([\d-]+)/)
43
+ if m1[1] == m2[1]
44
+ if m1[2] == m2[2]
45
+ if m1[3] == m2[3]
46
+ v1_major = m1[4].split(/-/).first
47
+ v2_major = m2[4].split(/-/).first
48
+ if v1_major == v2_major
49
+ return true
50
+ end
51
+ end
52
+ end
53
+ end
54
+ false
55
+ end
56
+
57
+ # Retrieve UDN from USN
58
+ # @param [String] usn
59
+ # @return [String]
60
+ def usn2udn(usn)
61
+ rex = '([A-Fa-f0-9]{8,8}(-[A-Fa-f0-9]{4,4}){3,3}-[A-Fa-f0-9]{12,12})'
62
+ usn.match(/#{rex}/)[1]
63
+ end
64
+ end
65
+
66
+ end
data/lib/rupnp.rb ADDED
@@ -0,0 +1,62 @@
1
+ require 'awesome_print'
2
+ require 'eventmachine-le'
3
+
4
+ # Module for RUPNP namespace
5
+ # @author Sylvain Daubert
6
+ module RUPNP
7
+
8
+ # RUPNP version
9
+ VERSION = '0.1.0'
10
+
11
+ @logdev = STDERR
12
+ @log_level = :info
13
+
14
+ # Set log device
15
+ # @param [IO,String] io_or_string io or filename to log to
16
+ # @return [IO]
17
+ def self.logdev=(io_or_string)
18
+ if io_or_string.is_a? String
19
+ @logdev = File.open(io_or_string, 'w')
20
+ else
21
+ @logdev = io_or_string
22
+ end
23
+ end
24
+
25
+ # Get log device
26
+ # @return [IO] io used to log
27
+ def self.logdev
28
+ @logdev
29
+ end
30
+
31
+ # Set log level
32
+ # @param [:debug,:info,:warn,:error] lvl
33
+ def self.log_level=(lvl)
34
+ @log_level = lvl
35
+ end
36
+
37
+ # Get log level
38
+ # @return [Symbol]
39
+ def self.log_level
40
+ @log_level
41
+ end
42
+
43
+
44
+ # Base class for RUPNP errors.
45
+ class Error < StandardError; end
46
+
47
+ end
48
+
49
+
50
+ require_relative 'rupnp/constants'
51
+ require_relative 'rupnp/tools'
52
+ require_relative 'rupnp/log_mixin'
53
+ require_relative 'rupnp/event'
54
+ require_relative 'rupnp/control_point'
55
+ require_relative 'rupnp/cp/base'
56
+ require_relative 'rupnp/cp/remote_service'
57
+ require_relative 'rupnp/cp/remote_device'
58
+ require_relative 'rupnp/cp/event_server'
59
+ require_relative 'rupnp/cp/event_subscriber'
60
+ require_relative 'rupnp/ssdp'
61
+
62
+ #require_relative 'rupnp/device'