adammck-rubygsm 0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +47 -0
- data/lib/rubygsm.rb +6 -0
- data/lib/rubygsm/core.rb +687 -0
- data/lib/rubygsm/errors.rb +117 -0
- data/lib/rubygsm/log.rb +59 -0
- data/rubygsm.gemspec +21 -0
- metadata +66 -0
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
|
+
# included in bin/gsm-app-reverse
|
7
|
+
|
8
|
+
class ReverseApp
|
9
|
+
def initialize(gsm)
|
10
|
+
gsm.receive(method(:incoming))
|
11
|
+
@gsm = gsm
|
12
|
+
end
|
13
|
+
|
14
|
+
def incoming(from, datetime, message)
|
15
|
+
@gsm.send(from, message.reverse)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
gsm = GsmModem.new("/dev/ttyS0")
|
20
|
+
ReverseApp.new(gsm)
|
21
|
+
|
22
|
+
|
23
|
+
=== Installing
|
24
|
+
RubyGSM is distributed via GitHub[http://github.com/adammck/rubygsm], which you must
|
25
|
+
add as a gem source before installing:
|
26
|
+
|
27
|
+
$ sudo gem sources -a http://gems.github.com
|
28
|
+
|
29
|
+
The library depends upon ruby-serialport, which is currently maintained on GitHub by
|
30
|
+
Toholio[http://github.com/toholio/ruby-serialport]. This library is included as a Gem
|
31
|
+
dependancy, so to install both:
|
32
|
+
|
33
|
+
$ sudo gem install adammck-rubygsm
|
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
|
data/lib/rubygsm.rb
ADDED
data/lib/rubygsm/core.rb
ADDED
@@ -0,0 +1,687 @@
|
|
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
|
+
|
18
|
+
class GsmModem
|
19
|
+
include Timeout
|
20
|
+
|
21
|
+
|
22
|
+
attr_accessor :verbosity, :read_timeout
|
23
|
+
attr_reader :device
|
24
|
+
|
25
|
+
# call-seq:
|
26
|
+
# GsmModem.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, verbosity=:warn, baud=9600, cmd_delay=0.1)
|
32
|
+
|
33
|
+
# port, baud, data bits, stop bits, parity
|
34
|
+
@device = SerialPort.new(port, baud, 8, 1, SerialPort::NONE)
|
35
|
+
|
36
|
+
@cmd_delay = cmd_delay
|
37
|
+
@verbosity = verbosity
|
38
|
+
@read_timeout = 10
|
39
|
+
@locked_to = false
|
40
|
+
|
41
|
+
# keep track of the depth which each
|
42
|
+
# thread is indented in the log
|
43
|
+
@log_indents = {}
|
44
|
+
@log_indents.default = 0
|
45
|
+
|
46
|
+
# to keep multi-part messages until
|
47
|
+
# the last part is delivered
|
48
|
+
@multipart = {}
|
49
|
+
|
50
|
+
# (re-) open the full log file
|
51
|
+
@log = File.new "rubygsm.log", "w"
|
52
|
+
|
53
|
+
# initialization message (yes, it's underlined)
|
54
|
+
msg = "RubyGSM Initialized at: #{Time.now}"
|
55
|
+
log msg + "\n" + ("=" * msg.length), :file
|
56
|
+
|
57
|
+
# to store incoming messages
|
58
|
+
# until they're dealt with by
|
59
|
+
# someone else, like a commander
|
60
|
+
@incoming = []
|
61
|
+
|
62
|
+
# initialize the modem
|
63
|
+
command "ATE0" # echo off
|
64
|
+
command "AT+CMEE=1" # useful errors
|
65
|
+
command "AT+WIND=0" # no notifications
|
66
|
+
command "AT+CMGF=1" # switch to text mode
|
67
|
+
end
|
68
|
+
|
69
|
+
|
70
|
+
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
|
75
|
+
INCOMING_FMT = "%y/%m/%d,%H:%M:%S%Z" #:nodoc:
|
76
|
+
|
77
|
+
def parse_incoming_timestamp(ts)
|
78
|
+
# extract the weirdo quarter-hour timezone,
|
79
|
+
# convert it into a regular hourly offset
|
80
|
+
ts.sub! /(\d+)$/ do |m|
|
81
|
+
sprintf("%02d", (m.to_i/4))
|
82
|
+
end
|
83
|
+
|
84
|
+
# parse the timestamp, and attempt to re-align
|
85
|
+
# it according to the timezone we extracted
|
86
|
+
DateTime.strptime(ts, INCOMING_FMT)
|
87
|
+
end
|
88
|
+
|
89
|
+
def parse_incoming_sms!(lines)
|
90
|
+
n = 0
|
91
|
+
|
92
|
+
# iterate the lines like it's 1984
|
93
|
+
# (because we're patching the array,
|
94
|
+
# which is hard work for iterators)
|
95
|
+
while n < lines.length
|
96
|
+
|
97
|
+
# not a CMT string? ignore it
|
98
|
+
unless lines && lines[n] && lines[n][0,5] == "+CMT:"
|
99
|
+
n += 1
|
100
|
+
next
|
101
|
+
end
|
102
|
+
|
103
|
+
# since this line IS a CMT string (an incomming
|
104
|
+
# SMS), parse it and store it to deal with later
|
105
|
+
unless m = lines[n].match(/^\+CMT: "(.+?)",.*?,"(.+?)".*?$/)
|
106
|
+
err = "Couldn't parse CMT data: #{buf}"
|
107
|
+
raise RuntimeError.new(err)
|
108
|
+
end
|
109
|
+
|
110
|
+
# extract the meta-info from the CMT line,
|
111
|
+
# and the message from the FOLLOWING line
|
112
|
+
from, timestamp = *m.captures
|
113
|
+
msg = lines[n+1].strip
|
114
|
+
|
115
|
+
# notify the network that we accepted
|
116
|
+
# the incoming message (for read receipt)
|
117
|
+
# BEFORE pushing it to the incoming queue
|
118
|
+
# (to avoid really ugly race condition)
|
119
|
+
command "AT+CNMA"
|
120
|
+
|
121
|
+
# we might abort if this is
|
122
|
+
catch :skip_processing do
|
123
|
+
|
124
|
+
# multi-part messages begin with ASCII char 130
|
125
|
+
if (msg[0] == 130) and (msg[1].chr == "@")
|
126
|
+
text = msg[7,999]
|
127
|
+
|
128
|
+
# ensure we have a place for the incoming
|
129
|
+
# message part to live as they are delivered
|
130
|
+
@multipart[from] = []\
|
131
|
+
unless @multipart.has_key?(from)
|
132
|
+
|
133
|
+
# append THIS PART
|
134
|
+
@multipart[from].push(text)
|
135
|
+
|
136
|
+
# add useless message to log
|
137
|
+
part = @multipart[from].length
|
138
|
+
log "Received part #{part} of message from: #{from}"
|
139
|
+
|
140
|
+
# abort if this is not the last part
|
141
|
+
throw :skip_processing\
|
142
|
+
unless (msg[5] == 173)
|
143
|
+
|
144
|
+
# last part, so switch out the received
|
145
|
+
# part with the whole message, to be processed
|
146
|
+
# below (the sender and timestamp are the same
|
147
|
+
# for all parts, so no change needed there)
|
148
|
+
msg = @multipart[from].join("")
|
149
|
+
@multipart.delete(from)
|
150
|
+
end
|
151
|
+
|
152
|
+
# just in case it wasn't already obvious...
|
153
|
+
log "Received message from #{from}: #{msg}"
|
154
|
+
|
155
|
+
# store the incoming data to be picked up
|
156
|
+
# from the attr_accessor as a tuple (this
|
157
|
+
# is kind of ghetto, and WILL change later)
|
158
|
+
dt = parse_incoming_timestamp(timestamp)
|
159
|
+
@incoming.push [from, dt, msg]
|
160
|
+
end
|
161
|
+
|
162
|
+
# drop the two CMT lines (meta-info and message),
|
163
|
+
# and patch the index to hit the next unchecked
|
164
|
+
# line during the next iteration
|
165
|
+
lines.slice!(n,2)
|
166
|
+
n -= 1
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
|
171
|
+
# write a string to the modem immediately,
|
172
|
+
# without waiting for the lock
|
173
|
+
def write(str)
|
174
|
+
log "Write: #{str.inspect}", :traffic
|
175
|
+
|
176
|
+
begin
|
177
|
+
str.each_byte do |b|
|
178
|
+
@device.putc(b.chr)
|
179
|
+
end
|
180
|
+
|
181
|
+
# the device couldn't be written to,
|
182
|
+
# which probably means that it has
|
183
|
+
# crashed or been unplugged
|
184
|
+
rescue Errno::EIO
|
185
|
+
raise GsmModem::WriteError
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
|
190
|
+
# read from the modem (blocking) until
|
191
|
+
# the term character is hit, and return
|
192
|
+
def read(term=nil)
|
193
|
+
term = "\r\n" if term==nil
|
194
|
+
term = [term] unless term.is_a? Array
|
195
|
+
buf = ""
|
196
|
+
|
197
|
+
# include the terminator in the traffic dump,
|
198
|
+
# if it's anything other than the default
|
199
|
+
#suffix = (term != ["\r\n"]) ? " (term=#{term.inspect})" : ""
|
200
|
+
#log_incr "Read" + suffix, :traffic
|
201
|
+
|
202
|
+
begin
|
203
|
+
timeout(@read_timeout) do
|
204
|
+
while true do
|
205
|
+
char = @device.getc
|
206
|
+
|
207
|
+
# die if we couldn't read
|
208
|
+
# (nil signifies an error)
|
209
|
+
raise GsmModem::ReadError\
|
210
|
+
if char.nil?
|
211
|
+
|
212
|
+
# convert the character to ascii,
|
213
|
+
# and append it to the tmp buffer
|
214
|
+
buf << sprintf("%c", char)
|
215
|
+
|
216
|
+
# if a terminator was just received,
|
217
|
+
# then return the current buffer
|
218
|
+
term.each do |t|
|
219
|
+
len = t.length
|
220
|
+
if buf[-len, len] == t
|
221
|
+
log "Read: #{buf.inspect}", :traffic
|
222
|
+
return buf.strip
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# reading took too long, so intercept
|
229
|
+
# and raise a more specific exception
|
230
|
+
rescue Timeout::Error
|
231
|
+
log = "Read: Timed out", :warn
|
232
|
+
raise TimeoutError
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
|
237
|
+
# issue a single command, and wait for the response
|
238
|
+
def command(cmd, resp_term=nil, write_term="\r")
|
239
|
+
begin
|
240
|
+
out = ""
|
241
|
+
log_incr "Command: #{cmd}"
|
242
|
+
|
243
|
+
exclusive do
|
244
|
+
write(cmd + write_term)
|
245
|
+
out = wait(resp_term)
|
246
|
+
end
|
247
|
+
|
248
|
+
# some hardware (my motorola phone) adds extra CRLFs
|
249
|
+
# to some responses. i see no reason that we need them
|
250
|
+
out.delete ""
|
251
|
+
|
252
|
+
# for the time being, ignore any unsolicited
|
253
|
+
# status messages. i can't seem to figure out
|
254
|
+
# how to disable them (AT+WIND=0 doesn't work)
|
255
|
+
out.delete_if do |line|
|
256
|
+
(line[0,6] == "+WIND:") or
|
257
|
+
(line[0,6] == "+CREG:") or
|
258
|
+
(line[0,7] == "+CGREG:")
|
259
|
+
end
|
260
|
+
|
261
|
+
# parse out any incoming sms that were bundled
|
262
|
+
# with this data (to be fetched later by an app)
|
263
|
+
parse_incoming_sms!(out)
|
264
|
+
|
265
|
+
# log the modified output
|
266
|
+
log_decr "=#{out.inspect}"
|
267
|
+
|
268
|
+
# rest up for a bit (modems are
|
269
|
+
# slow, and get confused easily)
|
270
|
+
sleep(@cmd_delay)
|
271
|
+
return out
|
272
|
+
|
273
|
+
# if the 515 (please wait) error was thrown,
|
274
|
+
# then automatically re-try the command after
|
275
|
+
# a short delay. for others, propagate
|
276
|
+
rescue Error => err
|
277
|
+
log "Rescued: #{err.desc}"
|
278
|
+
|
279
|
+
if (err.type == "CMS") and (err.code == 515)
|
280
|
+
sleep 2
|
281
|
+
retry
|
282
|
+
end
|
283
|
+
|
284
|
+
log_decr
|
285
|
+
raise
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
|
290
|
+
def query(cmd)
|
291
|
+
log_incr "Query: #{cmd}"
|
292
|
+
out = command cmd
|
293
|
+
|
294
|
+
# only very simple responses are supported
|
295
|
+
# (on purpose!) here - [response, crlf, ok]
|
296
|
+
if (out.length==2) and (out[1]=="OK")
|
297
|
+
log_decr "=#{out[0].inspect}"
|
298
|
+
return out[0]
|
299
|
+
|
300
|
+
else
|
301
|
+
err = "Invalid response: #{out.inspect}"
|
302
|
+
raise RuntimeError.new(err)
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
|
307
|
+
# just wait for a response, by reading
|
308
|
+
# until an OK or ERROR terminator is hit
|
309
|
+
def wait(term=nil)
|
310
|
+
buffer = []
|
311
|
+
log_incr "Waiting for response"
|
312
|
+
|
313
|
+
while true do
|
314
|
+
buf = read(term)
|
315
|
+
buffer.push(buf)
|
316
|
+
|
317
|
+
# some errors contain useful error codes,
|
318
|
+
# so raise a proper error with a description
|
319
|
+
if m = buf.match(/^\+(CM[ES]) ERROR: (\d+)$/)
|
320
|
+
log_then_decr "!! Raising GsmModem::Error #{$1} #{$2}"
|
321
|
+
raise Error.new(*m.captures)
|
322
|
+
end
|
323
|
+
|
324
|
+
# some errors are not so useful :|
|
325
|
+
if buf == "ERROR"
|
326
|
+
log_then_decr "!! Raising GsmModem::Error"
|
327
|
+
raise Error
|
328
|
+
end
|
329
|
+
|
330
|
+
# most commands return OK upon success, except
|
331
|
+
# for those which prompt for more data (CMGS)
|
332
|
+
if (buf=="OK") or (buf==">")
|
333
|
+
log_decr "=#{buffer.inspect}"
|
334
|
+
return buffer
|
335
|
+
end
|
336
|
+
|
337
|
+
# some commands DO NOT respond with OK,
|
338
|
+
# even when they're successful, so check
|
339
|
+
# for those exceptions manually
|
340
|
+
if m = buf.match(/^\+CPIN: (.+)$/)
|
341
|
+
log_decr "=#{buffer.inspect}"
|
342
|
+
return buffer
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
|
348
|
+
def exclusive &blk
|
349
|
+
old_lock = nil
|
350
|
+
|
351
|
+
begin
|
352
|
+
|
353
|
+
# prevent other threads from issuing
|
354
|
+
# commands while this block is working
|
355
|
+
if @locked_to and (@locked_to != Thread.current)
|
356
|
+
log "Locked by #{@locked_to["name"]}, waiting..."
|
357
|
+
|
358
|
+
# wait for the modem to become available,
|
359
|
+
# so we can issue commands from threads
|
360
|
+
while @locked_to
|
361
|
+
sleep 0.05
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# we got the lock!
|
366
|
+
old_lock = @locked_to
|
367
|
+
@locked_to = Thread.current
|
368
|
+
log_incr "Got lock"
|
369
|
+
|
370
|
+
# perform the command while
|
371
|
+
# we have exclusive access
|
372
|
+
# to the modem device
|
373
|
+
yield
|
374
|
+
|
375
|
+
|
376
|
+
# something went bang, which happens, but
|
377
|
+
# just pass it on (after unlocking...)
|
378
|
+
rescue GsmModem::Error
|
379
|
+
raise
|
380
|
+
|
381
|
+
|
382
|
+
# no message, but always un-
|
383
|
+
# indent subsequent log messages
|
384
|
+
# and RELEASE THE LOCK
|
385
|
+
ensure
|
386
|
+
@locked_to = old_lock
|
387
|
+
Thread.pass
|
388
|
+
log_decr
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
|
393
|
+
|
394
|
+
|
395
|
+
public
|
396
|
+
|
397
|
+
|
398
|
+
# call-seq:
|
399
|
+
# hardware => hash
|
400
|
+
#
|
401
|
+
# Returns a hash of containing information about the physical
|
402
|
+
# modem. The contents of each value are entirely manufacturer
|
403
|
+
# dependant, and vary wildly between devices.
|
404
|
+
#
|
405
|
+
# modem.hardware => { :manufacturer => "Multitech".
|
406
|
+
# :model => "MTCBA-G-F4",
|
407
|
+
# :revision => "123456789",
|
408
|
+
# :serial => "ABCD" }
|
409
|
+
def hardware
|
410
|
+
return {
|
411
|
+
:manufacturer => query("AT+CGMI"),
|
412
|
+
:model => query("AT+CGMM"),
|
413
|
+
:revision => query("AT+CGMR"),
|
414
|
+
:serial => query("AT+CGSN") }
|
415
|
+
end
|
416
|
+
|
417
|
+
|
418
|
+
# The values accepted and returned by the AT+WMBS
|
419
|
+
# command, mapped to frequency bands, in MHz. Copied
|
420
|
+
# directly from the MultiTech AT command-set reference
|
421
|
+
Bands = {
|
422
|
+
"0" => "850",
|
423
|
+
"1" => "900",
|
424
|
+
"2" => "1800",
|
425
|
+
"3" => "1900",
|
426
|
+
"4" => "850/1900",
|
427
|
+
"5" => "900E/1800",
|
428
|
+
"6" => "900E/1900"
|
429
|
+
}
|
430
|
+
|
431
|
+
# call-seq:
|
432
|
+
# compatible_bands => array
|
433
|
+
#
|
434
|
+
# Returns an array containing the bands supported by
|
435
|
+
# the modem.
|
436
|
+
def compatible_bands
|
437
|
+
data = query("AT+WMBS=?")
|
438
|
+
|
439
|
+
# wmbs data is returned as something like:
|
440
|
+
# +WMBS: (0,1,2,3,4,5,6),(0-1)
|
441
|
+
# +WMBS: (0,3,4),(0-1)
|
442
|
+
# extract the numbers with a regex, and
|
443
|
+
# iterate each to resolve it to a more
|
444
|
+
# readable description
|
445
|
+
if m = data.match(/^\+WMBS: \(([\d,]+)\),/)
|
446
|
+
return m.captures[0].split(",").collect do |index|
|
447
|
+
Bands[index]
|
448
|
+
end
|
449
|
+
|
450
|
+
else
|
451
|
+
# Todo: Recover from this exception
|
452
|
+
err = "Not WMBS data: #{data.inspect}"
|
453
|
+
raise RuntimeError.new(err)
|
454
|
+
end
|
455
|
+
end
|
456
|
+
|
457
|
+
# call-seq:
|
458
|
+
# band => string
|
459
|
+
#
|
460
|
+
# Returns a string containing the band
|
461
|
+
# currently selected for use by the modem.
|
462
|
+
def band
|
463
|
+
data = query("AT+WMBS?")
|
464
|
+
if m = data.match(/^\+WMBS: (\d+),/)
|
465
|
+
return Bands[m.captures[0]]
|
466
|
+
|
467
|
+
else
|
468
|
+
# Todo: Recover from this exception
|
469
|
+
err = "Not WMBS data: #{data.inspect}"
|
470
|
+
raise RuntimeError.new(err)
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
474
|
+
|
475
|
+
# call-seq:
|
476
|
+
# pin_required? => true or false
|
477
|
+
#
|
478
|
+
# Returns true if the modem is waiting for a SIM PIN. Some SIM cards will refuse
|
479
|
+
# to work until the correct four-digit PIN is provided via the _use_pin_ method.
|
480
|
+
def pin_required?
|
481
|
+
not command("AT+CPIN?").include?("+CPIN: READY")
|
482
|
+
end
|
483
|
+
|
484
|
+
|
485
|
+
# call-seq:
|
486
|
+
# use_pin(pin) => true or false
|
487
|
+
#
|
488
|
+
# Provide a SIM PIN to the modem, and return true if it was accepted.
|
489
|
+
def use_pin(pin)
|
490
|
+
|
491
|
+
# if the sim is already ready,
|
492
|
+
# this method isn't necessary
|
493
|
+
if pin_required?
|
494
|
+
begin
|
495
|
+
command "AT+CPIN=#{pin}"
|
496
|
+
|
497
|
+
# if the command failed, then
|
498
|
+
# the pin was not accepted
|
499
|
+
rescue GsmModem::Error
|
500
|
+
return false
|
501
|
+
end
|
502
|
+
end
|
503
|
+
|
504
|
+
# no error = SIM
|
505
|
+
# PIN accepted!
|
506
|
+
true
|
507
|
+
end
|
508
|
+
|
509
|
+
|
510
|
+
# call-seq:
|
511
|
+
# signal => fixnum or nil
|
512
|
+
#
|
513
|
+
# Returns an fixnum between 1 and 99, representing the current
|
514
|
+
# signal strength of the GSM network, or nil if we don't know.
|
515
|
+
def signal_strength
|
516
|
+
data = query("AT+CSQ")
|
517
|
+
if m = data.match(/^\+CSQ: (\d+),/)
|
518
|
+
|
519
|
+
# 99 represents "not known or not detectable",
|
520
|
+
# but we'll use nil for that, since it's a bit
|
521
|
+
# more ruby-ish to test for boolean equality
|
522
|
+
csq = m.captures[0].to_i
|
523
|
+
return (csq<99) ? csq : nil
|
524
|
+
|
525
|
+
else
|
526
|
+
# Todo: Recover from this exception
|
527
|
+
err = "Not CSQ data: #{data.inspect}"
|
528
|
+
raise RuntimeError.new(err)
|
529
|
+
end
|
530
|
+
end
|
531
|
+
|
532
|
+
|
533
|
+
# call-seq:
|
534
|
+
# wait_for_network
|
535
|
+
#
|
536
|
+
# Blocks until the signal strength indicates that the
|
537
|
+
# device is active on the GSM network. It's a good idea
|
538
|
+
# to call this before trying to send or receive anything.
|
539
|
+
def wait_for_network
|
540
|
+
|
541
|
+
# keep retrying until the
|
542
|
+
# network comes up (if ever)
|
543
|
+
until csq = signal_strength
|
544
|
+
sleep 1
|
545
|
+
end
|
546
|
+
|
547
|
+
# return the last
|
548
|
+
# signal strength
|
549
|
+
return csq
|
550
|
+
end
|
551
|
+
|
552
|
+
|
553
|
+
# call-seq:
|
554
|
+
# send(recipient, message) => true or false
|
555
|
+
#
|
556
|
+
# Sends an SMS message, and returns true if the network
|
557
|
+
# accepted it for delivery. We currently can't handle read
|
558
|
+
# receipts, so have no way of confirming delivery.
|
559
|
+
#
|
560
|
+
# Note: the recipient is passed directly to the modem, which
|
561
|
+
# in turn passes it straight to the SMSC (sms message center).
|
562
|
+
# for maximum compatibility, use phone numbers in international
|
563
|
+
# format, including the *plus* and *country code*.
|
564
|
+
def send(to, msg)
|
565
|
+
|
566
|
+
# the number must be in the international
|
567
|
+
# format for some SMSCs (notably, the one
|
568
|
+
# i'm on right now) so maybe add a PLUS
|
569
|
+
#to = "+#{to}" unless(to[0,1]=="+")
|
570
|
+
|
571
|
+
# 1..9 is a special number which does not
|
572
|
+
# result in a real sms being sent (see inject.rb)
|
573
|
+
if to == "+123456789"
|
574
|
+
log "Not sending test message: #{msg}"
|
575
|
+
return false
|
576
|
+
end
|
577
|
+
|
578
|
+
# block the receiving thread while
|
579
|
+
# we're sending. it can take some time
|
580
|
+
exclusive do
|
581
|
+
log_incr "Sending SMS to #{to}: #{msg}"
|
582
|
+
|
583
|
+
# initiate the sms, and wait for either
|
584
|
+
# the text prompt or an error message
|
585
|
+
command "AT+CMGS=\"#{to}\"", ["\r\n", "> "]
|
586
|
+
|
587
|
+
begin
|
588
|
+
# send the sms, and wait until
|
589
|
+
# it is accepted or rejected
|
590
|
+
write "#{msg}#{26.chr}"
|
591
|
+
wait
|
592
|
+
|
593
|
+
# if something went wrong, we are
|
594
|
+
# be stuck in entry mode (which will
|
595
|
+
# result in someone getting a bunch
|
596
|
+
# of AT commands via sms!) so send
|
597
|
+
# an escpae, to... escape
|
598
|
+
rescue Exception, Timeout::Error => err
|
599
|
+
log "Rescued #{err.desc}"
|
600
|
+
return false
|
601
|
+
#write 27.chr
|
602
|
+
#wait
|
603
|
+
end
|
604
|
+
|
605
|
+
log_decr
|
606
|
+
end
|
607
|
+
|
608
|
+
# if no error was raised,
|
609
|
+
# then the message was sent
|
610
|
+
return true
|
611
|
+
end
|
612
|
+
|
613
|
+
|
614
|
+
# call-seq:
|
615
|
+
# receive(callback_method, interval=5, join_thread=false)
|
616
|
+
#
|
617
|
+
# Starts a new thread, which polls the device every _interval_
|
618
|
+
# seconds to capture incoming SMS and call _callback_method_
|
619
|
+
# for each.
|
620
|
+
#
|
621
|
+
# class Receiver
|
622
|
+
# def incoming(caller, datetime, message)
|
623
|
+
# puts "From #{caller} at #{datetime}:", message
|
624
|
+
# end
|
625
|
+
# end
|
626
|
+
#
|
627
|
+
# # create the instances,
|
628
|
+
# # and start receiving
|
629
|
+
# rcv = Receiver.new
|
630
|
+
# m = GsmModem.new "/dev/ttyS0"
|
631
|
+
# m.receive inst.method :incoming
|
632
|
+
#
|
633
|
+
# # block until ctrl+c
|
634
|
+
# while(true) { sleep 2 }
|
635
|
+
#
|
636
|
+
# Note: New messages may arrive at any time, even if this method's
|
637
|
+
# receiver thread isn't waiting to process them. They are not lost,
|
638
|
+
# but cached in @incoming until this method is called.
|
639
|
+
def receive(callback, interval=5, join_thread=false)
|
640
|
+
@polled = 0
|
641
|
+
|
642
|
+
@thr = Thread.new do
|
643
|
+
Thread.current["name"] = "receiver"
|
644
|
+
|
645
|
+
# keep on receiving forever
|
646
|
+
while true
|
647
|
+
command "AT"
|
648
|
+
|
649
|
+
# enable new message notification mode
|
650
|
+
# every ten intevals, in case the
|
651
|
+
# modem "forgets" (power cycle, etc)
|
652
|
+
if (@polled % 10) == 0
|
653
|
+
command "AT+CNMI=2,2,0,0,0"
|
654
|
+
end
|
655
|
+
|
656
|
+
# if there are any new incoming messages,
|
657
|
+
# iterate, and pass each to the receiver
|
658
|
+
# in the same format that they were built
|
659
|
+
# back in _parse_incoming_sms!_
|
660
|
+
unless @incoming.empty?
|
661
|
+
@incoming.each do |inc|
|
662
|
+
begin
|
663
|
+
callback.call *inc
|
664
|
+
|
665
|
+
rescue StandardError => err
|
666
|
+
log "Error in callback: #{err}"
|
667
|
+
end
|
668
|
+
end
|
669
|
+
|
670
|
+
# we have dealt with all of the pending
|
671
|
+
# messages. todo: this is a ridiculous
|
672
|
+
# race condition, and i fail at ruby
|
673
|
+
@incoming.clear
|
674
|
+
end
|
675
|
+
|
676
|
+
# re-poll every
|
677
|
+
# five seconds
|
678
|
+
sleep(interval)
|
679
|
+
@polled += 1
|
680
|
+
end
|
681
|
+
end
|
682
|
+
|
683
|
+
# it's sometimes handy to run single-
|
684
|
+
# threaded (like debugging handsets)
|
685
|
+
@thr.join if join_thread
|
686
|
+
end
|
687
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#:title:Ruby GSM Errors
|
3
|
+
#--
|
4
|
+
# vim: noet
|
5
|
+
#++
|
6
|
+
|
7
|
+
class GsmModem
|
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
|
+
end
|
97
|
+
|
98
|
+
class TimeoutError < Error #:nodoc:
|
99
|
+
def desc
|
100
|
+
return "The command timed out"
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
class WriteError < Error #:nodoc:
|
105
|
+
def desc
|
106
|
+
"The modem couldn't be written to. It " +\
|
107
|
+
"may have crashed or been unplugged"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
class ReadError < Error #:nodoc:
|
112
|
+
def desc
|
113
|
+
"The modem couldn't be read from. It " +\
|
114
|
+
"may have crashed or been unplugged"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
data/lib/rubygsm/log.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# vim: noet
|
3
|
+
|
4
|
+
class GsmModem
|
5
|
+
private
|
6
|
+
|
7
|
+
# Symbols accepted by the GsmModem.new _verbosity_
|
8
|
+
# argument. Each level includes all of the levels
|
9
|
+
# below it (ie. :debug includes all :warn messages)
|
10
|
+
LOG_LEVELS = {
|
11
|
+
:file => 5,
|
12
|
+
:traffic => 4,
|
13
|
+
:debug => 3,
|
14
|
+
:warn => 2,
|
15
|
+
:error => 1 }
|
16
|
+
|
17
|
+
def log(msg, level=:debug)
|
18
|
+
ind = " " * (@log_indents[Thread.current] or 0)
|
19
|
+
|
20
|
+
# create a
|
21
|
+
thr = Thread.current["name"]
|
22
|
+
thr = (thr.nil?) ? "" : "[#{thr}] "
|
23
|
+
|
24
|
+
# dump (almost) everything to file
|
25
|
+
if LOG_LEVELS[level] >= LOG_LEVELS[:debug]\
|
26
|
+
or level == :file
|
27
|
+
|
28
|
+
@log.puts thr + ind + msg
|
29
|
+
@log.flush
|
30
|
+
end
|
31
|
+
|
32
|
+
# also print to the rolling
|
33
|
+
# screen log, if necessary
|
34
|
+
if LOG_LEVELS[@verbosity] >= LOG_LEVELS[level]
|
35
|
+
$stderr.puts thr + ind + msg
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
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
|
data/rubygsm.gemspec
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "rubygsm"
|
3
|
+
s.version = "0.2"
|
4
|
+
s.date = "2008-12-18"
|
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
|
+
]
|
19
|
+
|
20
|
+
s.add_dependency("toholio-serialport", ["> 0.7.1"])
|
21
|
+
end
|
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: adammck-rubygsm
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: "0.2"
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Adam Mckaig
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-12-18 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
|
+
|
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
|
+
has_rdoc: true
|
40
|
+
homepage: http://github.com/adammck/rubygsm
|
41
|
+
post_install_message:
|
42
|
+
rdoc_options: []
|
43
|
+
|
44
|
+
require_paths:
|
45
|
+
- lib
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
47
|
+
requirements:
|
48
|
+
- - ">="
|
49
|
+
- !ruby/object:Gem::Version
|
50
|
+
version: "0"
|
51
|
+
version:
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: "0"
|
57
|
+
version:
|
58
|
+
requirements: []
|
59
|
+
|
60
|
+
rubyforge_project:
|
61
|
+
rubygems_version: 1.2.0
|
62
|
+
signing_key:
|
63
|
+
specification_version: 2
|
64
|
+
summary: Send and receive SMS with a GSM modem
|
65
|
+
test_files: []
|
66
|
+
|