frisky 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/frisky.rb ADDED
@@ -0,0 +1,5 @@
1
+ require_relative 'frisky/version'
2
+
3
+ module Frisky
4
+
5
+ end
@@ -0,0 +1,8 @@
1
+ require 'log_switch'
2
+
3
+
4
+ module Frisky
5
+ include LogSwitch
6
+ end
7
+
8
+ Frisky.log_class_name = true
@@ -0,0 +1,188 @@
1
+ require_relative '../core_ext/socket_patch'
2
+ require 'eventmachine'
3
+ require 'em-synchrony'
4
+ require_relative '../core_ext/to_upnp_s'
5
+ require_relative 'logger'
6
+ require_relative 'ssdp/error'
7
+ require_relative 'ssdp/network_constants'
8
+ require_relative 'ssdp/listener'
9
+ require_relative 'ssdp/searcher'
10
+ require_relative 'ssdp/notifier'
11
+
12
+ require_relative 'ssdp/broadcast_searcher'
13
+
14
+ module Frisky
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
+ # After searching, you should then +listen+ to the activity on your network.
28
+ # New devices on your network may come online (via +ssdp:alive+) and devices
29
+ # that you care about may go offline (via +ssdp:byebye+), in which case you
30
+ # probably shouldn't try to talk to them anymore.
31
+ class SSDP
32
+ include LogSwitch
33
+ include NetworkConstants
34
+
35
+ # Opens a multicast UDP socket on 239.255.255.250:1900 and listens for
36
+ # alive and byebye notifications from devices.
37
+ #
38
+ # @param [Fixnum] ttl The TTL to use on the UDP socket.
39
+ #
40
+ # @return [Hash<Array>,Frisky::SSDP::Listener] If the EventMachine reactor is
41
+ # _not_ running, it returns two key/value pairs--one for
42
+ # alive_notifications, one for byebye_notifications. If the reactor _is_
43
+ # running, it returns a Frisky::SSDP::Listener so that that object can be
44
+ # used however desired.
45
+ def self.listen(ttl=TTL)
46
+ alive_notifications = Set.new
47
+ byebye_notifications = Set.new
48
+
49
+ listener = proc do
50
+ l = EM.open_datagram_socket(MULTICAST_IP, MULTICAST_PORT,
51
+ Frisky::SSDP::Listener, ttl)
52
+ i = 0
53
+ EM.add_periodic_timer(5) { i += 5; Frisky.log "Listening for #{i}\n" }
54
+ l
55
+ end
56
+
57
+ if EM.reactor_running?
58
+ return listener.call
59
+ else
60
+ EM.synchrony do
61
+ l = listener.call
62
+
63
+ alive_getter = Proc.new do |notification|
64
+ alive_notifications << notification
65
+ EM.next_tick { l.alive_notifications.pop(&live_getter) }
66
+ end
67
+ l.alive_notifications.pop(&alive_getter)
68
+
69
+ byebye_getter = Proc.new do |notification|
70
+ byebye_notifications << notification
71
+ EM.next_tick { l.byebye_notifications.pop(&byebye_getter) }
72
+ end
73
+ l.byebye_notifications.pop(&byebye_getter)
74
+
75
+ trap_signals
76
+ end
77
+ end
78
+
79
+ {
80
+ alive_notifications: alive_notifications.to_a.flatten,
81
+ byebye_notifications: byebye_notifications.to_a.flatten
82
+ }
83
+ end
84
+
85
+ # Opens a UDP socket on 0.0.0.0, on an ephemeral port, has Frisky::SSDP::Searcher
86
+ # build and send the search request, then receives the responses. The search
87
+ # will stop after +response_wait_time+.
88
+ #
89
+ # @param [String] search_target
90
+ #
91
+ # @param [Hash] options
92
+ #
93
+ # @option options [Fixnum] response_wait_time
94
+ # @option options [Fixnum] ttl
95
+ # @option options [Fixnum] m_search_count
96
+ # @option options [Boolean] do_broadcast_search Tells the search call to also send
97
+ # a M-SEARCH over 255.255.255.255. This is *NOT* part of the UPnP spec;
98
+ # it's merely a hack for working with some types of devices that don't
99
+ # properly implement the UPnP spec.
100
+ #
101
+ # @return [Array<Hash>,Frisky::SSDP::Searcher] Returns a Hash that represents
102
+ # the headers from the M-SEARCH response. If the reactor is already running
103
+ # this will return a Frisky::SSDP::Searcher which will make its accessors
104
+ # available so you can get responses in real time.
105
+ def self.search(search_target=:all, options = {})
106
+ response_wait_time = options[:response_wait_time] || 5
107
+ ttl = options[:ttl] || TTL
108
+ do_broadcast_search = options[:do_broadcast_search]
109
+
110
+ searcher_options = options
111
+ searcher_options.delete :do_broadcast_search
112
+
113
+ responses = []
114
+ search_target = search_target.to_upnp_s
115
+
116
+ multicast_searcher = proc do
117
+ EM.open_datagram_socket('0.0.0.0', 0, Frisky::SSDP::Searcher,
118
+ search_target, searcher_options)
119
+ end
120
+
121
+ broadcast_searcher = proc do
122
+ EM.open_datagram_socket('0.0.0.0', 0, Frisky::SSDP::BroadcastSearcher,
123
+ search_target, response_wait_time, ttl)
124
+ end
125
+
126
+ if EM.reactor_running?
127
+ return multicast_searcher.call
128
+ else
129
+ EM.synchrony do
130
+ ms = multicast_searcher.call
131
+
132
+ ms.discovery_responses.subscribe do |notification|
133
+ responses << notification
134
+ end
135
+
136
+ if do_broadcast_search
137
+ bs = broadcast_searcher.call
138
+
139
+ bs.discovery_responses.subscribe do |notification|
140
+ responses << notification
141
+ end
142
+ end
143
+
144
+ EM.add_timer(response_wait_time) { EM.stop }
145
+ trap_signals
146
+ end
147
+ end
148
+
149
+ responses.flatten
150
+ end
151
+
152
+ # @todo This is for Frisky::Devices, which aren't implemented yet, and thus
153
+ # this may not be working.
154
+ def self.notify(notification_type, usn, ddf_url, valid_for_duration=1800)
155
+ responses = []
156
+ notification_type = notification_type.to_upnp_s
157
+
158
+ EM.synchrony do
159
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
160
+ EM.add_shutdown_hook { responses = s.discovery_responses }
161
+
162
+ EM.add_periodic_timer(valid_for_duration) do
163
+ s = send_notification(notification_type, usn, ddf_url, valid_for_duration)
164
+ end
165
+
166
+ trap_signals
167
+ end
168
+
169
+ responses
170
+ end
171
+
172
+ # @todo This is for Frisky::Devices, which aren't implemented yet, and thus
173
+ # this may not be working.
174
+ def self.send_notification(notification_type, usn, ddf_url, valid_for_duration)
175
+ EM.open_datagram_socket('0.0.0.0', 0, Frisky::SSDP::Notifier, notification_type,
176
+ usn, ddf_url, valid_for_duration)
177
+ end
178
+
179
+ private
180
+
181
+ # Traps INT, TERM, and HUP signals and stops the reactor.
182
+ def self.trap_signals
183
+ trap('INT') { EM.stop }
184
+ trap('TERM') { EM.stop }
185
+ trap('HUP') { EM.stop } if RUBY_PLATFORM !~ /mswin|mingw/
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,114 @@
1
+ require_relative '../../core_ext/socket_patch'
2
+ require_relative '../logger'
3
+ require_relative 'network_constants'
4
+ require 'ipaddr'
5
+ require 'socket'
6
+ require 'eventmachine'
7
+
8
+
9
+ # TODO: DRY this up!! (it's mostly the same as Frisky::SSDP::MulticastConnection)
10
+ module Frisky
11
+ class SSDP
12
+ class BroadcastSearcher < EventMachine::Connection
13
+ include LogSwitch
14
+ include EventMachine::Deferrable
15
+ include Frisky::SSDP::NetworkConstants
16
+
17
+ # @return [Array] The list of responses from the current discovery request.
18
+ attr_reader :discovery_responses
19
+
20
+ attr_reader :available_responses
21
+ attr_reader :byebye_responses
22
+
23
+ def initialize(search_target, response_wait_time, ttl=TTL)
24
+ @ttl = ttl
25
+ @discovery_responses = []
26
+ @alive_notifications = []
27
+ @byebye_notifications = []
28
+
29
+ setup_broadcast_socket
30
+
31
+ @search = m_search(search_target, response_wait_time)
32
+ end
33
+
34
+ def post_init
35
+ if send_datagram(@search, BROADCAST_IP, MULTICAST_PORT) > 0
36
+ log "Sent broadcast datagram search:\n#{@search}"
37
+ end
38
+ end
39
+
40
+ def m_search(search_target, response_wait_time)
41
+ <<-MSEARCH
42
+ M-SEARCH * HTTP/1.1\r
43
+ HOST: #{MULTICAST_IP}:#{MULTICAST_PORT}\r
44
+ MAN: "ssdp:discover"\r
45
+ MX: #{response_wait_time}\r
46
+ ST: #{search_target}\r
47
+ \r
48
+ MSEARCH
49
+ end
50
+
51
+ # Gets the IP and port from the peer that just sent data.
52
+ #
53
+ # @return [Array<String,Fixnum>] The IP and port.
54
+ def peer_info
55
+ peer_bytes = get_peername[2, 6].unpack('nC4')
56
+ port = peer_bytes.first.to_i
57
+ ip = peer_bytes[1, 4].join('.')
58
+
59
+ [ip, port]
60
+ end
61
+
62
+ def receive_data(response)
63
+ ip, port = peer_info
64
+ log "Response from #{ip}:#{port}:\n#{response}\n"
65
+ parsed_response = parse(response)
66
+
67
+ if parsed_response.has_key? :nts
68
+ if parsed_response[:nts] == 'ssdp:alive'
69
+ @alive_notifications << parsed_response
70
+ elsif parsed_response[:nts] == 'ssdp:bye-bye'
71
+ @byebye_notifications << parsed_response
72
+ else
73
+ raise "Unknown NTS value: #{parsed_response[:nts]}"
74
+ end
75
+ else
76
+ @discovery_responses << parsed_response
77
+ end
78
+ end
79
+
80
+ # Converts the headers to a set of key-value pairs.
81
+ #
82
+ # @param [String] data The data to convert.
83
+ # @return [Hash] The converted data. Returns an empty Hash if it didn't
84
+ # know how to parse.
85
+ def parse(data)
86
+ new_data = {}
87
+
88
+ unless data =~ /\n/
89
+ log 'Received response as a single-line String. Discarding.'
90
+ log "Bad response looked like:\n#{data}"
91
+ return new_data
92
+ end
93
+
94
+ data.each_line do |line|
95
+ line =~ /(\S*):(.*)/
96
+
97
+ unless $1.nil?
98
+ key = $1
99
+ value = $2
100
+ key = key.gsub('-', '_').downcase.to_sym
101
+ new_data[key] = value.strip
102
+ end
103
+ end
104
+
105
+ new_data
106
+ end
107
+
108
+ # Sets Socket options to allow for brodcasting.
109
+ def setup_broadcast_socket
110
+ set_sock_opt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,6 @@
1
+ module Frisky
2
+ class SSDP
3
+ class Error < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ require_relative 'multicast_connection'
2
+
3
+
4
+ class Frisky::SSDP::Listener < Frisky::SSDP::MulticastConnection
5
+ include LogSwitch
6
+
7
+ # @return [EventMachine::Channel] Provides subscribers with notifications
8
+ # from devices that have come online (sent +ssdp:alive+ notifications).
9
+ attr_reader :alive_notifications
10
+
11
+ # @return [EventMachine::Channel] Provides subscribers with notifications
12
+ # from devices that have gone offline (sent +ssd:byebye+ notifications).
13
+ attr_reader :byebye_notifications
14
+
15
+ # This is the callback called by EventMachine when it receives data on the
16
+ # socket that's been opened for this connection. In this case, the method
17
+ # parses the SSDP notifications into Hashes and adds them to the
18
+ # appropriate EventMachine::Channel (provided as accessor methods). This
19
+ # effectively means that in each Channel, you get a Hash that represents
20
+ # the headers for each notification that comes in on the socket.
21
+ #
22
+ # @param [String] response The data received on this connection's socket.
23
+ def receive_data(response)
24
+ ip, port = peer_info
25
+ log "Response from #{ip}:#{port}:\n#{response}\n"
26
+ parsed_response = parse(response)
27
+
28
+ return unless parsed_response.has_key? :nts
29
+
30
+ if parsed_response[:nts] == 'ssdp:alive'
31
+ @alive_notifications << parsed_response
32
+ elsif parsed_response[:nts] == 'ssdp:byebye'
33
+ @byebye_notifications << parsed_response
34
+ else
35
+ raise "Unknown NTS value: #{parsed_response[:nts]}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,112 @@
1
+ require_relative '../../core_ext/socket_patch'
2
+ require_relative 'network_constants'
3
+ require_relative '../logger'
4
+ require_relative 'error'
5
+ require 'ipaddr'
6
+ require 'socket'
7
+ require 'eventmachine'
8
+ require 'em-synchrony'
9
+
10
+
11
+ module Frisky
12
+ class SSDP
13
+ class MulticastConnection < EventMachine::Connection
14
+ include Frisky::SSDP::NetworkConstants
15
+ include LogSwitch
16
+
17
+ # @param [Fixnum] ttl The TTL value to use when opening the UDP socket
18
+ # required for SSDP actions.
19
+ def initialize(ttl=TTL)
20
+ @ttl = ttl
21
+
22
+ @discovery_responses = EM::Channel.new
23
+ @alive_notifications = EM::Channel.new
24
+ @byebye_notifications = EM::Channel.new
25
+
26
+ setup_multicast_socket
27
+ end
28
+
29
+ # Gets the IP and port from the peer that just sent data.
30
+ #
31
+ # @return [Array<String,Fixnum>] The IP and port.
32
+ def peer_info
33
+ peer_bytes = get_peername[2, 6].unpack('nC4')
34
+ port = peer_bytes.first.to_i
35
+ ip = peer_bytes[1, 4].join('.')
36
+
37
+ [ip, port]
38
+ end
39
+
40
+ # Converts the headers to a set of key-value pairs.
41
+ #
42
+ # @param [String] data The data to convert.
43
+ # @return [Hash] The converted data. Returns an empty Hash if it didn't
44
+ # know how to parse.
45
+ def parse(data)
46
+ new_data = {}
47
+
48
+ unless data =~ /\n/
49
+ log 'Received response as a single-line String. Discarding.'
50
+ log "Bad response looked like:\n#{data}"
51
+ return new_data
52
+ end
53
+
54
+ data.each_line do |line|
55
+ line =~ /(\S+):(.*)/
56
+
57
+ unless $1.nil?
58
+ key = $1
59
+ value = $2
60
+ key = key.gsub('-', '_').downcase.to_sym
61
+ new_data[key] = value.strip
62
+ end
63
+ end
64
+
65
+ new_data
66
+ end
67
+
68
+ # Sets Socket options to allow for multicasting. If ENV["RUBY_UPNP_ENV"] is
69
+ # equal to "testing", then it doesn't turn off multicast looping.
70
+ def setup_multicast_socket
71
+ set_membership(IPAddr.new(MULTICAST_IP).hton +
72
+ IPAddr.new('0.0.0.0').hton)
73
+ set_multicast_ttl(@ttl)
74
+ set_ttl(@ttl)
75
+
76
+ unless ENV['RUBY_UPNP_ENV'] == 'testing'
77
+ switch_multicast_loop :off
78
+ end
79
+ end
80
+
81
+ # @param [String] membership The network byte ordered String that represents
82
+ # the IP(s) that should join the membership group.
83
+ def set_membership(membership)
84
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_ADD_MEMBERSHIP, membership)
85
+ end
86
+
87
+ # @param [Fixnum] ttl TTL to set IP_MULTICAST_TTL to.
88
+ def set_multicast_ttl(ttl)
89
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL,
90
+ [ttl].pack('i'))
91
+ end
92
+
93
+ # @param [Fixnum] ttl TTL to set IP_TTL to.
94
+ def set_ttl(ttl)
95
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_TTL, [ttl].pack('i'))
96
+ end
97
+
98
+ # @param [Symbol] on_off Turn on/off multicast looping. Supply :on or :off.
99
+ def switch_multicast_loop(on_off)
100
+ hex_value = case on_off
101
+ when :on then "\001"
102
+ when "\001" then "\001"
103
+ when :off then "\000"
104
+ when "\000" then "\000"
105
+ else raise SSDP::Error, "Can't switch IP_MULTICAST_LOOP to '#{on_off}'"
106
+ end
107
+
108
+ set_sock_opt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_LOOP, hex_value)
109
+ end
110
+ end
111
+ end
112
+ end