xap_ruby 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,76 @@
1
+ # Base class for object model of an xAP device.
2
+ # (C)2012 Mike Bourgeous
3
+
4
+ module Xap
5
+ # Classes that want to receive filtered xAP events from the main event loop
6
+ # should inherit from this class and override receive_message.
7
+ class XapDevice
8
+ attr_accessor :address, :uid, :interval
9
+
10
+ # Initializes this superclass with the given address and base UID,
11
+ # which will be used as the source address and UID of messages
12
+ # generated by this device, and the given heartbeat interval.
13
+ #
14
+ # address - a non-wildcarded XapAddress that doesn't contain ':'.
15
+ # uid - eight hexadecimal digits, the first two of which must be FF,
16
+ # the last two 00, and the middle two neither FF nor 00.
17
+ # interval - the number of seconds between xAP heartbeats sent by this
18
+ # device model when added to an XapHandler event loop.
19
+ def initialize address, uid, interval
20
+ raise 'Heartbeat interval must be a Fixnum or nil' if interval && !interval.is_a?(Fixnum)
21
+ set_address address
22
+ self.uid = uid
23
+ @interval = interval
24
+ end
25
+
26
+ # Sets the XapHandler that manages messages to and from this device.
27
+ # This should typically be called by XapHandler itself. Subclasses may
28
+ # override this method in order to send messages that are supposed to
29
+ # be sent on initialization, so long as they call this base
30
+ # implementation first. XapHandler will call this method with nil if
31
+ # the device is removed from the handler or the xAP socket is closed.
32
+ def handler= handler
33
+ raise 'handler must be a XapHandler' unless handler.nil? || handler.is_a?(XapHandler)
34
+ @handler = handler
35
+ end
36
+
37
+ # Called whenever a matching message is received by the associated
38
+ # handler.
39
+ def receive_message msg
40
+ puts "XXX: You forgot to override receive_message in #{self}: #{msg.inspect.lines.to_a.join("\t")}"
41
+ end
42
+
43
+ # Returns a string description of this device.
44
+ def to_s
45
+ "<#{self.class.name}: #{@address} #{@uid}>"
46
+ end
47
+
48
+ protected
49
+ # Uses the associated XapHandler to send the given message. Does
50
+ # nothing if there is no handler set.
51
+ def send_message message
52
+ @handler.send_message message if @handler
53
+ end
54
+
55
+ # TODO: Ability to request incoming messages matching a particular
56
+ # source address, using add_receiver/remove_receiver
57
+
58
+ # Changes the address used by this device. Subclasses should call this
59
+ # if, for example, the user-assigned name of the device is changed.
60
+ def set_address address
61
+ if !address.is_a?(XapAddress) || address.wildcard? || address.endpoint
62
+ raise 'address must be a non-wildcarded XapAddress without ":"'
63
+ end
64
+ @address = address
65
+ end
66
+
67
+ # Changes the UID used by this device. Subclasses should call this if
68
+ # the four-digit reassignable component of the UID is changed.
69
+ def uid= uid
70
+ unless uid =~ /^FF[0-9A-Z]{4}00$/i && !(uid.slice(2, 4) =~ /(?:^(?:00|FF)|(?:00|FF)$)/i)
71
+ raise "uid must be eight hex digits of the form FF(01..FE)(01..FE)00, not #{uid}."
72
+ end
73
+ @uid = uid
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,197 @@
1
+ # EventMachine packet transmission and receipt handler for the xAP protocol.
2
+ # (C)2012 Mike Bourgeous
3
+
4
+ module Xap
5
+ XAP_PORT=3639
6
+ BCAST_ADDR='255.255.255.255'
7
+
8
+ class XapHandler < EM::Connection
9
+ @@instance = nil
10
+
11
+ def self.instance
12
+ @@instance
13
+ end
14
+
15
+ def initialize servername
16
+ @@instance = self
17
+ @servername = servername
18
+ @devices = []
19
+ @receivers = []
20
+ @timers = {}
21
+ end
22
+
23
+ def unbind
24
+ @@instance = nil if @@instance == self
25
+
26
+ @devices.each do |d|
27
+ d.handler = nil
28
+ end
29
+ end
30
+
31
+ def receive_data d
32
+ handled = false
33
+ begin
34
+ msg = XapMessage.parse(d)
35
+ rescue Exception => e
36
+ Xap.log "Error parsing incoming message: #{e}\n\t#{e.backtrace.join("\n\t")}"
37
+ Xap.log "receive_data(#{d.length}) invalid: #{d.inspect}"
38
+ return
39
+ end
40
+
41
+ if msg.target_addr
42
+ @devices.each do |d|
43
+ begin
44
+ if msg.target_addr.base =~ d.address
45
+ d.receive_message msg
46
+ handled = true
47
+ end
48
+ rescue RuntimeError => e
49
+ Xap.log "Error processing message with device #{d}: #{e}\n\t#{e.backtrace.join("\n\t")}"
50
+ end
51
+ end
52
+ end
53
+
54
+ @receivers.each do |rcv|
55
+ if rcv[0] =~ msg.src_addr
56
+ rcv[1].call msg
57
+ end
58
+ end
59
+
60
+ if !handled && $DEBUG
61
+ Xap.log "Received a #{msg.class.name} message (#{msg.src_addr.inspect} => #{msg.target_addr.inspect})"
62
+ end
63
+ end
64
+
65
+ # Adds a device object to the list of devices. The device will be
66
+ # notified about incoming messages with a target matching the device's
67
+ # address. If the device's heartbeat interval is non-nil and greater
68
+ # than 0 then a periodic heartbeat will automatically be transmitted
69
+ # for the device,
70
+ def add_device device
71
+ raise 'device must be an XapDevice' unless device.is_a? XapDevice
72
+ raise 'device is already in this XapHandler' if @devices.include? device
73
+
74
+ @devices << device
75
+ if device.interval && device.interval > 0
76
+ @timers[device] = EM.add_periodic_timer(device.interval) {
77
+ send_heartbeat device.address, device.uid, device.interval
78
+ }
79
+ end
80
+
81
+ device.handler = self
82
+ end
83
+
84
+ # Removes the given device from message notifications. Cancels its
85
+ # heartbeat timer if it has one.
86
+ def remove_device device
87
+ @devices.delete device
88
+ timer = @timers.delete device
89
+ timer.cancel if timer
90
+ device.handler = nil
91
+ end
92
+
93
+ # Adds a callback to be called with the XapMessage when a message is
94
+ # received from the given source address (src_addr may be wildcarded).
95
+ def add_receiver src_addr, callback
96
+ raise 'src_addr must be an XapAddress' unless src_addr.is_a? XapAddress
97
+ raise 'callback must be callable' unless callback.respond_to? :call
98
+ @receivers << [src_addr, callback]
99
+ self
100
+ end
101
+
102
+ # Removes an address/callback pair from the list of callbacks called
103
+ # when a matching message is received.
104
+ def remove_receiver src_addr, callback
105
+ @receivers.delete_if { |rcv|
106
+ rcv[0] == src_addr && rcv[1] == callback
107
+ }
108
+ nil
109
+ end
110
+
111
+ # Sends an XapMessage to the network.
112
+ def send_message message
113
+ raise 'message must be an XapMessage' unless message.is_a? XapMessage
114
+ send_datagram(message.to_s, BCAST_ADDR, XAP_PORT)
115
+ end
116
+
117
+ # Broadcasts an xAP heartbeat from the given address and UID.
118
+ #
119
+ # src_addr and src_uid should be convertible to the exact strings that
120
+ # should go into the packet. interval is how often other devices
121
+ # should expect the heartbeat, in seconds.
122
+ #
123
+ # http://www.xapautomation.org/index.php?title=Protocol_definition#Device_Monitoring_-_Heartbeats
124
+ #
125
+ # The resulting packet will look like this:
126
+ # xap-hbeat
127
+ # {
128
+ # v=12
129
+ # hop=1
130
+ # uid=[src_uid]
131
+ # class=xap-hbeat.alive
132
+ # source=[src_addr]
133
+ # interval=[interval]
134
+ # }
135
+ def send_heartbeat src_addr, src_uid, interval = 60
136
+ msg = "xap-hbeat\n" +
137
+ "{\n" +
138
+ "v=12\n" +
139
+ "hop=1\n" +
140
+ "uid=#{src_uid}\n" +
141
+ "class=xap-hbeat.alive\n" +
142
+ "source=#{src_addr}\n" +
143
+ "interval=#{interval}\n" +
144
+ "}\n"
145
+
146
+ send_datagram(msg, BCAST_ADDR, XAP_PORT)
147
+ end
148
+ end
149
+
150
+ @@connection = nil
151
+
152
+ # Opens a UDP socket for sending and receiving xAP messages. The
153
+ # EventMachine event loop must be running.
154
+ def self.start_xap
155
+ # EventMachine doesn't seem to support using '::' for IP address
156
+ @@connection ||= EM.open_datagram_socket '0.0.0.0', XAP_PORT, XapHandler, "xAP Server" unless @@connection
157
+ @@connection
158
+
159
+ # TODO: xAP hub support
160
+ end
161
+
162
+ # Closes the xAP server UDP socket, if one exists.
163
+ def self.stop_xap
164
+ @@connection.close_connection_after_writing if @@connection
165
+ @@connection = nil
166
+ end
167
+
168
+ # Returns true if the xAP handler is connected to its UDP socket, false
169
+ # otherwise.
170
+ def self.xap_running?
171
+ !!XapHandler.instance
172
+ end
173
+
174
+ # Adds the given XapDevice to the current xAP socket server.
175
+ def self.add_device device
176
+ raise 'The xAP server is not running. Call start_xap first.' unless @@connection
177
+ XapHandler.instance.add_device device
178
+ end
179
+
180
+ # Removes the given XapDevice from the current xAP socket server.
181
+ def self.remove_device device
182
+ raise 'The xAP server is not running. Call start_xap first.' unless @@connection
183
+ XapHandler.instance.remove_device device
184
+ end
185
+
186
+ # Adds a message receiver to the current xAP socket server.
187
+ def self.add_receiver src_addr, callback
188
+ raise 'The xAP server is not running. Call start_xap first.' unless @@connection
189
+ XapHandler.instance.add_receiver src_addr, callback
190
+ end
191
+
192
+ # Removes a message receiver from the current xAP socket server.
193
+ def self.remove_receiver src_addr, callback
194
+ raise 'The xAP server is not running. Call start_xap first.' unless @@connection
195
+ XapHandler.instance.remove_receiver src_addr, callback
196
+ end
197
+ end
@@ -0,0 +1,194 @@
1
+ # xAP message base class definitions
2
+ # (C)2012 Mike Bourgeous
3
+
4
+ module Xap
5
+ # Base class for all xAP message types. Registered subclasses must implement
6
+ # a parse method that accepts the message hash as its first parameter.
7
+ class XapMessage
8
+ @@msgtypes = {}
9
+
10
+ attr_accessor :src_addr, :target_addr, :version, :hop, :uid, :msgclass, :headername
11
+
12
+ # Parses the given data as an xAP message. If the message type does
13
+ # not have a registered handler, an exception will be raised
14
+ def self.parse data
15
+ raise 'data must be (convertible to) a String' unless data.respond_to? :to_s
16
+
17
+ msghash = Xap::Parser::ParseXap.simple_parse data.to_s
18
+
19
+ headername = msghash.keys[0].downcase
20
+ raise "No handlers defined for #{headername} message headers." unless @@msgtypes[headername]
21
+
22
+ classname = msghash[headername]['class']
23
+ raise 'Message lacks a class field in its header.' unless classname || @@msgtypes[headername][nil]
24
+ classname.downcase!
25
+
26
+ handler = @@msgtypes[headername][classname] || @@msgtypes[headername][nil]
27
+ raise "No handler defined for #{headername}/#{classname} messages." unless handler
28
+
29
+ handler.parse msghash
30
+ end
31
+
32
+ # Registers the given klass as the handler for msgclass messages, with
33
+ # the header block called headername ('xap-header' by default).
34
+ #
35
+ # Specify nil for msgclass to register a fallback handler for messages
36
+ # with the given header name that don't have a specific class handler
37
+ # registered.
38
+ def self.register_class klass, msgclass, headername='xap-header'
39
+ unless klass.is_a?(Class) && klass < XapMessage
40
+ raise "klass must be a Class that inherits XapMessage, not #{klass.inspect}"
41
+ end
42
+ raise 'msgclass must be nil or a String' unless msgclass.nil? || msgclass.is_a?(String)
43
+
44
+ Xap.log "Registered support for #{headername}/#{msgclass} messages via #{klass.name}"
45
+
46
+ # TODO: Support regex for msgclass?
47
+ headername.downcase!
48
+ msgclass.downcase! if msgclass.is_a? String
49
+ @@msgtypes[headername] = @@msgtypes[headername] || {}
50
+ @@msgtypes[headername][msgclass] = klass
51
+ end
52
+
53
+ # msgclass - the message's class
54
+ # src_addr - the message's source address
55
+ # src_uid - the message's source UID (TODO: merge with XapAddress?)
56
+ # target_addr - the message's target address, or nil for no target
57
+ def initialize msgclass, src_addr, src_uid, target_addr = nil
58
+ raise 'Do not instantiate XapMessage directly (use a subclass)' if self.class == XapMessage
59
+ raise 'src_addr must be an XapAddress' unless src_addr.is_a? XapAddress
60
+ raise 'target_addr must be nil or an XapAddress' unless target_addr.nil? || target_addr.is_a?(XapAddress)
61
+
62
+ @src_addr = src_addr
63
+ @target_addr = target_addr
64
+ @version = 12
65
+ @hop = 1
66
+ @uid = src_uid
67
+ @msgclass = msgclass
68
+ @blocks = {}
69
+ @headers = {}
70
+ end
71
+
72
+ # Returns a string representation of the message suitable for
73
+ # transmission on the network.
74
+ def to_s
75
+ s = "#{headername}\n" +
76
+ "{\n" +
77
+ "v=#{@version}\n" +
78
+ "hop=#{@hop}\n" +
79
+ "uid=#{@uid}\n" +
80
+ "class=#{@msgclass}\n" +
81
+ "source=#{@src_addr}\n"
82
+
83
+ s << "target=#{@target_addr}\n" if @target_addr
84
+
85
+ if @headers
86
+ @headers.each do |k, v|
87
+ s << "#{k}=#{v}\n"
88
+ end
89
+ end
90
+
91
+ s << "}\n"
92
+
93
+ if @blocks
94
+ @blocks.each do |name, block|
95
+ s << "#{name}\n{\n"
96
+ block.each do |k, v|
97
+ s << "#{k}=#{v}\n"
98
+ end
99
+ s << "}\n"
100
+ end
101
+ end
102
+
103
+ s
104
+ end
105
+
106
+ # Returns the last two digits of the UID
107
+ def uid_endpoint
108
+ @uid[-2, 2]
109
+ end
110
+
111
+ # Parses standard xAP header information from the given header hash,
112
+ # such as protocol version, hop count, unique ID, message class,
113
+ # message source, and message target.
114
+ #
115
+ # Example usage: parse_header(message_hash['xap-header'])
116
+ protected
117
+ def parse_header header
118
+ # TODO: Mixed-case header field names?
119
+ @src_addr = XapAddress.parse header['source']
120
+ @target_addr = XapAddress.parse header['target']
121
+ @version = header['v']
122
+ @hop = header['hop']
123
+ @uid = header['uid'] # TODO: Parse/validate UID/add UID class/merge with Address?
124
+ @msgclass = header['class']
125
+ end
126
+
127
+ # Adds a custom field to the message header.
128
+ def add_header key, value
129
+ @headers ||= {}
130
+ @headers[key] = value
131
+ end
132
+
133
+ # Adds the given hash as a block under the given name (TODO: hexadecimal fields)
134
+ # FIXME: multiple blocks can have the same name in xAP according to
135
+ # http://www.xapautomation.org/index.php?title=Protocol_definition#Message_Grammar
136
+ # so the blocks hash and to_hash in the parser need to be replaced with
137
+ # arrays.
138
+ def add_block name, hash
139
+ @blocks ||= {}
140
+ @blocks[name] = hash
141
+ end
142
+
143
+ # Sets the block list to the given hash
144
+ def set_blocks hash
145
+ @blocks = hash
146
+ end
147
+ end
148
+
149
+ # A fallback class (or inheritable utility class) for messages not supported by
150
+ # a loaded schema.
151
+ class XapUnsupportedMessage < XapMessage
152
+ XapMessage.register_class self, nil
153
+
154
+ def self.parse hash
155
+ self.new hash, nil, nil
156
+ end
157
+
158
+ def initialize msgclass, src_addr, src_uid, target_addr = nil
159
+ @headername ||= 'xap-header'
160
+ if msgclass.is_a?(Hash)
161
+ parse_header msgclass[@headername]
162
+
163
+ blocks = msgclass.clone
164
+ blocks.delete @headername
165
+ set_blocks blocks
166
+ else
167
+ super msgclass, src_addr, src_uid, target_addr
168
+ end
169
+ end
170
+ end
171
+
172
+ # An xAP heartbeat message.
173
+ class XapHeartbeat < XapMessage
174
+ XapMessage.register_class self, 'xap-hbeat.alive', 'xap-hbeat'
175
+
176
+ attr_accessor :interval
177
+
178
+ def self.parse hash
179
+ self.new hash, nil
180
+ end
181
+
182
+ def initialize src_addr, src_uid, interval = 60
183
+ @headername = 'xap-hbeat'
184
+ if src_addr.is_a?(Hash)
185
+ parse_header src_addr[@headername]
186
+ interval = src_addr[@headername]['interval'] || interval
187
+ else
188
+ super 'xap-hbeat.alive', src_addr, src_uid
189
+ end
190
+ add_header 'interval', interval
191
+ @interval = interval
192
+ end
193
+ end
194
+ end