mtik 3.0.2

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