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