jeffrafter-rubygsm 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,47 @@
1
+ RubyGSM is a Ruby library which uses ruby-serialport[http://github.com/toholio/ruby-serialport]
2
+ to provide a nifty interface to send and receive SMS messages via a GSM modem.
3
+
4
+
5
+ === Sample Usage
6
+
7
+ class ReverseApp
8
+ def initialize(gsm)
9
+ gsm.receive(method(:incoming))
10
+ @gsm = gsm
11
+ end
12
+
13
+ def incoming(from, datetime, message)
14
+ @gsm.send(from, message.reverse)
15
+ end
16
+ end
17
+
18
+ gsm = GsmModem.new
19
+ ReverseApp.new(gsm)
20
+
21
+
22
+ === Installing
23
+ RubyGSM is distributed via GitHub[http://github.com/adammck/rubygsm], which you must
24
+ add as a gem source before installing:
25
+
26
+ $ sudo gem sources -a http://gems.github.com
27
+
28
+ The library depends upon ruby-serialport, which is currently maintained on GitHub by
29
+ Toholio[http://github.com/toholio/ruby-serialport]. This library is included as a Gem
30
+ dependancy, so to install both:
31
+
32
+ $ sudo gem install adammck-rubygsm
33
+
34
+
35
+ ==== For Debian/Ubuntu users...
36
+ The ruby-serialport library is available in the APT repository as a binary package, which
37
+ is substatially less hassle to install than the Gem, for those who don't have a compiler
38
+ installed (I prefer not to, on production servers). To install the package via apt, and
39
+ ignore the Gem dependancy:
40
+
41
+ $ sudo apt-get install libserialport-ruby
42
+ $ sudo gem install --ignore-dependencies adammck-rubygsm
43
+
44
+
45
+ === Devices Tested
46
+ * Multitech MTCBA
47
+ * Wavecom M1306B
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ #require "rubygems"
5
+ #require "rubygsm"
6
+ dir = File.dirname(__FILE__)
7
+ require "#{dir}/../lib/rubygsm.rb"
8
+
9
+
10
+ begin
11
+ modem = Gsm::Modem.new
12
+
13
+ rescue Gsm::Modem::Error => err
14
+ puts "Initialization Error:"
15
+ puts " " + err.desc
16
+ exit
17
+ end
18
+
19
+
20
+ puts "Found modem on port:"
21
+ puts " " + modem.port
22
+ puts
23
+
24
+
25
+ puts "GSM bands supported:"
26
+ puts " " + modem.bands_available.join(", ")
27
+ puts
28
+
29
+
30
+ puts "Currently selected:"
31
+ puts " " + modem.band
32
+ puts
33
+
34
+
35
+ if ARGV[0]
36
+ new_band = ARGV[0].downcase
37
+
38
+ begin
39
+ modem.band = new_band
40
+ puts "Switched to: "
41
+ puts " " + modem.band
42
+
43
+ rescue StandardError => err
44
+ puts "Error switching band: "
45
+ puts " " + err
46
+ end
47
+
48
+ puts
49
+ end
@@ -0,0 +1,851 @@
1
+ #!/usr/bin/env ruby
2
+ #:include:../../README.rdoc
3
+ #:title:Ruby GSM
4
+ #--
5
+ # vim: noet
6
+ #++
7
+
8
+ # standard library
9
+ require "timeout.rb"
10
+ require "date.rb"
11
+
12
+ # gems (we're using the ruby-serialport gem
13
+ # now, so we can depend upon it in our spec)
14
+ require "rubygems"
15
+ require "serialport"
16
+
17
+ module Gsm
18
+ class Modem
19
+ include Timeout
20
+
21
+
22
+ attr_accessor :verbosity, :read_timeout
23
+ attr_reader :device, :port
24
+
25
+ # call-seq:
26
+ # Gsm::Modem.new(port, verbosity=:warn)
27
+ #
28
+ # Create a new instance, to initialize and communicate exclusively with a
29
+ # single modem device via the _port_ (which is usually either /dev/ttyS0
30
+ # or /dev/ttyUSB0), and start logging to *rubygsm.log* in the chdir.
31
+ def initialize(port=:auto, verbosity=:warn, baud=9600, cmd_delay=0.1)
32
+
33
+ # if no port was specified, we'll attempt to iterate
34
+ # all of the serial ports that i've ever seen gsm
35
+ # modems mounted on. this is kind of shaky, and
36
+ # only works well with a single modem. for now,
37
+ # we'll try: ttyS0, ttyUSB0, ttyACM0, ttyS1...
38
+ if port == :auto
39
+ @device, @port = catch(:found) do
40
+ 0.upto(8) do |n|
41
+ ["ttyS", "ttyUSB", "ttyACM"].each do |prefix|
42
+ try_port = "/dev/#{prefix}#{n}"
43
+
44
+ begin
45
+ # serialport args: port, baud, data bits, stop bits, parity
46
+ device = SerialPort.new(try_port, baud, 8, 1, SerialPort::NONE)
47
+ throw :found, [device, try_port]
48
+
49
+ rescue ArgumentError, Errno::ENOENT
50
+ # do nothing, just continue to
51
+ # try the next port in order
52
+ end
53
+ end
54
+ end
55
+
56
+ # tried all ports, nothing worked
57
+ raise AutoDetectError
58
+ end
59
+
60
+ else
61
+ @device = SerialPort.new(port, baud, 8, 1, SerialPort::NONE)
62
+ @port = port
63
+ end
64
+
65
+ @cmd_delay = cmd_delay
66
+ @verbosity = verbosity
67
+ @read_timeout = 10
68
+ @locked_to = false
69
+
70
+ # keep track of the depth which each
71
+ # thread is indented in the log
72
+ @log_indents = {}
73
+ @log_indents.default = 0
74
+
75
+ # to keep multi-part messages until
76
+ # the last part is delivered
77
+ @multipart = {}
78
+
79
+ # (re-) open the full log file
80
+ @log = File.new "rubygsm.log", "w"
81
+
82
+ # initialization message (yes, it's underlined)
83
+ msg = "RubyGSM Initialized at: #{Time.now}"
84
+ log msg + "\n" + ("=" * msg.length), :file
85
+
86
+ # to store incoming messages
87
+ # until they're dealt with by
88
+ # someone else, like a commander
89
+ @incoming = []
90
+
91
+ # initialize the modem
92
+ command "ATE0" # echo off
93
+ #COMPAT command "AT+CMEE=1" # useful errors
94
+ #COMPAT command "AT+WIND=0" # no notifications
95
+ command "AT+CMGF=1" # switch to text mode
96
+ end
97
+
98
+
99
+
100
+
101
+ private
102
+
103
+
104
+ INCOMING_FMT = "%y/%m/%d,%H:%M:%S%Z" #:nodoc:
105
+
106
+ def parse_incoming_timestamp(ts)
107
+ # extract the weirdo quarter-hour timezone,
108
+ # convert it into a regular hourly offset
109
+ ts.sub! /(\d+)$/ do |m|
110
+ sprintf("%02d", (m.to_i/4))
111
+ end
112
+
113
+ # parse the timestamp, and attempt to re-align
114
+ # it according to the timezone we extracted
115
+ DateTime.strptime(ts, INCOMING_FMT)
116
+ end
117
+
118
+ def parse_incoming_sms!(lines)
119
+ n = 0
120
+
121
+ # iterate the lines like it's 1984
122
+ # (because we're patching the array,
123
+ # which is hard work for iterators)
124
+ while n < lines.length
125
+
126
+ # not a CMT string? ignore it
127
+ unless lines && lines[n] && lines[n][0,5] == "+CMT:"
128
+ n += 1
129
+ next
130
+ end
131
+
132
+ # since this line IS a CMT string (an incomming
133
+ # SMS), parse it and store it to deal with later
134
+ unless m = lines[n].match(/^\+CMT: "(.+?)",.*?,"(.+?)".*?$/)
135
+ err = "Couldn't parse CMT data: #{buf}"
136
+ raise RuntimeError.new(err)
137
+ end
138
+
139
+ # extract the meta-info from the CMT line,
140
+ # and the message from the FOLLOWING line
141
+ from, timestamp = *m.captures
142
+ msg = lines[n+1].strip
143
+
144
+ # notify the network that we accepted
145
+ # the incoming message (for read receipt)
146
+ # BEFORE pushing it to the incoming queue
147
+ # (to avoid really ugly race condition if
148
+ # the message is grabbed from the queue
149
+ # and responded to quickly, before we get
150
+ # a chance to issue at+cnma)
151
+ begin
152
+ command "AT+CNMA"
153
+
154
+ # not terribly important if it
155
+ # fails, even though it shouldn't
156
+ rescue Gsm::Error
157
+ log "Receipt acknowledgement (CNMA) was rejected"
158
+ end
159
+
160
+ # we might abort if this is
161
+ catch :skip_processing do
162
+
163
+ # multi-part messages begin with ASCII char 130
164
+ if (msg[0] == 130) and (msg[1].chr == "@")
165
+ text = msg[7,999]
166
+
167
+ # ensure we have a place for the incoming
168
+ # message part to live as they are delivered
169
+ @multipart[from] = []\
170
+ unless @multipart.has_key?(from)
171
+
172
+ # append THIS PART
173
+ @multipart[from].push(text)
174
+
175
+ # add useless message to log
176
+ part = @multipart[from].length
177
+ log "Received part #{part} of message from: #{from}"
178
+
179
+ # abort if this is not the last part
180
+ throw :skip_processing\
181
+ unless (msg[5] == 173)
182
+
183
+ # last part, so switch out the received
184
+ # part with the whole message, to be processed
185
+ # below (the sender and timestamp are the same
186
+ # for all parts, so no change needed there)
187
+ msg = @multipart[from].join("")
188
+ @multipart.delete(from)
189
+ end
190
+
191
+ # just in case it wasn't already obvious...
192
+ log "Received message from #{from}: #{msg}"
193
+
194
+ # store the incoming data to be picked up
195
+ # from the attr_accessor as a tuple (this
196
+ # is kind of ghetto, and WILL change later)
197
+ sent = parse_incoming_timestamp(timestamp)
198
+ @incoming.push Gsm::Incoming.new(self, from, sent, msg)
199
+ end
200
+
201
+ # drop the two CMT lines (meta-info and message),
202
+ # and patch the index to hit the next unchecked
203
+ # line during the next iteration
204
+ lines.slice!(n,2)
205
+ n -= 1
206
+ end
207
+ end
208
+
209
+
210
+ # write a string to the modem immediately,
211
+ # without waiting for the lock
212
+ def write(str)
213
+ log "Write: #{str.inspect}", :traffic
214
+
215
+ begin
216
+ str.each_byte do |b|
217
+ @device.putc(b.chr)
218
+ end
219
+
220
+ # the device couldn't be written to,
221
+ # which probably means that it has
222
+ # crashed or been unplugged
223
+ rescue Errno::EIO
224
+ raise Gsm::WriteError
225
+ end
226
+ end
227
+
228
+
229
+ # read from the modem (blocking) until
230
+ # the term character is hit, and return
231
+ def read(term=nil)
232
+ term = "\r\n" if term==nil
233
+ term = [term] unless term.is_a? Array
234
+ buf = ""
235
+
236
+ # include the terminator in the traffic dump,
237
+ # if it's anything other than the default
238
+ #suffix = (term != ["\r\n"]) ? " (term=#{term.inspect})" : ""
239
+ #log_incr "Read" + suffix, :traffic
240
+
241
+ begin
242
+ timeout(@read_timeout) do
243
+ while true do
244
+ char = @device.getc
245
+
246
+ # die if we couldn't read
247
+ # (nil signifies an error)
248
+ raise Gsm::ReadError\
249
+ if char.nil?
250
+
251
+ # convert the character to ascii,
252
+ # and append it to the tmp buffer
253
+ buf << sprintf("%c", char)
254
+
255
+ # if a terminator was just received,
256
+ # then return the current buffer
257
+ term.each do |t|
258
+ len = t.length
259
+ if buf[-len, len] == t
260
+ log "Read: #{buf.inspect}", :traffic
261
+ return buf.strip
262
+ end
263
+ end
264
+ end
265
+ end
266
+
267
+ # reading took too long, so intercept
268
+ # and raise a more specific exception
269
+ rescue Timeout::Error
270
+ log = "Read: Timed out", :warn
271
+ raise TimeoutError
272
+ end
273
+ end
274
+
275
+
276
+ # issue a single command, and wait for the response
277
+ def command(cmd, resp_term=nil, write_term="\r")
278
+ begin
279
+ out = ""
280
+ log_incr "Command: #{cmd}"
281
+
282
+ exclusive do
283
+ write(cmd + write_term)
284
+ out = wait(resp_term)
285
+ end
286
+
287
+ # some hardware (my motorola phone) adds extra CRLFs
288
+ # to some responses. i see no reason that we need them
289
+ out.delete ""
290
+
291
+ # for the time being, ignore any unsolicited
292
+ # status messages. i can't seem to figure out
293
+ # how to disable them (AT+WIND=0 doesn't work)
294
+ out.delete_if do |line|
295
+ (line[0,6] == "+WIND:") or
296
+ (line[0,6] == "+CREG:") or
297
+ (line[0,7] == "+CGREG:")
298
+ end
299
+
300
+ # parse out any incoming sms that were bundled
301
+ # with this data (to be fetched later by an app)
302
+ parse_incoming_sms!(out)
303
+
304
+ # log the modified output
305
+ log_decr "=#{out.inspect}"
306
+
307
+ # rest up for a bit (modems are
308
+ # slow, and get confused easily)
309
+ sleep(@cmd_delay)
310
+ return out
311
+
312
+ # if the 515 (please wait) error was thrown,
313
+ # then automatically re-try the command after
314
+ # a short delay. for others, propagate
315
+ rescue Error => err
316
+ log "Rescued: #{err.desc}"
317
+
318
+ if (err.type == "CMS") and (err.code == 515)
319
+ sleep 2
320
+ retry
321
+ end
322
+
323
+ log_decr
324
+ raise
325
+ end
326
+ end
327
+
328
+
329
+ def query(cmd)
330
+ log_incr "Query: #{cmd}"
331
+ out = command cmd
332
+
333
+ # only very simple responses are supported
334
+ # (on purpose!) here - [response, crlf, ok]
335
+ if (out.length==2) and (out[1]=="OK")
336
+ log_decr "=#{out[0].inspect}"
337
+ return out[0]
338
+
339
+ else
340
+ err = "Invalid response: #{out.inspect}"
341
+ raise RuntimeError.new(err)
342
+ end
343
+ end
344
+
345
+
346
+ # just wait for a response, by reading
347
+ # until an OK or ERROR terminator is hit
348
+ def wait(term=nil)
349
+ buffer = []
350
+ log_incr "Waiting for response"
351
+
352
+ while true do
353
+ buf = read(term)
354
+ buffer.push(buf)
355
+
356
+ # some errors contain useful error codes,
357
+ # so raise a proper error with a description
358
+ if m = buf.match(/^\+(CM[ES]) ERROR: (\d+)$/)
359
+ log_then_decr "!! Raising Gsm::Error #{$1} #{$2}"
360
+ raise Error.new(*m.captures)
361
+ end
362
+
363
+ # some errors are not so useful :|
364
+ if buf == "ERROR"
365
+ log_then_decr "!! Raising Gsm::Error"
366
+ raise Error
367
+ end
368
+
369
+ # most commands return OK upon success, except
370
+ # for those which prompt for more data (CMGS)
371
+ if (buf=="OK") or (buf==">")
372
+ log_decr "=#{buffer.inspect}"
373
+ return buffer
374
+ end
375
+
376
+ # some commands DO NOT respond with OK,
377
+ # even when they're successful, so check
378
+ # for those exceptions manually
379
+ if m = buf.match(/^\+CPIN: (.+)$/)
380
+ log_decr "=#{buffer.inspect}"
381
+ return buffer
382
+ end
383
+ end
384
+ end
385
+
386
+
387
+ def exclusive &blk
388
+ old_lock = nil
389
+
390
+ begin
391
+
392
+ # prevent other threads from issuing
393
+ # commands TO THIS MODDEM while this
394
+ # block is working. this does not lock
395
+ # threads, just the gsm device
396
+ if @locked_to and (@locked_to != Thread.current)
397
+ log "Locked by #{@locked_to["name"]}, waiting..."
398
+
399
+ # wait for the modem to become available,
400
+ # so we can issue commands from threads
401
+ while @locked_to
402
+ sleep 0.05
403
+ end
404
+ end
405
+
406
+ # we got the lock!
407
+ old_lock = @locked_to
408
+ @locked_to = Thread.current
409
+ log_incr "Got lock"
410
+
411
+ # perform the command while
412
+ # we have exclusive access
413
+ # to the modem device
414
+ yield
415
+
416
+
417
+ # something went bang, which happens, but
418
+ # just pass it on (after unlocking...)
419
+ rescue Gsm::Error
420
+ raise
421
+
422
+
423
+ # no message, but always un-
424
+ # indent subsequent log messages
425
+ # and RELEASE THE LOCK
426
+ ensure
427
+ @locked_to = old_lock
428
+ Thread.pass
429
+ log_decr
430
+ end
431
+ end
432
+
433
+
434
+
435
+
436
+ public
437
+
438
+
439
+ # call-seq:
440
+ # hardware => hash
441
+ #
442
+ # Returns a hash of containing information about the physical
443
+ # modem. The contents of each value are entirely manufacturer
444
+ # dependant, and vary wildly between devices.
445
+ #
446
+ # modem.hardware => { :manufacturer => "Multitech".
447
+ # :model => "MTCBA-G-F4",
448
+ # :revision => "123456789",
449
+ # :serial => "ABCD" }
450
+ def hardware
451
+ return {
452
+ :manufacturer => query("AT+CGMI"),
453
+ :model => query("AT+CGMM"),
454
+ :revision => query("AT+CGMR"),
455
+ :serial => query("AT+CGSN") }
456
+ end
457
+
458
+
459
+ # The values accepted and returned by the AT+WMBS
460
+ # command, mapped to frequency bands, in MHz. Copied
461
+ # directly from the MultiTech AT command-set reference
462
+ Bands = {
463
+ 0 => "850",
464
+ 1 => "900",
465
+ 2 => "1800",
466
+ 3 => "1900",
467
+ 4 => "850/1900",
468
+ 5 => "900E/1800",
469
+ 6 => "900E/1900"
470
+ }
471
+
472
+ # call-seq:
473
+ # bands_available => array
474
+ #
475
+ # Returns an array containing the bands supported by
476
+ # the modem.
477
+ def bands_available
478
+ data = query("AT+WMBS=?")
479
+
480
+ # wmbs data is returned as something like:
481
+ # +WMBS: (0,1,2,3,4,5,6),(0-1)
482
+ # +WMBS: (0,3,4),(0-1)
483
+ # extract the numbers with a regex, and
484
+ # iterate each to resolve it to a more
485
+ # readable description
486
+ if m = data.match(/^\+WMBS: \(([\d,]+)\),/)
487
+ return m.captures[0].split(",").collect do |index|
488
+ Bands[index.to_i]
489
+ end
490
+
491
+ else
492
+ # Todo: Recover from this exception
493
+ err = "Not WMBS data: #{data.inspect}"
494
+ raise RuntimeError.new(err)
495
+ end
496
+ end
497
+
498
+ # call-seq:
499
+ # band => string
500
+ #
501
+ # Returns a string containing the band
502
+ # currently selected for use by the modem.
503
+ def band
504
+ data = query("AT+WMBS?")
505
+ if m = data.match(/^\+WMBS: (\d+),/)
506
+ return Bands[m.captures[0].to_i]
507
+
508
+ else
509
+ # Todo: Recover from this exception
510
+ err = "Not WMBS data: #{data.inspect}"
511
+ raise RuntimeError.new(err)
512
+ end
513
+ end
514
+
515
+ BandAreas = {
516
+ :usa => 4,
517
+ :africa => 5,
518
+ :europe => 5,
519
+ :asia => 5,
520
+ :mideast => 5
521
+ }
522
+
523
+ # call-seq:
524
+ # band=(_numeric_band_) => string
525
+ #
526
+ # Sets the band currently selected for use
527
+ # by the modem, using either a literal band
528
+ # number (passed directly to the modem, see
529
+ # Gsm::Modem.Bands) or a named area from
530
+ # Gsm::Modem.BandAreas:
531
+ #
532
+ # m = Gsm::Modem.new
533
+ # m.band = :usa => "850/1900"
534
+ # m.band = :africa => "900E/1800"
535
+ # m.band = :monkey => ArgumentError
536
+ #
537
+ # (Note that as usual, the United States of
538
+ # America is wearing its ass backwards.)
539
+ #
540
+ # Raises ArgumentError if an unrecognized band was
541
+ # given, or raises Gsm::Error if the modem does
542
+ # not support the given band.
543
+ def band=(new_band)
544
+
545
+ # resolve named bands into numeric
546
+ # (mhz values first, then band areas)
547
+ unless new_band.is_a?(Numeric)
548
+
549
+ if Bands.has_value?(new_band.to_s)
550
+ new_band = Bands.index(new_band.to_s)
551
+
552
+ elsif BandAreas.has_key?(new_band.to_sym)
553
+ new_band = BandAreas[new_band.to_sym]
554
+
555
+ else
556
+ err = "Invalid band: #{new_band}"
557
+ raise ArgumentError.new(err)
558
+ end
559
+ end
560
+
561
+ # set the band right now (second wmbs
562
+ # argument is: 0=NEXT-BOOT, 1=NOW). if it
563
+ # fails, allow Gsm::Error to propagate
564
+ command("AT+WMBS=#{new_band},1")
565
+ end
566
+
567
+ # call-seq:
568
+ # pin_required? => true or false
569
+ #
570
+ # Returns true if the modem is waiting for a SIM PIN. Some SIM cards will refuse
571
+ # to work until the correct four-digit PIN is provided via the _use_pin_ method.
572
+ def pin_required?
573
+ not command("AT+CPIN?").include?("+CPIN: READY")
574
+ end
575
+
576
+
577
+ # call-seq:
578
+ # use_pin(pin) => true or false
579
+ #
580
+ # Provide a SIM PIN to the modem, and return true if it was accepted.
581
+ def use_pin(pin)
582
+
583
+ # if the sim is already ready,
584
+ # this method isn't necessary
585
+ if pin_required?
586
+ begin
587
+ command "AT+CPIN=#{pin}"
588
+
589
+ # if the command failed, then
590
+ # the pin was not accepted
591
+ rescue Gsm::Error
592
+ return false
593
+ end
594
+ end
595
+
596
+ # no error = SIM
597
+ # PIN accepted!
598
+ true
599
+ end
600
+
601
+
602
+ # call-seq:
603
+ # signal => fixnum or nil
604
+ #
605
+ # Returns an fixnum between 1 and 99, representing the current
606
+ # signal strength of the GSM network, or nil if we don't know.
607
+ def signal_strength
608
+ data = query("AT+CSQ")
609
+ if m = data.match(/^\+CSQ: (\d+),/)
610
+
611
+ # 99 represents "not known or not detectable",
612
+ # but we'll use nil for that, since it's a bit
613
+ # more ruby-ish to test for boolean equality
614
+ csq = m.captures[0].to_i
615
+ return (csq<99) ? csq : nil
616
+
617
+ else
618
+ # Todo: Recover from this exception
619
+ err = "Not CSQ data: #{data.inspect}"
620
+ raise RuntimeError.new(err)
621
+ end
622
+ end
623
+
624
+
625
+ # call-seq:
626
+ # wait_for_network
627
+ #
628
+ # Blocks until the signal strength indicates that the
629
+ # device is active on the GSM network. It's a good idea
630
+ # to call this before trying to send or receive anything.
631
+ def wait_for_network
632
+
633
+ # keep retrying until the
634
+ # network comes up (if ever)
635
+ until csq = signal_strength
636
+ sleep 1
637
+ end
638
+
639
+ # return the last
640
+ # signal strength
641
+ return csq
642
+ end
643
+
644
+
645
+ # call-seq:
646
+ # send_sms(message) => true or false
647
+ # send_sms(recipient, text) => true or false
648
+ #
649
+ # Sends an SMS message, and returns true if the network
650
+ # accepted it for delivery. We currently can't handle read
651
+ # receipts, so have no way of confirming delivery.
652
+ #
653
+ # Note: the recipient is passed directly to the modem, which
654
+ # in turn passes it straight to the SMSC (sms message center).
655
+ # for maximum compatibility, use phone numbers in international
656
+ # format, including the *plus* and *country code*.
657
+ def send_sms(*args)
658
+
659
+ # extract values from Outgoing object.
660
+ # for now, this does not offer anything
661
+ # in addition to the recipient/text pair,
662
+ # but provides an upgrade path for future
663
+ # features (like FLASH and VALIDITY TIME)
664
+ if args.length == 1\
665
+ and args[0].is_a? Gsm::Outgoing
666
+ to = args[0].recipient
667
+ msg = args[0].text
668
+
669
+ # the < v0.4 arguments. maybe
670
+ # deprecate this one day
671
+ elsif args.length == 2
672
+ to, msg = *args
673
+
674
+ else
675
+ raise ArgumentError,\
676
+ "The Gsm::Modem#send_sms method accepts" +\
677
+ "a single Gsm::Outgoing instance, " +\
678
+ "or recipient and text strings"
679
+ end
680
+
681
+ # the number must be in the international
682
+ # format for some SMSCs (notably, the one
683
+ # i'm on right now) so maybe add a PLUS
684
+ #to = "+#{to}" unless(to[0,1]=="+")
685
+
686
+ # 1..9 is a special number which does notm
687
+ # result in a real sms being sent (see inject.rb)
688
+ if to == "+123456789"
689
+ log "Not sending test message: #{msg}"
690
+ return false
691
+ end
692
+
693
+ # block the receiving thread while
694
+ # we're sending. it can take some time
695
+ exclusive do
696
+ log_incr "Sending SMS to #{to}: #{msg}"
697
+
698
+ # initiate the sms, and wait for either
699
+ # the text prompt or an error message
700
+ command "AT+CMGS=\"#{to}\"", ["\r\n", "> "]
701
+
702
+ begin
703
+ # send the sms, and wait until
704
+ # it is accepted or rejected
705
+ write "#{msg}#{26.chr}"
706
+ wait
707
+
708
+ # if something went wrong, we are
709
+ # be stuck in entry mode (which will
710
+ # result in someone getting a bunch
711
+ # of AT commands via sms!) so send
712
+ # an escpae, to... escape
713
+ rescue Exception, Timeout::Error => err
714
+ log "Rescued #{err.desc}"
715
+ return false
716
+ #write 27.chr
717
+ #wait
718
+ end
719
+
720
+ log_decr
721
+ end
722
+
723
+ # if no error was raised,
724
+ # then the message was sent
725
+ return true
726
+ end
727
+
728
+
729
+ # call-seq:
730
+ # receive(callback_method, interval=5, join_thread=false)
731
+ #
732
+ # Starts a new thread, which polls the device every _interval_
733
+ # seconds to capture incoming SMS and call _callback_method_
734
+ # for each.
735
+ #
736
+ # class Receiver
737
+ # def incoming(msg)
738
+ # puts "From #{msg.from} at #{msg.sent}:", msg.text
739
+ # end
740
+ # end
741
+ #
742
+ # # create the instances,
743
+ # # and start receiving
744
+ # rcv = Receiver.new
745
+ # m = Gsm::Modem.new "/dev/ttyS0"
746
+ # m.receive rcv.method :incoming
747
+ #
748
+ # # block until ctrl+c
749
+ # while(true) { sleep 2 }
750
+ #
751
+ # Note: New messages may arrive at any time, even if this method's
752
+ # receiver thread isn't waiting to process them. They are not lost,
753
+ # but cached in @incoming until this method is called.
754
+ def receive(callback, interval=5, join_thread=false)
755
+ @polled = 0
756
+
757
+ @thr = Thread.new do
758
+ Thread.current["name"] = "receiver"
759
+
760
+ # keep on receiving forever
761
+ while true
762
+ command "AT"
763
+ check_for_inbox_messages
764
+
765
+ # enable new message notification mode
766
+ # every ten intevals, in case the
767
+ # modem "forgets" (power cycle, etc)
768
+ if (@polled % 10) == 0
769
+ #COMPAT command "AT+CNMI=2,2,0,0,0"
770
+ end
771
+
772
+ # if there are any new incoming messages,
773
+ # iterate, and pass each to the receiver
774
+ # in the same format that they were built
775
+ # back in _parse_incoming_sms!_
776
+ unless @incoming.empty?
777
+ @incoming.each do |msg|
778
+ begin
779
+ callback.call(msg)
780
+
781
+ rescue StandardError => err
782
+ log "Error in callback: #{err}"
783
+ end
784
+ end
785
+
786
+ # we have dealt with all of the pending
787
+ # messages. todo: this is a ridiculous
788
+ # race condition, and i fail at ruby
789
+ @incoming.clear
790
+ end
791
+
792
+ # re-poll every
793
+ # five seconds
794
+ sleep(interval)
795
+ @polled += 1
796
+ end
797
+ end
798
+
799
+ # it's sometimes handy to run single-
800
+ # threaded (like debugging handsets)
801
+ @thr.join if join_thread
802
+ end
803
+
804
+ def select_default_mailbox
805
+ # Eventually we will select the first mailbox as the default
806
+ result = command("AT+CPMS=?")
807
+ boxes = result.first.scan(/\"(\w+)\"/).flatten #"
808
+ mailbox = boxes.first
809
+ command "AT+CPMS=\"#{mailbox}\""
810
+ mailbox
811
+ rescue
812
+ raise RuntimeError.new("Could not select the default mailbox")
813
+ nil
814
+ end
815
+
816
+ # Could accomplish this with a CMGL=\"REC UNREAD\" too
817
+ def check_for_inbox_messages
818
+ return unless select_default_mailbox
819
+
820
+ # Try to read the first message from the box (should this be 0?)
821
+ begin
822
+ out = command("AT+CMGR=1")
823
+ rescue
824
+ return
825
+ end
826
+ return if out.nil? || out.empty?
827
+
828
+ # Delete that message
829
+ command("AT+CMGD=1")
830
+
831
+ # Break that down
832
+ header = out.first
833
+ msg = out[1..out.size-2].join("\n")
834
+ validity = out.last
835
+
836
+ # Read the header
837
+ header = header.scan(/\"([^"]+)\"/).flatten #"
838
+ status = header[0]
839
+ from = header[1]
840
+ timestamp = header[2]
841
+ # Parsing this using the incoming format failed, it need %Y instead of %y
842
+ sent = DateTime.parse(timestamp)
843
+
844
+ # just in case it wasn't already obvious...
845
+ log "Received message from #{from}: #{msg}"
846
+
847
+ @incoming.push Gsm::Incoming.new(self, from, sent, msg)
848
+ end
849
+
850
+ end # Modem
851
+ end # Gsm
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env ruby
2
+ #:title:Ruby GSM Errors
3
+ #--
4
+ # vim: noet
5
+ #++
6
+
7
+ module Gsm
8
+ class Error < StandardError
9
+ ERRORS = {
10
+ "CME" => {
11
+ 3 => "Operation not allowed",
12
+ 4 => "Operation not supported",
13
+ 5 => "PH-SIM PIN required (SIM lock)",
14
+ 10 => "SIM not inserted",
15
+ 11 => "SIM PIN required",
16
+ 12 => "SIM PUK required",
17
+ 13 => "SIM failure",
18
+ 16 => "Incorrect password",
19
+ 17 => "SIM PIN2 required",
20
+ 18 => "SIM PUK2 required",
21
+ 20 => "Memory full",
22
+ 21 => "Invalid index",
23
+ 22 => "Not found",
24
+ 24 => "Text string too long",
25
+ 26 => "Dial string too long",
26
+ 27 => "Invalid characters in dial string",
27
+ 30 => "No network service",
28
+ 32 => "Network not allowed – emergency calls only",
29
+ 40 => "Network personal PIN required (Network lock)",
30
+ 103 => "Illegal MS (#3)",
31
+ 106 => "Illegal ME (#6)",
32
+ 107 => "GPRS services not allowed",
33
+ 111 => "PLMN not allowed",
34
+ 112 => "Location area not allowed",
35
+ 113 => "Roaming not allowed in this area",
36
+ 132 => "Service option not supported",
37
+ 133 => "Requested service option not subscribed",
38
+ 134 => "Service option temporarily out of order",
39
+ 148 => "unspecified GPRS error",
40
+ 149 => "PDP authentication failure",
41
+ 150 => "Invalid mobile class"
42
+ },
43
+
44
+ # message service errors
45
+ "CMS" => {
46
+ 301 => "SMS service of ME reserved",
47
+ 302 => "Operation not allowed",
48
+ 303 => "Operation not supported",
49
+ 304 => "Invalid PDU mode parameter",
50
+ 305 => "Invalid text mode parameter",
51
+ 310 => "SIM not inserted",
52
+ 311 => "SIM PIN required",
53
+ 312 => "PH-SIM PIN required",
54
+ 313 => "SIM failure",
55
+ 316 => "SIM PUK required",
56
+ 317 => "SIM PIN2 required",
57
+ 318 => "SIM PUK2 required",
58
+ 321 => "Invalid memory index",
59
+ 322 => "SIM memory full",
60
+ 330 => "SC address unknown",
61
+ 340 => "No +CNMA acknowledgement expected",
62
+
63
+ # specific error result codes (also from +CMS ERROR)
64
+ 500 => "Unknown error",
65
+ 512 => "MM establishment failure (for SMS)",
66
+ 513 => "Lower layer failure (for SMS)",
67
+ 514 => "CP error (for SMS)",
68
+ 515 => "Please wait, init or command processing in progress",
69
+ 517 => "SIM Toolkit facility not supported",
70
+ 518 => "SIM Toolkit indication not received",
71
+ 519 => "Reset product to activate or change new echo cancellation algo",
72
+ 520 => "Automatic abort about get PLMN list for an incomming call",
73
+ 526 => "PIN deactivation forbidden with this SIM card",
74
+ 527 => "Please wait, RR or MM is busy. Retry your selection later",
75
+ 528 => "Location update failure. Emergency calls only",
76
+ 529 => "PLMN selection failure. Emergency calls only",
77
+ 531 => "SMS not send: the <da> is not in FDN phonebook, and FDN lock is enabled (for SMS)"
78
+ }
79
+ }
80
+
81
+ attr_reader :type, :code
82
+ def initialize(type=nil, code=nil)
83
+ @code = code.to_i
84
+ @type = type
85
+ end
86
+
87
+ def desc
88
+ # attempt to return something useful
89
+ return(ERRORS[@type][@code])\
90
+ if(@type and ERRORS[@type] and @code and ERRORS[@type][@code])
91
+
92
+ # fall back to something not-so useful
93
+ return "Unknown error (unrecognized command?) " +\
94
+ "[type=#{@type}] [code=#{code}]"
95
+ end
96
+
97
+ alias :to_s :desc
98
+ end
99
+
100
+ # TODO: what the hell is going on with
101
+ # all these "desc" methods? refactor this
102
+
103
+ class TimeoutError < Error #:nodoc:
104
+ def desc
105
+ "The command timed out"
106
+ end
107
+ end
108
+
109
+ class WriteError < Error #:nodoc:
110
+ def desc
111
+ "The modem couldn't be written to. It " +\
112
+ "may have crashed or been unplugged"
113
+ end
114
+ end
115
+
116
+ class ReadError < Error #:nodoc:
117
+ def desc
118
+ "The modem couldn't be read from. It " +\
119
+ "may have crashed or been unplugged"
120
+ end
121
+ end
122
+
123
+ class AutoDetectError < Error #:nodoc:
124
+ def desc
125
+ "No modem could be auto-detected."
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ module Gsm
5
+ class Modem
6
+ private
7
+
8
+ # Symbols accepted by the Gsm::Modem.new _verbosity_
9
+ # argument. Each level includes all of the levels
10
+ # below it (ie. :debug includes all :warn messages)
11
+ LOG_LEVELS = {
12
+ :file => 5,
13
+ :traffic => 4,
14
+ :debug => 3,
15
+ :warn => 2,
16
+ :error => 1 }
17
+
18
+ def log(msg, level=:debug)
19
+ ind = " " * (@log_indents[Thread.current] or 0)
20
+
21
+ # create a
22
+ thr = Thread.current["name"]
23
+ thr = (thr.nil?) ? "" : "[#{thr}] "
24
+
25
+ # dump (almost) everything to file
26
+ if LOG_LEVELS[level] >= LOG_LEVELS[:debug]\
27
+ or level == :file
28
+
29
+ @log.puts thr + ind + msg
30
+ @log.flush
31
+ end
32
+
33
+ # also print to the rolling
34
+ # screen log, if necessary
35
+ if LOG_LEVELS[@verbosity] >= LOG_LEVELS[level]
36
+ $stderr.puts thr + ind + msg
37
+ end
38
+ end
39
+
40
+ # log a message, and increment future messages
41
+ # in this thread. useful for nesting logic
42
+ def log_incr(*args)
43
+ log(*args) unless args.empty?
44
+ @log_indents[Thread.current] += 1
45
+ end
46
+
47
+ # close the logical block, and (optionally) log
48
+ def log_decr(*args)
49
+ @log_indents[Thread.current] -= 1\
50
+ if @log_indents[Thread.current] > 0
51
+ log(*args) unless args.empty?
52
+ end
53
+
54
+ # the last message in a logical block
55
+ def log_then_decr(*args)
56
+ log(*args)
57
+ log_decr
58
+ end
59
+ end # Modem
60
+ end # Gsm
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ module Gsm
5
+ class Incoming
6
+ attr_reader :device, :sender, :sent, :received, :text
7
+
8
+ def initialize(device, sender, sent, text)
9
+
10
+ # move all arguments into read-only
11
+ # attributes. ugly, but Struct only
12
+ # supports read/write attrs
13
+ @device = device
14
+ @sender = sender
15
+ @sent = sent
16
+ @text = text
17
+
18
+ # assume that the message was
19
+ # received right now, since we
20
+ # don't have an incoming buffer
21
+ @received = Time.now
22
+ end
23
+
24
+ def to_a
25
+ [@device, @sender, @sent, @text]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ module Gsm
5
+ class Outgoing
6
+ attr_accessor :recipient, :text
7
+ attr_reader :device, :sent
8
+
9
+ def initialize(device, recipient=nil, text=nil)
10
+
11
+ # check that the device is
12
+ #raise ArgumentError, "Invalid device"\
13
+ # unless device.respond_to?(:send_sms)
14
+
15
+ # init the instance vars
16
+ @recipient = recipient
17
+ @device = device
18
+ @text = text
19
+ end
20
+
21
+ def send!
22
+ @device.send_sms(self)
23
+ @sent = Time.now
24
+
25
+ # once sent, allow no
26
+ # more modifications
27
+ freeze
28
+ end
29
+ end
30
+ end
data/lib/rubygsm.rb ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: noet
3
+
4
+ dir = File.dirname(__FILE__)
5
+ require "#{dir}/rubygsm/core.rb"
6
+ require "#{dir}/rubygsm/errors.rb"
7
+ require "#{dir}/rubygsm/log.rb"
8
+
9
+ # messages are now passed around
10
+ # using objects, rather than flat
11
+ # arguments (from, time, msg, etc)
12
+ require "#{dir}/rubygsm/msg/incoming.rb"
13
+ require "#{dir}/rubygsm/msg/outgoing.rb"
14
+
15
+ # during development, it's important to EXPLODE
16
+ # as early as possible when something goes wrong
17
+ Thread.abort_on_exception = true
18
+ Thread.current["name"] = "main"
data/rubygsm.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = "rubygsm"
3
+ s.version = "0.3.1"
4
+ s.date = "2009-01-09"
5
+ s.summary = "Send and receive SMS with a GSM modem"
6
+ s.email = "adam.mckaig@gmail.com"
7
+ s.homepage = "http://github.com/adammck/rubygsm"
8
+ s.authors = ["Adam Mckaig"]
9
+ s.has_rdoc = true
10
+
11
+ s.files = [
12
+ "rubygsm.gemspec",
13
+ "README.rdoc",
14
+ "lib/rubygsm.rb",
15
+ "lib/rubygsm/core.rb",
16
+ "lib/rubygsm/errors.rb",
17
+ "lib/rubygsm/log.rb",
18
+ "lib/rubygsm/msg/incoming.rb",
19
+ "lib/rubygsm/msg/outgoing.rb",
20
+ "bin/gsm-modem-band"
21
+ ]
22
+
23
+ s.executables = [
24
+ "gsm-modem-band"
25
+ ]
26
+
27
+ s.add_dependency("toholio-serialport", ["> 0.7.1"])
28
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jeffrafter-rubygsm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.1
5
+ platform: ruby
6
+ authors:
7
+ - Adam Mckaig
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-09 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: toholio-serialport
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">"
21
+ - !ruby/object:Gem::Version
22
+ version: 0.7.1
23
+ version:
24
+ description:
25
+ email: adam.mckaig@gmail.com
26
+ executables:
27
+ - gsm-modem-band
28
+ extensions: []
29
+
30
+ extra_rdoc_files: []
31
+
32
+ files:
33
+ - rubygsm.gemspec
34
+ - README.rdoc
35
+ - lib/rubygsm.rb
36
+ - lib/rubygsm/core.rb
37
+ - lib/rubygsm/errors.rb
38
+ - lib/rubygsm/log.rb
39
+ - lib/rubygsm/msg/incoming.rb
40
+ - lib/rubygsm/msg/outgoing.rb
41
+ - bin/gsm-modem-band
42
+ has_rdoc: true
43
+ homepage: http://github.com/adammck/rubygsm
44
+ post_install_message:
45
+ rdoc_options: []
46
+
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: "0"
60
+ version:
61
+ requirements: []
62
+
63
+ rubyforge_project:
64
+ rubygems_version: 1.2.0
65
+ signing_key:
66
+ specification_version: 2
67
+ summary: Send and receive SMS with a GSM modem
68
+ test_files: []
69
+