jeffrafter-rubygsm 0.3.1

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