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,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