druzy-upnp 1.0.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,36 @@
1
+ require_relative 'multicast_connection'
2
+
3
+
4
+ class Druzy::Upnp::SSDP::Listener < Druzy::Upnp::SSDP::MulticastConnection
5
+
6
+ # @return [EventMachine::Channel] Provides subscribers with notifications
7
+ # from devices that have come online (sent +ssdp:alive+ notifications).
8
+ attr_reader :alive_notifications
9
+
10
+ # @return [EventMachine::Channel] Provides subscribers with notifications
11
+ # from devices that have gone offline (sent +ssd:byebye+ notifications).
12
+ attr_reader :byebye_notifications
13
+
14
+ # This is the callback called by EventMachine when it receives data on the
15
+ # socket that's been opened for this connection. In this case, the method
16
+ # parses the SSDP notifications into Hashes and adds them to the
17
+ # appropriate EventMachine::Channel (provided as accessor methods). This
18
+ # effectively means that in each Channel, you get a Hash that represents
19
+ # the headers for each notification that comes in on the socket.
20
+ #
21
+ # @param [String] response The data received on this connection's socket.
22
+ def receive_data(response)
23
+ ip, port = peer_info
24
+ parsed_response = parse(response)
25
+
26
+ return unless parsed_response.has_key? :nts
27
+
28
+ if parsed_response[:nts] == 'ssdp:alive'
29
+ @alive_notifications << parsed_response
30
+ elsif parsed_response[:nts] == 'ssdp:byebye'
31
+ @byebye_notifications << parsed_response
32
+ else
33
+ raise "Unknown NTS value: #{parsed_response[:nts]}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,109 @@
1
+ require 'core_ext/socket_patch'
2
+ require_relative 'network_constants'
3
+ require_relative 'error'
4
+ require 'ipaddr'
5
+ require 'socket'
6
+ require 'eventmachine'
7
+ require 'em-synchrony'
8
+
9
+ module Druzy
10
+ module Upnp
11
+ class SSDP
12
+ class MulticastConnection < EventMachine::Connection
13
+ include Druzy::Upnp::SSDP::NetworkConstants
14
+
15
+ # @param [Fixnum] ttl The TTL value to use when opening the UDP socket
16
+ # required for SSDP actions.
17
+ def initialize(ttl=TTL)
18
+ @ttl = ttl
19
+
20
+ @discovery_responses = EM::Channel.new
21
+ @alive_notifications = EM::Channel.new
22
+ @byebye_notifications = EM::Channel.new
23
+
24
+ setup_multicast_socket
25
+ end
26
+
27
+ # Gets the IP and port from the peer that just sent data.
28
+ #
29
+ # @return [Array<String,Fixnum>] The IP and port.
30
+ def peer_info
31
+ peer_bytes = get_peername[2, 6].unpack('nC4')
32
+ port = peer_bytes.first.to_i
33
+ ip = peer_bytes[1, 4].join('.')
34
+
35
+ [ip, port]
36
+ end
37
+
38
+ # Converts the headers to a set of key-value pairs.
39
+ #
40
+ # @param [String] data The data to convert.
41
+ # @return [Hash] The converted data. Returns an empty Hash if it didn't
42
+ # know how to parse.
43
+ def parse(data)
44
+ new_data = {}
45
+
46
+ unless data =~ /\n/
47
+ return new_data
48
+ end
49
+
50
+ data.each_line do |line|
51
+ line =~ /(\S+):(.*)/
52
+
53
+ unless $1.nil?
54
+ key = $1
55
+ value = $2
56
+ key = key.gsub('-', '_').downcase.to_sym
57
+ new_data[key] = value.strip
58
+ end
59
+ end
60
+
61
+ new_data
62
+ end
63
+
64
+ # Sets Socket options to allow for multicasting. If ENV["RUBY_UPNP_ENV"] is
65
+ # equal to "testing", then it doesn't turn off multicast looping.
66
+ def setup_multicast_socket
67
+ set_membership(IPAddr.new(MULTICAST_IP).hton +
68
+ IPAddr.new('0.0.0.0').hton)
69
+ set_multicast_ttl(@ttl)
70
+ set_ttl(@ttl)
71
+
72
+ unless ENV['RUBY_UPNP_ENV'] == 'testing'
73
+ switch_multicast_loop :off
74
+ end
75
+ end
76
+
77
+ # @param [String] membership The network byte ordered String that represents
78
+ # the IP(s) that should join the membership group.
79
+ def set_membership(membership)
80
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership)
81
+ end
82
+
83
+ # @param [Fixnum] ttl TTL to set IP_MULTICAST_TTL to.
84
+ def set_multicast_ttl(ttl)
85
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL,
86
+ [ttl].pack('i'))
87
+ end
88
+
89
+ # @param [Fixnum] ttl TTL to set IP_TTL to.
90
+ def set_ttl(ttl)
91
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_TTL, [ttl].pack('i'))
92
+ end
93
+
94
+ # @param [Symbol] on_off Turn on/off multicast looping. Supply :on or :off.
95
+ def switch_multicast_loop(on_off)
96
+ hex_value = case on_off
97
+ when :on then "\001"
98
+ when "\001" then "\001"
99
+ when :off then "\000"
100
+ when "\000" then "\000"
101
+ else raise SSDP::Error, "Can't switch IP_MULTICAST_LOOP to '#{on_off}'"
102
+ end
103
+
104
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_LOOP, hex_value)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,19 @@
1
+ module Druzy
2
+ module Upnp
3
+ class SSDP
4
+ module NetworkConstants
5
+
6
+ BROADCAST_IP = '255.255.255.255'
7
+
8
+ # Default multicast IP address
9
+ MULTICAST_IP = '239.255.255.250'
10
+
11
+ # Default multicast port
12
+ MULTICAST_PORT = 1900
13
+
14
+ # Default TTL
15
+ TTL = 4
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ require_relative 'multicast_connection'
2
+
3
+
4
+ class Druzy::Upnp::SSDP::Notifier < Druzy::Upnp::SSDP::MulticastConnection
5
+
6
+ def initialize(nt, usn, ddf_url, valid_for_duration)
7
+ @os = RbConfig::CONFIG['host_vendor'].capitalize + '/' +
8
+ RbConfig::CONFIG['host_os']
9
+ @upnp_version = '1.0'
10
+ @notification = notification(nt, usn, ddf_url, valid_for_duration)
11
+ end
12
+
13
+ def post_init
14
+ if send_datagram(@notification, MULTICAST_IP, MULTICAST_PORT) > 0
15
+
16
+ end
17
+ end
18
+
19
+ # @param [String] nt "Notification Type"; a potential search target. Used in
20
+ # +NT+ header.
21
+ # @param [String] usn "Unique Service Name"; a composite identifier for the
22
+ # advertisement. Used in +USN+ header.
23
+ # @param [String] ddf_url Device Description File URL for the root device.
24
+ # @param [Fixnum] valid_for_duration Duration in seconds for which the
25
+ # advertisement is valid. Used in +CACHE-CONTROL+ header.
26
+ def notification(nt, usn, ddf_url, valid_for_duration)
27
+ <<-NOTIFICATION
28
+ NOTIFY * HTTP/1.1\r
29
+ HOST: #{MULTICAST_IP}:#{MULTICAST_PORT}\r
30
+ CACHE-CONTROL: max-age=#{valid_for_duration}\r
31
+ LOCATION: #{ddf_url}\r
32
+ NT: #{nt}\r
33
+ NTS: ssdp:alive\r
34
+ SERVER: #{@os} UPnP/#{@upnp_version} Playful/#{Playful::VERSION}\r
35
+ USN: #{usn}\r
36
+ \r
37
+ NOTIFICATION
38
+ end
39
+ end
@@ -0,0 +1,85 @@
1
+ require_relative 'multicast_connection'
2
+
3
+ module Druzy
4
+ module Upnp
5
+
6
+ # A subclass of an EventMachine::Connection, this handles doing M-SEARCHes.
7
+ #
8
+ # Search types:
9
+ # ssdp:all
10
+ # upnp:rootdevice
11
+ # uuid:[device-uuid]
12
+ # urn:schemas-upnp-org:device:[deviceType-version]
13
+ # urn:schemas-upnp-org:service:[serviceType-version]
14
+ # urn:[custom-schema]:device:[deviceType-version]
15
+ # urn:[custom-schema]:service:[serviceType-version]
16
+ class SSDP::Searcher < SSDP::MulticastConnection
17
+
18
+ DEFAULT_RESPONSE_WAIT_TIME = 5
19
+ DEFAULT_M_SEARCH_COUNT = 2
20
+
21
+ # @return [EventMachine::Channel] Provides subscribers with responses from
22
+ # their search request.
23
+ attr_reader :discovery_responses
24
+
25
+ # @param [String] search_target
26
+ # @param [Hash] options
27
+ # @option options [Fixnum] response_wait_time
28
+ # @option options [Fixnum] ttl
29
+ # @option options [Fixnum] m_search_count The number of times to send the
30
+ # M-SEARCH. UPnP 1.0 suggests to send the request more than once.
31
+ def initialize(search_target, options = {})
32
+ options[:ttl] ||= TTL
33
+ options[:response_wait_time] ||= DEFAULT_RESPONSE_WAIT_TIME
34
+ @m_search_count = options[:m_search_count] ||= DEFAULT_M_SEARCH_COUNT
35
+
36
+ @search = m_search(search_target, options[:response_wait_time])
37
+
38
+ super options[:ttl]
39
+ end
40
+
41
+ # This is the callback called by EventMachine when it receives data on the
42
+ # socket that's been opened for this connection. In this case, the method
43
+ # parses the SSDP responses/notifications into Hashes and adds them to the
44
+ # appropriate EventMachine::Channel (provided as accessor methods). This
45
+ # effectively means that in each Channel, you get a Hash that represents
46
+ # the headers for each response/notification that comes in on the socket.
47
+ #
48
+ # @param [String] response The data received on this connection's socket.
49
+ def receive_data(response)
50
+ ip, port = peer_info
51
+ parsed_response = parse(response)
52
+
53
+ return if parsed_response.has_key? :nts
54
+ return if parsed_response[:man] &&
55
+ parsed_response[:man] =~ /ssdp:discover/
56
+
57
+ @discovery_responses << parsed_response
58
+ end
59
+
60
+ # Sends the M-SEARCH that was built during init. Logs what was sent if the
61
+ # send was successful.
62
+ def post_init
63
+ @m_search_count.times do
64
+ send_datagram(@search, MULTICAST_IP, MULTICAST_PORT)
65
+
66
+ end
67
+ end
68
+
69
+ # Builds the M-SEARCH request string.
70
+ #
71
+ # @param [String] search_target
72
+ # @param [Fixnum] response_wait_time
73
+ def m_search(search_target, response_wait_time)
74
+ <<-MSEARCH
75
+ M-SEARCH * HTTP/1.1\r
76
+ HOST: #{MULTICAST_IP}:#{MULTICAST_PORT}\r
77
+ MAN: "ssdp:discover"\r
78
+ MX: #{response_wait_time}\r
79
+ ST: #{search_target}\r
80
+ \r
81
+ MSEARCH
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,195 @@
1
+ require 'core_ext/socket_patch'
2
+ require 'eventmachine'
3
+ require 'em-synchrony'
4
+ require 'core_ext/to_upnp_s'
5
+ require_relative 'ssdp/error'
6
+ require_relative 'ssdp/network_constants'
7
+ require_relative 'ssdp/listener'
8
+ require_relative 'ssdp/searcher'
9
+ require_relative 'ssdp/notifier'
10
+
11
+ require_relative 'ssdp/broadcast_searcher'
12
+
13
+ module Druzy
14
+ module Upnp
15
+
16
+ # This is the main class for doing SSDP stuff. You can have a look at child
17
+ # classes, but you'll probably want to just use these methods here.
18
+ #
19
+ # SSDP is "Simple Service Discovery Protocol", which lets you find and learn
20
+ # about UPnP devices on your network. Of the six "steps" of UPnP (given in
21
+ # the UPnP spec--that's counting step 0), SSDP is what provides step 1, or the
22
+ # "discovery" step.
23
+ #
24
+ # Before you can do anything with any of the UPnP devices on your network, you
25
+ # need to +search+ your network to see what devices are available. Once you've
26
+ # found what's available, you can then decide device(s) you'd like to control
27
+ # (that's where Control Points come in; take a look at Playful::ControlPoint).
28
+ # After searching, you should then +listen+ to the activity on your network.
29
+ # New devices on your network may come online (via +ssdp:alive+) and devices
30
+ # that you care about may go offline (via +ssdp:byebye+), in which case you
31
+ # probably shouldn't try to talk to them anymore.
32
+ #
33
+ # @todo Add docs for Playful::Device perspective.
34
+ class SSDP
35
+ include NetworkConstants
36
+
37
+ # Opens a multicast UDP socket on 239.255.255.250:1900 and listens for
38
+ # alive and byebye notifications from devices.
39
+ #
40
+ # @param [Fixnum] ttl The TTL to use on the UDP socket.
41
+ #
42
+ # @return [Hash<Array>,Playful::SSDP::Listener] If the EventMachine reactor is
43
+ # _not_ running, it returns two key/value pairs--one for
44
+ # alive_notifications, one for byebye_notifications. If the reactor _is_
45
+ # running, it returns a Playful::SSDP::Listener so that that object can be
46
+ # used however desired. The latter method is used in Playful::ControlPoints
47
+ # so that an object of that type can keep track of devices it cares about.
48
+ def self.listen(ttl=TTL)
49
+ alive_notifications = Set.new
50
+ byebye_notifications = Set.new
51
+
52
+ listener = proc do
53
+ l = EM.open_datagram_socket(MULTICAST_IP, MULTICAST_PORT,
54
+ Playful::SSDP::Listener, ttl)
55
+ i = 0
56
+ EM.add_periodic_timer(5) { i += 5; Playful.log "Listening for #{i}\n" }
57
+ l
58
+ end
59
+
60
+ if EM.reactor_running?
61
+ return listener.call
62
+ else
63
+ EM.synchrony do
64
+ l = listener.call
65
+
66
+ alive_getter = Proc.new do |notification|
67
+ alive_notifications << notification
68
+ EM.next_tick { l.alive_notifications.pop(&live_getter) }
69
+ end
70
+ l.alive_notifications.pop(&alive_getter)
71
+
72
+ byebye_getter = Proc.new do |notification|
73
+ byebye_notifications << notification
74
+ EM.next_tick { l.byebye_notifications.pop(&byebye_getter) }
75
+ end
76
+ l.byebye_notifications.pop(&byebye_getter)
77
+
78
+ trap_signals
79
+ end
80
+ end
81
+
82
+ {
83
+ alive_notifications: alive_notifications.to_a.flatten,
84
+ byebye_notifications: byebye_notifications.to_a.flatten
85
+ }
86
+ end
87
+
88
+ # Opens a UDP socket on 0.0.0.0, on an ephemeral port, has Druzy::Upnp::SSDP::Searcher
89
+ # build and send the search request, then receives the responses. The search
90
+ # will stop after +response_wait_time+.
91
+ #
92
+ # @param [String] search_target
93
+ #
94
+ # @param [Hash] options
95
+ #
96
+ # @option options [Fixnum] response_wait_time
97
+ # @option options [Fixnum] ttl
98
+ # @option options [Fixnum] m_search_count
99
+ # @option options [Boolean] do_broadcast_search Tells the search call to also send
100
+ # a M-SEARCH over 255.255.255.255. This is *NOT* part of the UPnP spec;
101
+ # it's merely a hack for working with some types of devices that don't
102
+ # properly implement the UPnP spec.
103
+ #
104
+ # @return [Array<Hash>,Playful::SSDP::Searcher] Returns a Hash that represents
105
+ # the headers from the M-SEARCH response. Each one of these can be passed
106
+ # in to Playful::ControlPoint::Device.new to download the device's
107
+ # description file, parse it, and interact with the device's devices
108
+ # and/or services. If the reactor is already running this will return a
109
+ # a Playful::SSDP::Searcher which will make its accessors available so you
110
+ # can get responses in real time.
111
+ def self.search(search_target=:all, options = {})
112
+ response_wait_time = options[:response_wait_time] || 5
113
+ ttl = options[:ttl] || TTL
114
+ do_broadcast_search = options[:do_broadcast_search]
115
+
116
+ searcher_options = options
117
+ searcher_options.delete :do_broadcast_search
118
+
119
+ responses = []
120
+ search_target = search_target.to_upnp_s
121
+
122
+ multicast_searcher = proc do
123
+ EM.open_datagram_socket('0.0.0.0', 0, Druzy::Upnp::SSDP::Searcher,
124
+ search_target, searcher_options)
125
+ end
126
+
127
+ broadcast_searcher = proc do
128
+ EM.open_datagram_socket('0.0.0.0', 0, Druzy::Upnp::SSDP::BroadcastSearcher,
129
+ search_target, response_wait_time, ttl)
130
+ end
131
+
132
+ if EM.reactor_running?
133
+ return multicast_searcher.call
134
+ else
135
+ EM.synchrony do
136
+ ms = multicast_searcher.call
137
+
138
+ ms.discovery_responses.subscribe do |notification|
139
+ responses << notification
140
+ end
141
+
142
+ if do_broadcast_search
143
+ bs = broadcast_searcher.call
144
+
145
+ bs.discovery_responses.subscribe do |notification|
146
+ responses << notification
147
+ end
148
+ end
149
+
150
+ EM.add_timer(response_wait_time) { EM.stop }
151
+ trap_signals
152
+ end
153
+ end
154
+
155
+ responses.flatten
156
+ end
157
+
158
+ # @todo This is for Playful::Devices, which aren't implemented yet, and thus
159
+ # this may not be working.
160
+ def self.notify(notification_type, usn, ddf_url, valid_for_duration=1800)
161
+ responses = []
162
+ notification_type = notification_type.to_upnp_s
163
+
164
+ EM.synchrony do
165
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
166
+ EM.add_shutdown_hook { responses = s.discovery_responses }
167
+
168
+ EM.add_periodic_timer(valid_for_duration) do
169
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
170
+ end
171
+
172
+ trap_signals
173
+ end
174
+
175
+ responses
176
+ end
177
+
178
+ # @todo This is for Playful::Devices, which aren't implemented yet, and thus
179
+ # this may not be working.
180
+ def self.send_notification(notification_type, usn, ddf_url, valid_for_duration)
181
+ EM.open_datagram_socket('0.0.0.0', 0, Druzy::Upnp::SSDP::Notifier, notification_type,
182
+ usn, ddf_url, valid_for_duration)
183
+ end
184
+
185
+ private
186
+
187
+ # Traps INT, TERM, and HUP signals and stops the reactor.
188
+ def self.trap_signals
189
+ trap('INT') { EM.stop }
190
+ trap('TERM') { EM.stop }
191
+ trap('HUP') { EM.stop } if RUBY_PLATFORM !~ /mswin|mingw/
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,5 @@
1
+ module Druzy
2
+ module Upnp
3
+ VERSION = '1.0.0'
4
+ end
5
+ end
data/lib/druzy/upnp.rb ADDED
@@ -0,0 +1,7 @@
1
+ require_relative 'upnp/version'
2
+
3
+ module Druzy
4
+ module Upnp
5
+
6
+ end
7
+ end
@@ -0,0 +1,70 @@
1
+ require 'rack'
2
+ require 'druzy/upnp/control_point'
3
+
4
+ module Rack
5
+
6
+ # Middleware that allows your Rack app to keep tabs on devices that the
7
+ # {Playful::ControlPoint} has found. UPnP devices that match the +search_type+
8
+ # are discovered and added to the list, then removed as those devices send out
9
+ # +ssdp:byebye+ notifications. All of this depends on +EventMachine::Channel+s,
10
+ # and thus requires that an EventMachine reactor is running. If you don't
11
+ # have one running, the {Playful::ControlPoint} will start one for you.
12
+ #
13
+ # @example Control all root devices
14
+ #
15
+ # Thin::Server.start('0.0.0.0', 3000) do
16
+ # use Rack::UPnPControlPoint, search_type: :root
17
+ #
18
+ # map "/devices" do
19
+ # run lambda { |env|
20
+ # devices = env['upnp.devices']
21
+ # friendly_names = devices.map(&:friendly_name).join("\n")
22
+ # [200, {'Content-Type' => 'text/plain'}, [friendly_names]]
23
+ # }
24
+ # end
25
+ # end
26
+ #
27
+ class UPnPControlPoint
28
+
29
+ # @param [Rack::Builder] app Your Rack application.
30
+ # @param [Hash] options Options to pass to the Playful::SSDP::Searcher.
31
+ # @see Playful::SSDP::Searcher
32
+ def initialize(app, options = {})
33
+ @app = app
34
+ @devices = []
35
+ options[:search_type] ||= :root
36
+ EM.next_tick { start_control_point(options[:search_type], options) }
37
+ end
38
+
39
+ # Creates and starts the {Playful::ControlPoint}, then manages the list of
40
+ # devices using the +EventMachine::Channel+ objects yielded in.
41
+ #
42
+ # @param [Symbol,String] search_type The device(s) you want to search for
43
+ # and control.
44
+ # @param [Hash] options Options to pass to the Playful::SSDP::Searcher.
45
+ # @see Playful::SSDP::Searcher
46
+ def start_control_point(search_type, options)
47
+ @cp = ::Playful::ControlPoint.new(search_type, options)
48
+
49
+ @cp.start do |new_device_channel, old_device_channel|
50
+ new_device_channel.subscribe do |notification|
51
+ @devices << notification
52
+ end
53
+
54
+ old_device_channel.subscribe do |old_device|
55
+ @devices.reject! { |d| d.usn == old_device[:usn] }
56
+ end
57
+ end
58
+
59
+ end
60
+
61
+ # Adds the whole list of devices to <tt>env['upnp.devices']</tt> so that
62
+ # that list can be accessed from within your app.
63
+ #
64
+ # @param [Hash] env The Rack environment.
65
+ def call(env)
66
+ env['upnp.devices'] = @devices
67
+ @app.call(env)
68
+ end
69
+ end
70
+ end