rupnp 0.1.0

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