xap_ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,384 @@
1
+ # An XapDevice model of an xAP Basic Status and Control device.
2
+ # (C)2012 Mike Bourgeous
3
+
4
+ module Xap
5
+ module Schema
6
+ # Represents an xAP BSC Device. See the xAP Basic Status and Control Schema.
7
+ # http://www.xapautomation.org/index.php?title=Basic_Status_and_Control_Schema
8
+ class XapBscDevice < XapDevice
9
+ # Initializes an XapBscDevice with the given address, uid. Endpoints
10
+ # is an array of hashes containing :State, :Level, :Text, and/or
11
+ # optionally :DisplayText. :State should be a boolean value or nil,
12
+ # :Level should be an array of [numerator, denominator], and :Text and
13
+ # :DisplayText should be Strings. Each input and output block must
14
+ # also have an :endpoint key that contains the endpoint name for the
15
+ # given block and may include a :uid key that contains an integer from
16
+ # 1 to 254. Endpoint names and UIDs must be unique within this device.
17
+ # Output hashes (i.e. those endpoints that can be changed via xAP) are
18
+ # identified by including a :callback key that is a proc to be called
19
+ # with the endpoint hash when any of the output endpoint's attributes
20
+ # are changed by an incoming xAP message.
21
+ #
22
+ # See add_endpoint for a summary of endpoint fields.
23
+ #
24
+ # Example:
25
+ #
26
+ # XapBscDevice.new XapAddress.new('vendor', 'dev', 'hostname'), Xap.random_uid,
27
+ # [
28
+ # { :endpoint => 'Input 1', :uid => 1, :State => true },
29
+ # { :endpoint => 'Output 1', :State => true, :callback => proc { |ep| puts 'Output 1' } }
30
+ # ]
31
+ def initialize address, uid, endpoints, interval = 5
32
+ super address, uid, interval
33
+
34
+ # TODO: Make endpoints a hash, with what is currently the
35
+ # :endpoint field as the hash key, then store the hash key back
36
+ # into the hash (will simplify calls to XapBscDevice.new, so
37
+ # the user can type => instead of :endpoint)
38
+
39
+ @input_count = 0
40
+ @output_count = 0
41
+ @endpoints = {} # Mapping from endpoint name to endpoint hash
42
+ @uids = []
43
+ @outputs = [] # Array containing only output endpoints to simplify handling command messages
44
+ endpoints.each do |ep|
45
+ add_endpoint ep
46
+ end
47
+ end
48
+
49
+ # Assigns the handler that owns this device, then sends the initial
50
+ # state of all input and output blocks as xAPBSC.info messages.
51
+ def handler= handler
52
+ super handler
53
+ announce_endpoints
54
+ end
55
+
56
+ # Sets the xAP UID of this virtual device, then sends an xAPBSC.info
57
+ # message for all endpoints.
58
+ def uid= uid
59
+ super uid
60
+ announce_endpoints
61
+ end
62
+
63
+ # Sets the xAP address of this virtual device, then sends an
64
+ # xAPBSC.info message for all endpoints.
65
+ def set_address address
66
+ super address
67
+ announce_endpoints
68
+ end
69
+
70
+ # Called when a message targeting this device's address is received.
71
+ def receive_message msg
72
+ if msg.is_a? XapBscCommand
73
+ Xap.log "Command message for #{self}"
74
+
75
+ if @output_count > 0
76
+ eps = []
77
+ if msg.target_addr.wildcard?
78
+ @outputs.each do |out|
79
+ eps << out if msg.target_addr.endpoint_match out[:endpoint]
80
+ end
81
+ else
82
+ ep = @endpoints[msg.target_addr.endpoint.downcase]
83
+ eps << ep if ep
84
+ end
85
+
86
+ # For each message block, if ID=*, apply the
87
+ # change to all matched endpoints. If ID!=*,
88
+ # apply the change to the matching endpoint,
89
+ # iff that endpoint is in the eps list.
90
+ msg.each_block do |blk|
91
+ id = blk.id
92
+ if id == nil || id == '*'
93
+ eps.each do |ep|
94
+ update_endpoint ep, blk
95
+ end
96
+ else
97
+ ep = @uids[id.to_i]
98
+ if ep && eps.include?(ep)
99
+ update_endpoint ep, blk
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ elsif msg.is_a? XapBscQuery
106
+ Xap.log "Query message for #{self}, target #{msg.target_addr}, wildcard #{msg.target_addr.wildcard?}"
107
+
108
+ if msg.target_addr.wildcard?
109
+ @endpoints.each do |name, ep|
110
+ if msg.target_addr.endpoint_match name
111
+ Xap.log "Matching endpoint found: #{ep[:endpoint]}"
112
+ send_info ep
113
+ end
114
+ end
115
+ elsif msg.target_addr.endpoint
116
+ ep = @endpoints[msg.target_addr.endpoint.to_s.downcase]
117
+ if ep
118
+ Xap.log "Matching endpoint found: #{ep[:endpoint]}"
119
+ send_info ep
120
+ else
121
+ Xap.log "No matching endpoint found"
122
+ end
123
+ else
124
+ Xap.log "Error: No endpoint was given in the query"
125
+ end
126
+
127
+ elsif msg.is_a? XapBscInfo
128
+ Xap.log "Info message for #{self}"
129
+
130
+ elsif msg.is_a? XapBscEvent
131
+ Xap.log "Event message for #{self}"
132
+
133
+ end
134
+ end
135
+
136
+ # Adds a new endpoint hash to the list of endpoints, generating an
137
+ # xAPBSC.info message if the addition is successful. The endpoint's
138
+ # name must be unique. UID collision will result in an exception being
139
+ # raised. If the UID is not specified, the lowest available UID will
140
+ # be used.
141
+ #
142
+ # Summary of endpoint fields:
143
+ # :endpoint - Name of endpoint - mandatory, must be unique when downcased, String
144
+ # :uid - UID of endpoint - optional, must be unique, Fixnum 1-254
145
+ # :callback - Output change callback - mandatory for outputs, may be nil
146
+ # :State - On/off/? state - mandatory according to xAP BSC spec
147
+ # :Level - numerator / denominator - optional
148
+ # :Text - Stream text - optional (mutually exclusive with :Level according to xAP BSC spec)
149
+ # :DisplayText - UI display text - optional
150
+ #
151
+ # Example:
152
+ # add_endpoint { :endpoint => 'Input 1', :uid => 4, :State => false, :Level => [ 0, 30 ] }
153
+ def add_endpoint ep
154
+ unless ep.include?(:endpoint) && ep.include?(:State) && (!ep.include?(:uid) || ep[:uid].is_a?(Fixnum))
155
+ raise 'An endpoint is missing one or more required fields (:endpoint, :State).'
156
+ end
157
+
158
+ raise "Duplicate endpoint name #{ep[:endpoint]}." if @endpoints.include? ep[:endpoint].downcase
159
+
160
+ ep[:uid] ||= find_free_uid
161
+ raise "Duplicate UID #{ep[:uid]}." if @uids[ep[:uid]]
162
+
163
+ # TODO: Additional verification of :Level, :Text, and :DisplayText
164
+
165
+ if ep.include? :callback
166
+ @output_count = @output_count + 1
167
+ @outputs << ep
168
+ else
169
+ @input_count = @input_count + 1
170
+ end
171
+
172
+ @endpoints[ep[:endpoint].downcase] = ep
173
+ @uids[ep[:uid]] = ep
174
+
175
+ send_info ep if @handler
176
+ end
177
+
178
+ # Removes the given endpoint, which may be the endpoint hash or name.
179
+ # Doesn't verify that the endpoint actually exists.
180
+ def remove_endpoint ep
181
+ if ep.is_a? String
182
+ ep = @endpoints[ep.downcase]
183
+ end
184
+ @endpoints.delete ep[:endpoint].downcase
185
+ @uids[ep[:uid]] = nil
186
+ end
187
+
188
+ # Returns the endpoint having the given UID, if any.
189
+ def uid_endpoint uid
190
+ @uids[uid]
191
+ end
192
+
193
+ # Finds and returns the lowest-available endpoint UID, or nil if there
194
+ # are no free endpoint IDs.
195
+ def find_free_uid
196
+ for idx in 1..254
197
+ return idx unless @uids[idx]
198
+ end
199
+ nil
200
+ end
201
+
202
+ # Returns the UID for the endpoint with the given name, or nil if no
203
+ # such endpoint exists.
204
+ def get_uid endpoint
205
+ @endpoints[endpoint.downcase][:uid]
206
+ end
207
+
208
+ # Returns the State field of the endpoint with the given name.
209
+ def get_state endpoint
210
+ @endpoints[endpoint.downcase][:State]
211
+ end
212
+
213
+ # Sets the State field of the endpoint with the given name. If the new
214
+ # state is different from the old state, an event message will be
215
+ # generated. Otherwise, an info message will be generated.
216
+ def set_state endpoint, state
217
+ raise 'state must be true, false, or nil.' unless state == true || state == false || state == nil
218
+
219
+ ep = @endpoints[endpoint.downcase]
220
+ old = ep[:State]
221
+ ep[:State] = state
222
+
223
+ if state != old
224
+ send_event ep
225
+ else
226
+ send_info ep
227
+ end
228
+ end
229
+
230
+ # Returns the Level field of the endpoint with the given name.
231
+ def get_level endpoint
232
+ @endpoints[endpoint.downcase][:Level]
233
+ end
234
+
235
+ # Sets the Level field of the endpoint with the given name. If level
236
+ # is an array, then both the numerator and denominator are replaced.
237
+ # If level is a Fixnum, only the numerator is replaced. If the new
238
+ # level is different from the old level, an event message will be
239
+ # generated. Otherwise, an info message will be generated. Error
240
+ # checking is not performed on the level parameter. Pass nil to remove
241
+ # the level from this endpoint.
242
+ def set_level endpoint, level
243
+ ep = @endpoints[endpoint.downcase]
244
+
245
+ old = ep[:Level]
246
+ if level != nil
247
+ level = [ level, ep[:Level][1] ] if level.is_a? Fixnum
248
+ ep[:Level] = level
249
+ else
250
+ ep.delete :Level
251
+ end
252
+
253
+ if level != old
254
+ send_event ep
255
+ else
256
+ send_info ep
257
+ end
258
+ end
259
+
260
+ # Returns the Text field of the endpoint with the given name.
261
+ def get_text endpoint
262
+ @endpoints[endpoint.downcase][:Text]
263
+ end
264
+
265
+ # Sets the Text field of the endpoint with the given name. If the new
266
+ # state is different from the old state, an event message will be
267
+ # generated. Otherwise, an info message will be generated.
268
+ def set_text endpoint, text
269
+ ep = @endpoints[endpoint.downcase]
270
+
271
+ old = ep[:Text]
272
+ ep[:Text] = old
273
+
274
+ if text != old
275
+ send_event ep
276
+ else
277
+ send_info ep
278
+ end
279
+ end
280
+
281
+ # Returns the DisplayText field of the endpoint with the given name.
282
+ def get_display_text endpoint
283
+ @endpoints[endpoint.downcase][:DisplayText]
284
+ end
285
+
286
+ # Sets the DisplayText field of the endpoint with the given name. If
287
+ # the new state is different from the old state, an event message will
288
+ # be generated. Otherwise, an info message will be generated.
289
+ def set_display_text endpoint, text
290
+ ep = @endpoints[endpoint.downcase]
291
+
292
+ old = ep[:DisplayText]
293
+ ep[:DisplayText] = old
294
+
295
+ if text != old
296
+ send_event ep
297
+ else
298
+ send_info ep
299
+ end
300
+ end
301
+
302
+ # FIXME: If multiple fields need to change at once, don't send
303
+ # info/event messages until after all the fields are changed.
304
+ private
305
+ # Send an xAPBSC.info message for all endpoints.
306
+ def announce_endpoints
307
+ if @endpoints
308
+ @endpoints.each do |name, ep|
309
+ send_info ep
310
+ end
311
+ end
312
+ end
313
+
314
+ # Send an xAPBSC.info message for the given endpoint hash.
315
+ def send_info ep
316
+ # Send info message for endpoint (TODO: Store an info
317
+ # message in the endpoint hash instead of continually
318
+ # creating new info messages?)
319
+ msg = XapBscInfo.new(address.for_endpoint(ep[:endpoint]), uid_for(ep[:uid]), !ep.include?(:callback))
320
+ msg.state = ep[:State] if ep.include? :State
321
+ msg.level = ep[:Level] if ep.include? :Level
322
+ msg.text = ep[:Text] if ep.include? :Text
323
+ msg.display_text = ep[:DisplayText] if ep.include? :DisplayText
324
+
325
+ send_message msg
326
+ end
327
+
328
+ # Send an xAPBSC.event message for the given endpoint hash.
329
+ def send_event ep
330
+ # Send event message for endpoint (TODO: Store an event
331
+ # message in the endpoint hash instead of continually
332
+ # creating new info messages?)
333
+ msg = XapBscEvent.new(address.for_endpoint(ep[:endpoint]), uid_for(ep[:uid]), !ep.include?(:callback))
334
+ msg.state = ep[:State] if ep.include? :State
335
+ msg.level = ep[:Level] if ep.include? :Level
336
+ msg.text = ep[:Text] if ep.include? :Text
337
+ msg.display_text = ep[:DisplayText] if ep.include? :DisplayText
338
+
339
+ send_message msg
340
+ end
341
+
342
+ # Generates a UID string for the given integer sub-UID between 1 and 254.
343
+ def uid_for uid
344
+ sprintf "#{@uid.slice(0,6)}%02X", uid
345
+ end
346
+
347
+ # Updates the given endpoint with values from the given XapBscBlock
348
+ def update_endpoint ep, block
349
+ old = ep.clone
350
+
351
+ if block.state != nil
352
+ case block.state
353
+ when true
354
+ ep[:State] = true
355
+ when false
356
+ ep[:State] = false
357
+ when 'toggle'
358
+ ep[:State] = !ep[:State]
359
+ end
360
+ end
361
+
362
+ if ep.include?(:Level) && block.level
363
+ case block.level[1]
364
+ when '%', Fixnum
365
+ div = block.level[1] == '%' ? 100 : block.level[1]
366
+ ep[:Level][0] = block.level[0] * ep[:Level][1] / div
367
+ when nil
368
+ ep[:Level][0] = block.level[0]
369
+ end
370
+ end
371
+
372
+ ep[:Text] = block.text if ep.include?(:Text) && block.text
373
+ ep[:DisplayText] = block.display_text if ep.include?(:DisplayText) && block.display_text
374
+
375
+ if ep != old
376
+ send_event ep
377
+ ep[:callback].call(ep)
378
+ else
379
+ send_info ep
380
+ end
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,169 @@
1
+ # Represents an xAP address.
2
+ # (C)2012 Mike Bourgeous
3
+
4
+ module Xap
5
+ # Represents an xAP address. Matching is case-insensitive.
6
+ #
7
+ # Wildcard characters supported: * and >. * matches anything within a
8
+ # subsection, but will not match across . or : (e.g. a.*.c will match a.b.c but
9
+ # not a.b.d.c). * must occur by itself in a subsection (e.g. *.a.b and a.*.b
10
+ # are OK, but ab*.c*d.*e is not). > matches anything to the end of the section
11
+ # if specified before a :, or anything to the end of an address being tested if
12
+ # at the end of the wildcarded address.
13
+ class XapAddress
14
+ attr_accessor :vendor, :product, :instance, :endpoint, :wildcard, :basewildcard, :epwildcard
15
+
16
+ # Parses the given address string into an XapAddress. If addr is nil,
17
+ # returns nil.
18
+ def self.parse addr
19
+ return nil unless addr
20
+ raise 'addr must be a String' unless addr.is_a? String
21
+
22
+ # As far as I can tell, the xAP spec isn't very clear on how
23
+ # long an address can be, whether the subaddress is specified
24
+ # by a colon or a period, etc.
25
+ #
26
+ # This section says that both instance and subaddr can have any depth
27
+ # http://www.xapautomation.org/index.php?title=Protocol_definition#Message_Addressing_Schemes
28
+ #
29
+ # This section has additional information on addresses
30
+ # (including vendor and device length limits of 8 characters,
31
+ # which are broken in many of the xAP BSC examples...)
32
+ # http://www.xapautomation.org/index.php?title=Protocol_definition#Message_Header_Structure
33
+ #
34
+ # This section makes the distinction between : and . less clear
35
+ # http://www.xapautomation.org/index.php?title=Protocol_definition#Wildcarding_of_Addresses_via_Header
36
+ #
37
+ # Here's documentation for an xAP plugin for some other
38
+ # software that ignores the UID rules entirely and uses the >
39
+ # wildcard character in the middle of an address
40
+ # http://www.erspearson.com/xAP/Slim/Manual.html#id616380
41
+ tokens = addr.strip.split ':', 2
42
+ addr = tokens[0].split '.', 3
43
+ subaddr = tokens[1]
44
+
45
+ self.new addr[0], addr[1], addr[2], subaddr
46
+ end
47
+
48
+ # vendor - xAP-assigned vendor ID (e.g. ACME)
49
+ # product - vendor-assigned product name (e.g. Controller)
50
+ # instance - user-assigned product instance (e.g. Apartment)
51
+ # endpoint - user- or device-assigned name (e.g. Zone1), or nil
52
+ def initialize vendor, product, instance, endpoint=nil
53
+ # This would be a nice application of macros (e.g. "check_type var, String")
54
+ raise 'vendor must be (convertible to) a String' unless vendor.respond_to? :to_s
55
+ raise 'product must be (convertible to) a String' unless product.respond_to? :to_s
56
+ raise 'instance must be (convertible to) a String' unless instance.respond_to? :to_s
57
+ raise 'endpoint must be (convertible to) a String' unless endpoint.respond_to? :to_s
58
+
59
+ # TODO: Validate characters in the address
60
+
61
+ @vendor = vendor.to_s
62
+ @product = product.to_s
63
+ @instance = instance.to_s
64
+ @endpoint = endpoint ? endpoint.to_s : nil
65
+
66
+ # Many of the xAP standard's own examples violate the length limits...
67
+ #raise 'vendor is too long' if @vendor.length > 8
68
+ #raise 'product is too long' if @product.length > 8
69
+
70
+ # Build the string representation of this address
71
+ @str = "#{@vendor}.#{@product}.#{@instance}"
72
+ @str << ":#{@endpoint}" if @endpoint
73
+
74
+ # Build a regex for matching wildcarded addresses
75
+ raise "Address #{@str} contains * in the middle of a word" if @str =~ /([^.:]\*)|(\*[^.:])/
76
+ raise "Address #{@str} contains > not at the end of a section" if @str =~ />(?!\:|$)/
77
+
78
+ @regex, @wildcard = build_regex @str
79
+ @baseregex, @basewildcard = build_regex("#{@vendor}.#{@product}.#{@instance}")
80
+ if @endpoint
81
+ @epregex, @epwildcard = build_regex(@endpoint)
82
+ elsif @str.end_with? '>'
83
+ # FIXME: I believe xAP wildcard addresses are supposed to treat . and : indistinguishably
84
+ @epregex = //
85
+ @epwildcard = true
86
+ else
87
+ @epregex = /^$/
88
+ @epwildcard = false
89
+ end
90
+ end
91
+
92
+ # Returns true if all fields are == when converted to lowercase
93
+ def == other
94
+ if other.is_a? XapAddress
95
+ other.to_s.downcase == to_s.downcase
96
+ else
97
+ false
98
+ end
99
+ end
100
+
101
+ # Returns true if other == self or self is a wildcard address that
102
+ # matches other (which may either be an XapAddress or anything that can
103
+ # be converted to a String with to_s). Note that (self =~ other) !=
104
+ # (other =~ self).
105
+ def =~ other
106
+ other == self || (other.to_s =~ @regex) == 0
107
+ end
108
+ alias_method :match, :'=~'
109
+
110
+ # Returns an XapAddress that contains the base of this address with no
111
+ # endpoint. Wildcards in the base address components are preserved.
112
+ def base
113
+ XapAddress.new @vendor, @product, @instance
114
+ end
115
+
116
+ # Returns a new XapAddress that contains this address's base components
117
+ # with the given endpoint.
118
+ def for_endpoint ep_name
119
+ XapAddress.new @vendor, @product, @instance, ep_name
120
+ end
121
+
122
+ # Returns true if this address's base components (i.e. everything
123
+ # before the colon) match the base components of the given other
124
+ # address. If matching a wildcarded address, call this on the
125
+ # wildcarded address (e.g. wild.base_match(other) rather than
126
+ # other.base_match(wild).
127
+ def base_match other
128
+ other.base.to_s =~ @baseregex
129
+ end
130
+
131
+ # Returns true if this address's endpoint matches the endpoint of the
132
+ # given other address. If other is a String, then it will be matched
133
+ # as if it were an XapAddress endpoint.
134
+ def endpoint_match other
135
+ if other.is_a? String
136
+ other =~ @epregex
137
+ elsif other.respond_to?(:endpoint)
138
+ other.endpoint =~ @epregex
139
+ else
140
+ false
141
+ end
142
+ end
143
+
144
+ # Returns a correctly-formatted string representation of the address,
145
+ # suitable for inclusion in an xAP message.
146
+ def to_s
147
+ @str
148
+ end
149
+
150
+ # Whether this address is a wildcard address.
151
+ def wildcard?
152
+ @wildcard
153
+ end
154
+
155
+ private
156
+ # Builds a regular expression for a wildcarded xAP string
157
+ def build_regex str
158
+ # FIXME: escape all regex characters with Regexp.escape, without breaking * wildcard
159
+ regex = str.gsub '.', '\\.'
160
+ wildcard = !!(str =~ /[*>]/)
161
+ if wildcard
162
+ regex = regex.gsub /(?<=\\\.|^)\*(?=\\\.|$)/, '\\1[^.:]*'
163
+ regex = regex.gsub />:/, '[^:]*:'
164
+ regex = regex.gsub />$/, '.*'
165
+ end
166
+ return Regexp.new("^#{regex}$", Regexp::IGNORECASE), wildcard
167
+ end
168
+ end
169
+ end