mtik 3.0.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,603 @@
1
+ ############################################################################
2
+ ## A Ruby library implementing the Ruby MikroTik API
3
+ ############################################################################
4
+ ## Author:: Aaron D. Gifford - http://www.aarongifford.com/
5
+ ## Copyright:: Copyright (c) 2009-2010, InfoWest, Inc.
6
+ ## License:: BSD license
7
+ ##
8
+ ## Redistribution and use in source and binary forms, with or without
9
+ ## modification, are permitted provided that the following conditions
10
+ ## are met:
11
+ ## 1. Redistributions of source code must retain the above copyright
12
+ ## notice, the above list of authors and contributors, this list of
13
+ ## conditions and the following disclaimer.
14
+ ## 2. Redistributions in binary form must reproduce the above copyright
15
+ ## notice, this list of conditions and the following disclaimer in the
16
+ ## documentation and/or other materials provided with the distribution.
17
+ ## 3. Neither the name of the author(s) or copyright holder(s) nor the
18
+ ## names of any contributors may be used to endorse or promote products
19
+ ## derived from this software without specific prior written permission.
20
+ ##
21
+ ## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S), AUTHOR(S) AND
22
+ ## CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
23
+ ## INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
24
+ ## AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
25
+ ## IN NO EVENT SHALL THE COPYRIGHT HOLDER(S), AUTHOR(S), OR CONTRIBUTORS BE
26
+ ## LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
27
+ ## DCONSEQUENTIAL AMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
28
+ ## SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
29
+ ## INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
30
+ ## CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31
+ ## ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
32
+ ## THE POSSIBILITY OF SUCH DAMAGE.
33
+ ############################################################################
34
+
35
+ ## The MTik::Connection class is the workhorse where most stuff gets done.
36
+ ## Create an instance of this object to connect to a MikroTik device via
37
+ ## the API and execute commands (requests) and receive responses (replies).
38
+ class MTik::Connection
39
+ require 'socket'
40
+ require 'digest/md5'
41
+
42
+ ## Initialize/construct the new _MTik_ object. One or more
43
+ ## key/value pair style arguments must be specified. The one
44
+ ## required argument is the host or IP of the device to connect
45
+ ## to.
46
+ ## _host_ :: This is the only _required_ argument. Example:
47
+ ## + :host => "rb411.example.org" +
48
+ ## _port_ :: Override the default API port (8728)
49
+ ## _user_ :: Override the default API username ('admin')
50
+ ## _pass_ :: Override the default API password (blank)
51
+ ## _conn_timeout_ :: Override the default connection
52
+ ## timeout (60 seconds) -- *NOT USED*
53
+ ## _cmd_timeout_ :: Override the default command timeout
54
+ ## (60 seconds) -- the number of seconds
55
+ ## to wait for additional API input.
56
+ def initialize(args)
57
+ @sock = nil
58
+ @requests = Hash.new
59
+ @host = args[:host]
60
+ @port = args[:port] || MTik::PORT
61
+ @user = args[:user] || MTik::USER
62
+ @pass = args[:pass] || MTik::PASS
63
+ @conn_timeout = args[:conn_timeout] || MTik::CONN_TIMEOUT
64
+ @cmd_timeout = args[:cmd_timeout] || MTik::CMD_TIMEOUT
65
+ @data = ''
66
+ @parsing = false ## Recursion flag
67
+
68
+ ## Initiate connection and immediately login to device:
69
+ login
70
+ end
71
+
72
+ ## Return the number of currently outstanding requests
73
+ def outstanding
74
+ return @requests.length
75
+ end
76
+ attr_reader :requests, :host, :port, :user, :pass, :conn_timeout, :cmd_timeout
77
+
78
+ ## Utility to pack a binary string as an ASCII hex string
79
+ ## (Ruby really needs pack method for the String class--like
80
+ ## "deadbeef".pack("H*")--then this would be unnecessary.)
81
+ def hexpack(str)
82
+ return str.length % 2 == 0 ?
83
+ [str].pack('H'+str.length.to_s) :
84
+ [str[0,1]].pack('h') + [str[1,str.length-1]].pack('H'+(str.length-1).to_s)
85
+ end
86
+
87
+ ## Connect and login to the device using the API
88
+ def login
89
+ connect
90
+ unless connected?
91
+ raise MTik::Error.new("Login failed: Unable to connect to device.")
92
+ end
93
+
94
+ ## Send first /login command to obtain the challenge:
95
+ reply = get_reply('/login')
96
+ ## Make sure the reply has the info we expect:
97
+ if reply.length != 1 || reply[0].length != 3 || !reply[0].key?('ret')
98
+ raise MTik::Error.new("Login failed: unexpected reply to login attempt.")
99
+ end
100
+
101
+ ## Grab the challenge from first (only) sentence in the reply:
102
+ challenge = hexpack(reply[0]['ret'])
103
+
104
+ ## Generate reply MD5 hash and convert binary hash to hex string:
105
+ response = Digest::MD5.hexdigest(0.chr + @pass + challenge)
106
+
107
+ ## Send second /login command with our response:
108
+ reply = get_reply('/login', '=name=' + @user, '=response=00' + response)
109
+ if reply[0].key?('!trap')
110
+ raise MTik::Error.new("Login failed: " + (reply[0].key?('message') ? reply[0]['message'] : 'Unknown error.'))
111
+ end
112
+ unless reply.length == 1 && reply[0].length == 2 && reply[0].key?('!done')
113
+ @sock.close
114
+ @sock = nil
115
+ raise MTik::Error.new('Login failed: Unknown response to login.')
116
+ end
117
+ end
118
+
119
+ ## Connect to the device
120
+ def connect
121
+ return unless @sock.nil?
122
+ ## TODO: Perhaps catch more errors; implement connection timeout
123
+ begin
124
+ @sock = TCPSocket::new(@host, @port)
125
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::ENETUNREACH,
126
+ Errno::EHOSTUNREACH => e
127
+ @sock = nil
128
+ raise e ## Re-raise the exception
129
+ end
130
+ end
131
+
132
+ ## Wait for and read exactly one sentence, regardless of content:
133
+ def get_sentence
134
+ ## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
135
+ if @sock.nil?
136
+ raise MTik::Error.new("Cannot retrieve reply sentence--not connected.")
137
+ end
138
+ sentence = Hash.new
139
+ oldlen = -1
140
+ while true ## read-data loop
141
+ if @data.length == oldlen
142
+ sleep(1) ## Wait for some more data
143
+ else
144
+ while true ## word parsing loop
145
+ bytes, word = get_tikword(@data)
146
+ @data[0, bytes] = ''
147
+ if word.nil?
148
+ break
149
+ end
150
+ if word.length == 0
151
+ ## Received END-OF-SENTENCE
152
+ if sentence.length == 0
153
+ raise MTik::Error.new("Received END-OF-SENTENCE from device with no sentence data.")
154
+ end
155
+ ## Debugging or verbose, show the received sentence:
156
+ if MTik::debug || MTik::verbose
157
+ sentence.each do |k, v|
158
+ if v.nil?
159
+ STDERR.print ">>> '#{k}' (#{k.length})\n"
160
+ else
161
+ STDERR.print ">>> '#{k}=#{v}' (#{k.length+v.length+1})\n"
162
+ end
163
+ end
164
+ STDERR.print ">>> END-OF SENTENCE\n\n"
165
+ end
166
+ if sentence.key?('!fatal')
167
+ ## Fatal error (or '/quit'):
168
+ close ## Assume disconnection
169
+ end
170
+ ## Finished. Return the sentence:
171
+ return sentence
172
+ else
173
+ ## Add word to sentence
174
+ if m = /^=?([^=]+)=(.*)$/.match(word)
175
+ sentence[m[1]] = m[2]
176
+ else
177
+ sentence[word] = nil
178
+ end
179
+ end
180
+ end ## word parsing loop
181
+ end
182
+ oldlen = @data.length
183
+ ## Read some more data IF any is available:
184
+ sel = IO.select([@sock],nil,[@sock], @cmd_timeout)
185
+ if sel.nil?
186
+ raise MTik::TimeoutError.new(
187
+ "Time-out while awaiting data with #{outstanding} pending " +
188
+ "requests: '" + @requests.values.map{|req| req.command}.join("' ,'") + "'"
189
+ )
190
+ end
191
+ if sel[0].length == 1
192
+ @data += @sock.recv(8192)
193
+ elsif sel[2].length == 1
194
+ raise MTik::Error.new(
195
+ "I/O (select) error while awaiting data with #{outstanding} pending " +
196
+ "requests: '" + @requests.values.map{|req| req.command}.join("' ,'") + "'"
197
+ )
198
+ end
199
+ end ## read-data loop
200
+ end
201
+
202
+ ## Keep reading replies until ALL outstanding requests have completed
203
+ def wait_all
204
+ while outstanding > 0
205
+ wait_for_reply
206
+ end
207
+ end
208
+
209
+ ## Keep reading replies until a SPECIFIC command has completed.
210
+ def wait_for_request(req)
211
+ while !req.done?
212
+ wait_for_reply
213
+ end
214
+ end
215
+
216
+ ## Read one or more reply sentences.
217
+ ## TODO: Implement timeouts, detect disconnection, maybe do auto-reconnect
218
+ def wait_for_reply
219
+ ## Sanity check:
220
+ if @data.length > 0 && !@parsing
221
+ raise MTik::Error.new("An unexpected #{@data.length} bytes were found from a previous reply. API utility may be buggy.\n")
222
+ end
223
+ if @requests.length < 1
224
+ raise MTik::Error.new("Cannot retrieve reply--No request was made.")
225
+ end
226
+
227
+ ## SENTENCE READING LOOP:
228
+ oldparsing = @parsing
229
+ @parsing = true
230
+ begin
231
+ ## Fetch a sentence:
232
+ sentence = get_sentence ## This call must be ATOMIC or re-entrant safety fails
233
+
234
+ ## Check for '!fatal' before checking for a tag--'!fatal'
235
+ ## is never(???) tagged:
236
+ if sentence.key?('!fatal')
237
+ ## FATAL ERROR has occured! (Or a '/quit' command was issued...)
238
+ if @data.length > 0
239
+ raise MTik::Error.new("Sanity check failed on receipt of '!fatal' message: #{@data.length} more bytes remain to be parsed. API utility may be buggy.")
240
+ end
241
+
242
+ quit = false
243
+ ## Iterate over all incomplete requests:
244
+ @requests.each_value do |r|
245
+ if r.done?
246
+ raise MTik::Error.new("Sanity check failed: an outstanding request was flagged as done!")
247
+ end
248
+ @requests.delete(r.tag)
249
+ r.done!
250
+ if r.await_completion
251
+ ## Pass partial reply to callback along with '!fatal' sentence
252
+ r.callback(sentence)
253
+ end
254
+ ## Was this a '/quit' command?
255
+ if r.command == '/quit'
256
+ quit = true
257
+ ## Attach the untagged '!fatal' reply to the '/quit' command:
258
+ r.reply.push(sentence)
259
+ end
260
+ end
261
+
262
+ ## Raise fatal error if there wasn't a '/quit' command:
263
+ unless quit
264
+ raise MTik::FatalError.new(sentence.key?('message') ? sentence['message'] : '')
265
+ end
266
+ ## On /quit, just return:
267
+ @parsing = oldparsing
268
+ return
269
+ end
270
+
271
+ ## We expect ALL sentences thus far to be tagged:
272
+ unless sentence.key?('.tag')
273
+ ## This code tags EVERY request, so NO RESPONSE should be untagged
274
+ ## except maybe a '!fatal' error...
275
+ raise MTik::Error.new("Unexected untagged response received.")
276
+ end
277
+ rtag = sentence['.tag']
278
+
279
+ ## Find which request this reply sentence belongs to:
280
+ unless @requests.key?(rtag)
281
+ raise MTik::Error.new("Unknown tag '#{rtag}' found in response.")
282
+ end
283
+ request = @requests[rtag]
284
+
285
+ ## Sanity check: No sentences should arrive for completed requests.
286
+ if request.done?
287
+ raise MTik::Error.new("Unexpected new reply sentence received for already-completed request.")
288
+ end
289
+
290
+ ## Add the sentence to the request's reply:
291
+ request.reply.push(sentence)
292
+
293
+ ## On '!done', flag the request response as complete:
294
+ if sentence.key?('!done')
295
+ request.done!
296
+ ## Pass the data to the callback:
297
+ request.callback(sentence)
298
+ ## Remove the request:
299
+ @requests.delete(request.tag)
300
+ else
301
+ unless request.await_completion && !request.done?
302
+ ## Pass the data to the callback:
303
+ request.callback(sentence)
304
+ end
305
+ end
306
+ ## Keep reading sentences as long as there is data to be parsed:
307
+ end while @data.length > 0
308
+ @parsing = oldparsing
309
+ end
310
+
311
+ ## Alias of send_request() with param 1 set to false
312
+ def request_each(command, *args, &callback)
313
+ return send_request(false, command, *args, &callback)
314
+ end
315
+
316
+ ## Alias of send_request() with param 1 set to true
317
+ def request(command, *args, &callback)
318
+ return send_request(true, command, *args, &callback)
319
+ end
320
+
321
+ ## Send a request to the device.
322
+ ## +await_completion+ :: Boolean indicating whether to execute callbacks
323
+ ## only once upon request completion (if set to _true_)
324
+ ## or to execute for every received complete sentence
325
+ ## (if set to _false_). ALTERNATIVELY, this parameter
326
+ ## may be an object (MTik::Request) to be sent, in which
327
+ ## case any command and/or arguments will be treated as
328
+ ## additional arguments to the request contained in the
329
+ ## object.
330
+ ## +command+ :: The command to be executed.
331
+ ## +args+ :: Zero or more arguments to the command
332
+ ## +callback+ :: Proc/lambda code (or code block if not provided as
333
+ ## an argument) to be called. (See the +await_completion+
334
+ ##
335
+ def send_request(await_completion, command, *args, &callback)
336
+ if await_completion.is_a?(MTik::Request)
337
+ if req.done?
338
+ raise MTik::Error.new("Cannot MTik#send_request() with an already-completed MTik::Request object.")
339
+ end
340
+ req = await_completion
341
+ req.addarg(command)
342
+ req.addargs(*args)
343
+ else
344
+ req = MTik::Request.new(await_completion, command, *args, &callback)
345
+ end
346
+ ## Add the new outstanding request
347
+ @requests[req.tag] = req
348
+
349
+ if MTik::debug || MTik::verbose
350
+ req.each do |x|
351
+ STDERR.print "<<< '#{x}' (#{x.length})\n"
352
+ end
353
+ end
354
+ STDERR.print "<<< END-OF-SENTENCE\n\n" if MTik::debug || MTik::verbose
355
+
356
+ req.conn(self) ## Associate the request to this connection object:
357
+ return req.send
358
+ end
359
+
360
+ ## Send the request object over the socket
361
+ def xmit(req)
362
+ @sock.send(req.request, 0)
363
+ return req
364
+ end
365
+
366
+ ## Send a command, then wait for the command to complete, then return
367
+ ## the completed reply.
368
+ ## _NOTE_ :: This call has its own event loop that will cycle
369
+ ## until the command in question completes. You
370
+ ## should:
371
+ ## * NOT call get_reply with a command that may not
372
+ ## complete with a "!done" response on its own
373
+ ## (with no additional intervention); and
374
+ ## * BE CAREFUL to understand how things interact if
375
+ ## you mix this call with requests that generate
376
+ ## continuous output.
377
+ ## +command+ :: The command to execute
378
+ ## +args+ :: Arguments (if any)
379
+ ## +callback+ :: Proc/lambda or code block to act as callback
380
+ def get_reply(command, *args, &callback)
381
+ req = send_request(true, command, *args, &callback)
382
+ wait_for_request(req)
383
+ return req.reply
384
+ end
385
+
386
+ ## This is exactly like get_reply() except that EACH
387
+ ## sentence read will result in the passed Proc/block
388
+ ## being called instead of just the final "!done" reply
389
+ def get_reply_each(command, *args, &callback)
390
+ req = send_request(false, command, *args, &callback)
391
+ wait_for_request(req)
392
+ return req.reply
393
+ end
394
+
395
+ ## Close the connection.
396
+ def close
397
+ return if @sock.nil?
398
+ @sock.close
399
+ @sock = nil
400
+ end
401
+
402
+ ## Is the connection open?
403
+ def connected?
404
+ return @sock.nil? ? false : true
405
+ end
406
+
407
+ ## Because of differences in the Ruby 1.8.x vs 1.9.x 'String' class,
408
+ ## a 'cbyte' utility method that returns the character byte at the
409
+ ## specified offset of the supplied string is here defined so that
410
+ ## there is a single consistent method that will work with either
411
+ ## Ruby version (treating all strings as 8-bit binary data in Ruby 1.9+)
412
+ if RUBY_VERSION >= '1.9.0'
413
+ ## Return the byte at the offset specified from the
414
+ ## Ruby 1.9 8-bit binary string as an integer.
415
+ def cbyte(str, offset)
416
+ return str.encode(Encoding::BINARY)[offset].ord
417
+ end
418
+ else
419
+ ## Return the byte at the offset specified from the
420
+ ## Ruby 1.8.x character string (Ruby 1.8 doesn't
421
+ ## support multi-byte characters so all characters
422
+ ## are 8-bits in length).
423
+ def cbyte(str, offset)
424
+ return str[offset]
425
+ end
426
+ end
427
+
428
+ ## Parse binary string data and return the first 'Tik "word"
429
+ ## found:
430
+ def get_tikword(data)
431
+ unless data.is_a?(String)
432
+ raise ArgumentError.new("bad argument: expected String but got #{data.class}")
433
+ end
434
+
435
+ ## Be sure we're working in 8-bit binary (Ruby 1.9+):
436
+ if RUBY_VERSION >= '1.9.0'
437
+ data.force_encoding(Encoding::BINARY)
438
+ end
439
+
440
+ unless data.length > 0
441
+ return 0, nil ## Not enough data to parse
442
+ end
443
+
444
+ ## The first byte tells us how the word length is encoded:
445
+ len = 0
446
+ len_byte = cbyte(data, 0)
447
+ if len_byte & 0x80 == 0
448
+ len = len_byte & 0x7f
449
+ i = 1
450
+ elsif len_byte & 0x40 == 0
451
+ unless data.length > 0x81
452
+ return 0, nil ## Not enough data to parse
453
+ end
454
+ len = ((len_byte & 0x3f) << 8) | cbyte(data, 1)
455
+ i = 2
456
+ elsif len_byte & 0x20 == 0
457
+ unless data.length > 0x4002
458
+ return 0, nil ## Not enough data to parse
459
+ end
460
+ len = ((len_byte & 0x1f) << 16) | (cbyte(data, 1) << 8) | cbyte(data, 2)
461
+ i = 3
462
+ elsif len_byte & 0x10 == 0
463
+ unless data.length > 0x200003
464
+ return 0, nil ## Not enough data to parse
465
+ end
466
+ len = ((len_byte & 0x0f) << 24) | (cbyte(data, 1) << 16) | (cbyte(data, 2) << 8) | cbyte(data, 3)
467
+ i = 4
468
+ elsif len_byte == 0xf0
469
+ len = (cbyte(data, 1) << 24) | (cbyte(data, 2) << 16) | (cbyte(data, 3) << 8) | cbyte(data, 4)
470
+ i = 5
471
+ else
472
+ ## This will also catch reserved control words where the first byte is >= 0xf8
473
+ raise ArgumentError.new("bad argument: String length encoding is invalid")
474
+ end
475
+ if data.length - i < len
476
+ return 0, nil ## Not enough data to parse
477
+ end
478
+ return i + len, data[i, len]
479
+ end
480
+
481
+ ## Utility to execute the "/tool/fetch" command, instructing
482
+ ## the device to download a file from the specified URL.
483
+ ## Status updates are provided via the provided callback.
484
+ ## _url_ :: The URL to fetch the file from
485
+ ## _filename_ :: The filename to use on the device
486
+ ## _callback_ :: Callback called for status updates. The three
487
+ ## arguments passed to the callback are:
488
+ ## _status_ :: Either 'downloading', 'connecting',
489
+ ## 'failed', 'requesting', or 'finished',
490
+ ## otherwise a '!trap' error occured,
491
+ ## and the value is the trap message.
492
+ ## _total_ :: Final expected file size in bytes
493
+ ## _bytes_ :: Number of bytes transferred so far
494
+ def fetch(url, filename, &callback)
495
+ total = bytes = 0
496
+ status = ''
497
+ done = false
498
+ req = get_reply_each(
499
+ '/tool/fetch',
500
+ '=url=' + url,
501
+ '=dst-path=' + filename
502
+ ) do |req, s|
503
+ if s.key?('!re') && !done
504
+ unless s.key?('status')
505
+ raise MTik::Error.new("Unknown response to '/tool/fetch': missing 'status' in response.")
506
+ end
507
+ status = s['status']
508
+ case status
509
+ when 'downloading'
510
+ total = s['total'].to_i
511
+ bytes = s['downloaded'].to_i
512
+ callback.call(status, total, bytes)
513
+ when 'connecting', 'requesting'
514
+ callback.call(status, 0, 0)
515
+ when 'failed', 'finished'
516
+ bytes = total if status == 'finished'
517
+ callback.call(status, total, bytes)
518
+ done = true
519
+ ## Now terminate the download request (since it's done):
520
+ get_reply('/cancel', '=tag=' + req.tag) {}
521
+ else
522
+ raise MTik::Error.new("Unknown status in '/tool/fetch' response: '#{status}'")
523
+ end
524
+ elsif s.key?('!trap')
525
+ ## Pass trap message back (unless finished--in which case we
526
+ ## ignore the 'interrrupted' trap message):
527
+ callback.call(s['message'], total, bytes) if !done
528
+ end
529
+ end
530
+ end
531
+
532
+ ## Utility to check and update MikroTik device settings within a
533
+ ## specified subsection of the device.
534
+ def update_values(cmdpath, keyvaluepairs, &callback)
535
+ get_reply_each(cmdpath + '/getall') do |req, s|
536
+ if s.key?('!re')
537
+ ## Iterate over each key/value pair and check if the current
538
+ ## device subsection's "getall" matches one of the keys:
539
+ keyvaluepairs.each do |key, value|
540
+ ## If the key is a String, it matches if the reply sentence
541
+ ## has a matching key. If the key is a Regexp, then iterate
542
+ ## over ALL sentence keys and find all items that match.
543
+ matchedkey = nil
544
+ if key.is_a?(String)
545
+ if s.key?(key)
546
+ matchedkey = key
547
+ end
548
+ elsif key.is_a?(Regexp)
549
+ s.each_key do |skey|
550
+ if key.match(skey)
551
+ matchedkey = skey
552
+ end
553
+ end
554
+ elsif key.is_a(Array)
555
+ ## Iterate over each array item and perform matching on
556
+ ## each String or Regexp therein:
557
+ key.each do |keyitem|
558
+ if keyitem.is_a?(String)
559
+ if s.key?(keyitem)
560
+ matchedkey = keyitem
561
+ end
562
+ elsif keyitem.is_a?(Regexp)
563
+ ## Iterate over each sentence key and test matching
564
+ s.each_key do |skey|
565
+ if key.match(skey)
566
+ ## Check setting's current value:
567
+ if value.is_a?(Proc)
568
+ v = value.call(skey, s[skey])
569
+ elsif value.is_a?(String)
570
+ v = value
571
+ else
572
+ raise MTik::Error.new("Invalid settings value class '#{value}' (expected String or Proc)")
573
+ end
574
+ if s[skey] != v
575
+ ## Update setting from s[skey] to v
576
+ end
577
+ end
578
+ end
579
+ else
580
+ raise MTik::Error.new("Invalid settings match class '#{keyitem}' (expected Regexp or String)")
581
+ end
582
+ end
583
+ else
584
+ raise MTik::Error.new("Invalid settings match class '#{keyitem}' (expected Array, Regexp, or String)")
585
+ end
586
+
587
+ if s.key?(key)
588
+ ## A key matches! && s[k] != v
589
+ oldv = s[k]
590
+ get_reply(cmdpath + '/set', '='+k+'='+v) do |req, s|
591
+ trap = req.reply.find_sentence('!trap')
592
+ unless trap.nil?
593
+ raise MTik::Error.new("Trap while executing '#{cmdpath}/set =#{k}=#{v}': #{trap['message']}")
594
+ end
595
+ callback.call(cmdpath + '/' + k, oldv, v)
596
+ end
597
+ end
598
+ end
599
+ end
600
+ end
601
+ end
602
+ end
603
+