jeffrafter-win32-sms 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (4) hide show
  1. data/VERSION.yml +1 -1
  2. data/lib/win32/sms.rb +308 -0
  3. metadata +3 -2
  4. data/lib/win32_sms.rb +0 -1042
data/VERSION.yml CHANGED
@@ -1,4 +1,4 @@
1
1
  ---
2
- :minor: 1
2
+ :minor: 2
3
3
  :patch: 0
4
4
  :major: 0
data/lib/win32/sms.rb ADDED
@@ -0,0 +1,308 @@
1
+ require 'Win32API'
2
+
3
+ # http://www.rubyinside.com/cross-platform-ruby-serial-port-library-328.html
4
+
5
+ module Win32
6
+
7
+ GENERIC_READ = 0x80000000
8
+ GENERIC_WRITE = 0x40000000
9
+ OPEN_EXISTING = 0x00000003
10
+ FILE_FLAG_OVERLAPPED = 0x40000000
11
+ NULL = 0x00000000
12
+ EV_RXCHAR = 0x0001
13
+ ERROR_IO_PENDING = 997
14
+ ERROR_HANDLE_EOF = 38
15
+ DCB_SIZE = 80
16
+
17
+ class SmsError < RuntimeError; end
18
+
19
+ class Sms
20
+
21
+ attr_accessor :messages
22
+
23
+ def initialize(port)
24
+ @read_buffer = ""
25
+
26
+ @CreateFile = Win32API.new('Kernel32', 'CreateFile', 'PLLLLLL', 'L')
27
+ @CloseHandle = Win32API.new('Kernel32','CloseHandle', 'L', 'N')
28
+ @ReadFile = Win32API.new('Kernel32','ReadFile','LPLPP','I')
29
+ @WriteFile = Win32API.new('Kernel32','WriteFile','LPLPP','I')
30
+ @SetCommState = Win32API.new('Kernel32','SetCommState','LP','N')
31
+ @SetCommTimeouts = Win32API.new('Kernel32','SetCommTimeouts','LP','N')
32
+ @BuildCommDCB = Win32API.new('Kernel32','BuildCommDCB', 'PP', 'N')
33
+ @GetLastError = Win32API.new('Kernel32','GetLastError', 'V', 'N')
34
+
35
+ # Open the device non-overlapped
36
+ @device = create_file(port, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL)
37
+
38
+ # Set the speed
39
+ set_comm_state(9600, 8, 'N', 1)
40
+
41
+ # Need to be able to reset blocking
42
+ set_comm_timeouts(2, 1, 1, 0, 0)
43
+
44
+ # Keep multi-part messages until the last part is delivered
45
+ @multipart = {}
46
+
47
+ # Store incoming messages until they're dealt with by someone else
48
+ @messages = []
49
+
50
+ # Setup the command for texting and processing
51
+ connect
52
+ end
53
+
54
+ def finalize
55
+ close
56
+ end
57
+
58
+ def close
59
+ hResult = @CloseHandle.call(@device) if @device
60
+ raise "Could not close device (#{get_last_error})" if hResult == 0
61
+ @device = nil
62
+ end
63
+
64
+ def command(data, term = "\r\n")
65
+ write(data + "\r\n")
66
+ response = wait(term)
67
+ response.delete_if do |line|
68
+ (line[0,6] == "+WIND:") or
69
+ (line[0,6] == "+CREG:") or
70
+ (line[0,7] == "+CGREG:")
71
+ end
72
+ response
73
+ end
74
+
75
+ def query(data)
76
+ response = command(data)
77
+ return response[0] if response.size == 2 and response[1] == "OK"
78
+ raise "Invalid response: #{response.inspect}"
79
+ end
80
+
81
+ def encodings?
82
+ command "AT+CSCS=?"
83
+ end
84
+
85
+ def encoding
86
+ @encoding
87
+ end
88
+
89
+ def encoding=(enc)
90
+ @encoding = enc
91
+ case enc
92
+ when :ascii
93
+ command "AT+CSCS=\"ASCII\""
94
+ when :utf8
95
+ command "AT+CSCS=\"UTF8\""
96
+ when :ucs2
97
+ command "AT+CSCS=\"UCS2\""
98
+ when :gsm
99
+ command "AT+CSCS=\"GSM\""
100
+ when :iso88591
101
+ command "AT+CSCS=\"8859-1\""
102
+ end
103
+ end
104
+
105
+ def sms(number, text)
106
+ # initiate the sms, and wait for either the text prompt or an error
107
+ command "AT+CMGS=\"#{number}\"", ["\r\n", "> "]
108
+ begin
109
+ # send the sms, and wait until it is accepted or rejected
110
+ text = encode(text)
111
+ write "#{text}#{26.chr}"
112
+ response = wait
113
+ rescue
114
+ # Escape entry mode
115
+ write 27.chr
116
+ raise
117
+ end
118
+ response
119
+ end
120
+
121
+ def hardware
122
+ {:manufacturer => query("AT+CGMI"),
123
+ :model => query("AT+CGMM"),
124
+ :revision => query("AT+CGMR"),
125
+ :serial => query("AT+CGSN") }
126
+ end
127
+
128
+ def process
129
+ @messages = []
130
+ fetch_and_delete_stored_messages
131
+ end
132
+
133
+ protected
134
+
135
+ def wait(term = "\r\n")
136
+ response = []
137
+ loop do
138
+ buffer = read_until(term)
139
+ buffer.strip!
140
+ next if buffer.nil? || buffer.empty?
141
+ response << buffer
142
+
143
+ # Check for formatted error
144
+ if m = buffer.match(/^\+(CM[ES]) ERROR: (\d+)$/)
145
+ raise SmsError.new(buffer)
146
+ end
147
+
148
+ # Check for unformatted error
149
+ if buffer == "ERROR"
150
+ raise SmsError
151
+ end
152
+
153
+ # Check for 'OK' or prompt
154
+ if (buffer == "OK") or (buffer == ">")
155
+ return response
156
+ end
157
+ end
158
+ end
159
+
160
+ def parse_timestamp(data)
161
+ # Extract the weirdo quarter-hour timezone into a regular hourly offset
162
+ data.sub! /(\d+)$/ do |m| sprintf("%02d", (m.to_i/4)); end
163
+ DateTime.strptime(data, "%d/%m/%Y %H:%M:%S %z")
164
+ end
165
+
166
+ # Encodes the message using the set encoding or, if no encoding is specified
167
+ # returns the msg unchange
168
+ def encode(msg)
169
+ if (@encoding == :ascii)
170
+ # TODO, use lucky sneaks here
171
+ msg
172
+ elsif (@encoding == :utf8)
173
+ # Unpacking and repacking supposedly cleans out bad (non-UTF-8) stuff
174
+ utf8 = msg.unpack("U*");
175
+ packed = utf8.pack("U*");
176
+ packed
177
+ elsif (@encoding == :ucs2)
178
+ ucs2 = Iconv.iconv("UCS-2", "UTF-8", msg).first
179
+ ucs2 = ucs2.unpack("H*").join
180
+ ucs2
181
+ else
182
+ msg
183
+ end
184
+ end
185
+
186
+ def select_default_mailbox
187
+ # Eventually we will select the first mailbox as the default
188
+ result = query("AT+CPMS=?")
189
+ boxes = result.scan(/\"(\w+)\"/).flatten #"
190
+ mailbox = boxes.first
191
+ command "AT+CPMS=\"#{mailbox}\""
192
+ mailbox
193
+ rescue
194
+ raise RuntimeError.new("Could not select the default mailbox")
195
+ nil
196
+ end
197
+
198
+ def fetch_and_delete_stored_messages
199
+ # If there is no way to select a default mailbox we can't continue
200
+ return unless select_default_mailbox
201
+
202
+ # Try to read the first message from the box
203
+ begin
204
+ response = command("AT+CMGR=1")
205
+ rescue
206
+ return
207
+ end
208
+
209
+ # Did we find any messages
210
+ return if response.nil? || response.empty?
211
+
212
+ # Delete that message
213
+ command("AT+CMGD=1")
214
+
215
+ # Break that down
216
+ header = response.first
217
+ text = response[1..response.size-2].join("\n")
218
+ validity = response.last
219
+
220
+ # Read the header
221
+ header = header.scan(/\"([^"]+)\"/).flatten #"
222
+ status = header[0]
223
+ from = header[1]
224
+ timestamp = header[2]
225
+ sent = DateTime.parse(timestamp)
226
+
227
+ # Just in case it wasn't already obvious
228
+ puts "Received message from #{from}: #{text}"
229
+ @messages << {:from => from,
230
+ :text => text,
231
+ :created_at => sent,
232
+ :processed_at => Time.now}
233
+ end
234
+
235
+ private
236
+
237
+ def get_last_error
238
+ @GetLastError.Call
239
+ end
240
+
241
+ def create_file(file_name, desired_access, shared_mode, security_attributes,
242
+ creation_distribution, flags_and_attributes, template_file)
243
+ hResult = @CreateFile.Call(file_name, desired_access, shared_mode,
244
+ security_attributes, creation_distribution, flags_and_attributes,
245
+ template_file)
246
+ raise "Could not open device (#{get_last_error})" if hResult < 1
247
+ hResult
248
+ end
249
+
250
+ def set_comm_state(baud, data, parity, stop)
251
+ args = "baud=#{baud} parity=#{parity} data=#{data} stop=#{stop}"
252
+ dcb = " "*80
253
+ hResult = @BuildCommDCB.Call(args, dcb)
254
+ raise "Could not build dcb structure (#{get_last_error})" if hResult == 0
255
+ hResult = @SetCommState.Call(@device, dcb)
256
+ raise "Could not set comm state (#{get_last_error})" if hResult == 0
257
+ end
258
+
259
+ def set_comm_timeouts(read_interval_timeout = 3, read_total_timeout_multiplier = 3,
260
+ read_total_timeout_constant = 2, write_total_timeout_multiplier = 3,
261
+ write_total_timeout_constant = 2)
262
+ hResult = @SetCommTimeouts.Call(@device, [read_interval_timeout,
263
+ read_total_timeout_multiplier, read_total_timeout_constant,
264
+ write_total_timeout_multiplier, write_total_timeout_constant].pack("L*"))
265
+ raise "Could not set comm timeouts (#{get_last_error})" if hResult == 0
266
+ end
267
+
268
+ def read
269
+ count = " "*4
270
+ buffer = " "*1024
271
+ hResult = @ReadFile.Call(@device, buffer, 1024, count, NULL)
272
+ raise "Could not read data (#{get_last_error})" if hResult == 0
273
+ count = count.unpack("L").first
274
+ @read_buffer << buffer[0..(count-1)]
275
+ hResult
276
+ end
277
+
278
+ def read_until(term)
279
+ term = [term].flatten
280
+ stop = nil
281
+ loop do
282
+ term.each {|t| stop = t and break if @read_buffer.index(t)}
283
+ break if stop
284
+ read
285
+ end
286
+ @read_buffer.slice!(0, @read_buffer.index(stop)+stop.size)
287
+ end
288
+
289
+ def write(data)
290
+ count = " "*4
291
+ hResult = @WriteFile.Call(@device, data, data.size, count, NULL)
292
+ count = count.unpack("L").first
293
+ raise "Could not write data (#{get_last_error})" if hResult == 0
294
+ # raise "Not enough bytes written" if count != data.size
295
+ end
296
+
297
+ def connect
298
+ # Echo off
299
+ command "ATE0" rescue nil
300
+ # Useful errors
301
+ command "AT+CMEE=1" rescue nil
302
+ # No notifications
303
+ command "AT+WIND=0" rescue nil
304
+ # Switch to text mode
305
+ command "AT+CMGF=1"
306
+ end
307
+ end
308
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jeffrafter-win32-sms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Rafter
@@ -23,7 +23,8 @@ extra_rdoc_files: []
23
23
 
24
24
  files:
25
25
  - VERSION.yml
26
- - lib/win32_sms.rb
26
+ - lib/win32
27
+ - lib/win32/sms.rb
27
28
  - test/test_helper.rb
28
29
  - test/win32_sms_test.rb
29
30
  has_rdoc: false
data/lib/win32_sms.rb DELETED
@@ -1,1042 +0,0 @@
1
- require 'Win32API'
2
-
3
- # http://www.rubyinside.com/cross-platform-ruby-serial-port-library-328.html
4
-
5
- module Win32
6
-
7
- GENERIC_READ = 0x80000000
8
- GENERIC_WRITE = 0x40000000
9
- OPEN_EXISTING = 0x00000003
10
- FILE_FLAG_OVERLAPPED = 0x40000000
11
- NULL = 0x00000000
12
- EV_RXCHAR = 0x0001
13
- ERROR_IO_PENDING = 997
14
- ERROR_HANDLE_EOF = 38
15
- DCB_SIZE = 80
16
-
17
- class Sms
18
- def initialize(port)
19
- @read_buffer = ""
20
-
21
- @CreateFile = Win32API.new('Kernel32', 'CreateFile', 'PLLLLLL', 'L')
22
- @CloseHandle = Win32API.new('Kernel32','CloseHandle', 'L', 'N')
23
- @ReadFile = Win32API.new('Kernel32','ReadFile','LPLPP','I')
24
- @WriteFile = Win32API.new('Kernel32','WriteFile','LPLPP','I')
25
- @SetCommState = Win32API.new('Kernel32','SetCommState','LP','N')
26
- @SetCommTimeouts = Win32API.new('Kernel32','SetCommTimeouts','LP','N')
27
- @BuildCommDCB = Win32API.new('Kernel32','BuildCommDCB', 'LP', 'N')
28
- @GetLastError = Win32API.new('Kernel32','GetLastError', 'V', 'N')
29
-
30
- # Open the device non-overlapped
31
- @device = create_file(port, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL)
32
-
33
- # Set the speed
34
- set_comm_state(9600, 8, 'N', 1)
35
-
36
- # Need to be able to reset blocking
37
- set_comm_timeouts(2, 1, 1, 0, 0)
38
-
39
- # Keep multi-part messages until the last part is delivered
40
- @multipart = {}
41
-
42
- # Store incoming messages until they're dealt with by someone else
43
- @incoming = []
44
- end
45
-
46
- def finalize
47
- hResult = @CloseHandle.call(@device) if @device
48
- raise "Could not close device (#{get_last_error})" if hResult == 0
49
- end
50
-
51
- def command(data)
52
- write(data)
53
- response = read_until("\r\n")
54
- response.strip!
55
- # Clean up status messages
56
- response.delete_if do |line|
57
- (line[0,6] == "+WIND:") or
58
- (line[0,6] == "+CREG:") or
59
- (line[0,7] == "+CGREG:")
60
- end
61
- response
62
- end
63
-
64
- private
65
- def get_last_error
66
- @GetLastError.Call
67
- end
68
-
69
- def create_file(file_name, desired_access, shared_mode, security_attributes,
70
- creation_distribution, flags_and_attributes, template_file)
71
- hResult = @CreateFile.Call(file_name, desired_access, shared_mode,
72
- security_attributes, creation_distribution, flags_and_attributes,
73
- template_file)
74
- raise "Could not open device (#{get_last_error})" if hResult == INVALID_HANDLE_VALUE
75
- hResult
76
- end
77
-
78
- def set_comm_state(baud, data, parity, stop)
79
- args = "baud=#{baud} parity=#{parity} data=#{data} stop=#{stop}"
80
- dcb = nil
81
- hResult = @BuildCommDCB.Call(args, dcb)
82
- raise "Could not build dcb structure (#{get_last_error})" if hResult == 0
83
- hResult = @SetCommState.Call(@device, dcb)
84
- raise "Could not set comm state (#{get_last_error})" if hResult == 0
85
- end
86
-
87
- def set_comm_timeouts(
88
- read_interval_timeout = 3,
89
- read_total_timeout_multiplier = 3,
90
- read_total_timeout_constant = 2,
91
- write_total_timeout_multiplier = 3,
92
- write_total_timeout_constant = 2)
93
- hResult = @SetCommTimeouts.Call(@device, [read_interval_timeout,
94
- read_total_timeout_multiplier,
95
- read_total_timeout_constant,
96
- write_total_timeout_multiplier,
97
- write_total_timeout_constant].pack("L*"))
98
- raise "Could not set comm timeouts (#{get_last_error})" if hResult == 0
99
- end
100
-
101
- def read
102
- count = nil
103
- buffer = nil
104
- hResult = @ReadFile.Call(@device, buffer, 1024, count, NULL)
105
- raise "Could not read data (#{get_last_error})" if hResult == 0
106
- count = [count].unpack("L")
107
- @read_buffer << buffer.slice[0..count-1]
108
- hResult
109
- end
110
-
111
- def read_until(term)
112
- loop do
113
- break if @read_buffer.index(term)
114
- read
115
- end
116
- @read_buffer.slice!(0, buf.index(term)+term.size)
117
- end
118
-
119
- def write(data)
120
- count = nil
121
- hResult = @WriteFile.Call(@device, data, data.size, count, NULL)
122
- count = [count].unpack("L")
123
- raise "Could not write data (#{get_last_error})" if hResult == 0
124
- raise "Not enough bytes written" if count != data.size
125
- end
126
-
127
- def connect
128
- # Echo off
129
- command "ATE0" rescue nil
130
- # Useful errors
131
- command "AT+CMEE=1" rescue nil
132
- # No notifications
133
- command "AT+WIND=0" rescue nil
134
- # Switch to text mode
135
- command "AT+CMGF=1"
136
- end
137
- end
138
- end
139
-
140
- =begin
141
-
142
-
143
- private
144
-
145
-
146
- INCOMING_FMT = "%y/%m/%d,%H:%M:%S%Z" #:nodoc:
147
- CMGL_STATUS = "REC UNREAD" #:nodoc:
148
-
149
-
150
- def parse_incoming_timestamp(ts)
151
- # extract the weirdo quarter-hour timezone,
152
- # convert it into a regular hourly offset
153
- ts.sub! /(\d+)$/ do |m|
154
- sprintf("%02d", (m.to_i/4))
155
- end
156
-
157
- # parse the timestamp, and attempt to re-align
158
- # it according to the timezone we extracted
159
- DateTime.strptime(ts, INCOMING_FMT)
160
- end
161
-
162
- def parse_incoming_sms!(lines)
163
- n = 0
164
-
165
- # iterate the lines like it's 1984
166
- # (because we're patching the array,
167
- # which is hard work for iterators)
168
- while n < lines.length
169
-
170
- # not a CMT string? ignore it
171
- unless lines && lines[n] && lines[n][0,5] == "+CMT:"
172
- n += 1
173
- next
174
- end
175
-
176
- # since this line IS a CMT string (an incoming
177
- # SMS), parse it and store it to deal with later
178
- unless m = lines[n].match(/^\+CMT: "(.+?)",.*?,"(.+?)".*?$/)
179
- err = "Couldn't parse CMT data: #{lines[n]}"
180
- raise RuntimeError.new(err)
181
- end
182
-
183
- # extract the meta-info from the CMT line,
184
- # and the message from the FOLLOWING line
185
- from, timestamp = *m.captures
186
- msg_text = lines[n+1].strip
187
-
188
- # notify the network that we accepted
189
- # the incoming message (for read receipt)
190
- # BEFORE pushing it to the incoming queue
191
- # (to avoid really ugly race condition if
192
- # the message is grabbed from the queue
193
- # and responded to quickly, before we get
194
- # a chance to issue at+cnma)
195
- begin
196
- command "AT+CNMA"
197
-
198
- # not terribly important if it
199
- # fails, even though it shouldn't
200
- rescue Gsm::Error
201
- log "Receipt acknowledgement (CNMA) was rejected"
202
- end
203
-
204
- # we might abort if this part of a
205
- # multi-part message, but not the last
206
- catch :skip_processing do
207
-
208
- # multi-part messages begin with ASCII char 130
209
- if (msg_text[0] == 130) and (msg_text[1].chr == "@")
210
- text = msg_text[7,999]
211
-
212
- # ensure we have a place for the incoming
213
- # message part to live as they are delivered
214
- @multipart[from] = []\
215
- unless @multipart.has_key?(from)
216
-
217
- # append THIS PART
218
- @multipart[from].push(text)
219
-
220
- # add useless message to log
221
- part = @multipart[from].length
222
- log "Received part #{part} of message from: #{from}"
223
-
224
- # abort if this is not the last part
225
- throw :skip_processing\
226
- unless (msg_text[5] == 173)
227
-
228
- # last part, so switch out the received
229
- # part with the whole message, to be processed
230
- # below (the sender and timestamp are the same
231
- # for all parts, so no change needed there)
232
- msg_text = @multipart[from].join("")
233
- @multipart.delete(from)
234
- end
235
-
236
- # just in case it wasn't already obvious...
237
- log "Received message from #{from}: #{msg_text.inspect}"
238
-
239
- # store the incoming data to be picked up
240
- # from the attr_accessor as a tuple (this
241
- # is kind of ghetto, and WILL change later)
242
- sent = parse_incoming_timestamp(timestamp)
243
- msg = Gsm::Incoming.new(self, from, sent, msg_text)
244
- @incoming.push(msg)
245
- end
246
-
247
- # drop the two CMT lines (meta-info and message),
248
- # and patch the index to hit the next unchecked
249
- # line during the next iteration
250
- lines.slice!(n,2)
251
- n -= 1
252
- end
253
- end
254
-
255
-
256
- # write a string to the modem immediately,
257
- # without waiting for the lock
258
- def write(str)
259
- log "Write: #{str.inspect}", :traffic
260
-
261
- begin
262
- str.each_byte do |b|
263
- @device.putc(b.chr)
264
- end
265
-
266
- # the device couldn't be written to,
267
- # which probably means that it has
268
- # crashed or been unplugged
269
- rescue Errno::EIO
270
- raise Gsm::WriteError
271
- end
272
- end
273
-
274
-
275
- # read from the modem (blocking) until
276
- # the term character is hit, and return
277
- def read(term=nil)
278
- term = "\r\n" if term==nil
279
- term = [term] unless term.is_a? Array
280
- buf = ""
281
-
282
- # include the terminator in the traffic dump,
283
- # if it's anything other than the default
284
- #suffix = (term != ["\r\n"]) ? " (term=#{term.inspect})" : ""
285
- #log_incr "Read" + suffix, :traffic
286
-
287
- begin
288
- timeout(@read_timeout) do
289
- while true do
290
- char = @device.getc
291
-
292
- # die if we couldn't read
293
- # (nil signifies an error)
294
- raise Gsm::ReadError\
295
- if char.nil?
296
-
297
- # convert the character to ascii,
298
- # and append it to the tmp buffer
299
- buf << sprintf("%c", char)
300
-
301
- # if a terminator was just received,
302
- # then return the current buffer
303
- term.each do |t|
304
- len = t.length
305
- if buf[-len, len] == t
306
- log "Read: #{buf.inspect}", :traffic
307
- return buf.strip
308
- end
309
- end
310
- end
311
- end
312
-
313
- # reading took too long, so intercept
314
- # and raise a more specific exception
315
- rescue Timeout::Error
316
- log = "Read: Timed out", :warn
317
- raise TimeoutError
318
- end
319
- end
320
-
321
-
322
- # issue a single command, and wait for the response
323
- def command(cmd, resp_term=nil, write_term="\r")
324
- begin
325
- out = ""
326
-
327
- exclusive do
328
- log_incr "Command: #{cmd}"
329
- write(cmd + write_term)
330
- out = wait(resp_term)
331
- end
332
-
333
- # some hardware (my motorola phone) adds extra CRLFs
334
- # to some responses. i see no reason that we need them
335
- out.delete ""
336
-
337
- # for the time being, ignore any unsolicited
338
- # status messages. i can't seem to figure out
339
- # how to disable them (AT+WIND=0 doesn't work)
340
- out.delete_if do |line|
341
- (line[0,6] == "+WIND:") or
342
- (line[0,6] == "+CREG:") or
343
- (line[0,7] == "+CGREG:")
344
- end
345
-
346
- # parse out any incoming sms that were bundled
347
- # with this data (to be fetched later by an app)
348
- parse_incoming_sms!(out)
349
-
350
- # log the modified output
351
- log_decr "=#{out.inspect}"
352
-
353
- # rest up for a bit (modems are
354
- # slow, and get confused easily)
355
- sleep(@cmd_delay)
356
- return out
357
-
358
- # if the 515 (please wait) error was thrown,
359
- # then automatically re-try the command after
360
- # a short delay. for others, propagate
361
- rescue Error => err
362
- log "Rescued (in #command): #{err.desc}"
363
-
364
- if (err.type == "CMS") and (err.code == 515)
365
- sleep 2
366
- retry
367
- end
368
-
369
- log_decr
370
- raise
371
- end
372
- end
373
-
374
-
375
- # proxy a single command to #command, but catch any
376
- # Gsm::Error exceptions that are raised, and return
377
- # nil. This should be used to issue commands which
378
- # aren't vital - of which there are VERY FEW.
379
- def try_command(cmd, *args)
380
- begin
381
- log_incr "Trying Command: #{cmd}"
382
- out = command(cmd, *args)
383
- log_decr "=#{out}"
384
- return out
385
-
386
- rescue Error => err
387
- log_then_decr "Rescued (in #try_command): #{err.desc}"
388
- return nil
389
- end
390
- end
391
-
392
-
393
- def query(cmd)
394
- log_incr "Query: #{cmd}"
395
- out = command cmd
396
-
397
- # only very simple responses are supported
398
- # (on purpose!) here - [response, crlf, ok]
399
- if (out.length==2) and (out[1]=="OK")
400
- log_decr "=#{out[0].inspect}"
401
- return out[0]
402
-
403
- else
404
- err = "Invalid response: #{out.inspect}"
405
- raise RuntimeError.new(err)
406
- end
407
- end
408
-
409
-
410
- # just wait for a response, by reading
411
- # until an OK or ERROR terminator is hit
412
- def wait(term=nil)
413
- buffer = []
414
- log_incr "Waiting for response"
415
-
416
- while true do
417
- buf = read(term)
418
- buffer.push(buf)
419
-
420
- # some errors contain useful error codes,
421
- # so raise a proper error with a description
422
- if m = buf.match(/^\+(CM[ES]) ERROR: (\d+)$/)
423
- log_then_decr "!! Raising Gsm::Error #{$1} #{$2}"
424
- raise Error.new(*m.captures)
425
- end
426
-
427
- # some errors are not so useful :|
428
- if buf == "ERROR"
429
- log_then_decr "!! Raising Gsm::Error"
430
- raise Error
431
- end
432
-
433
- # most commands return OK upon success, except
434
- # for those which prompt for more data (CMGS)
435
- if (buf=="OK") or (buf==">")
436
- log_decr "=#{buffer.inspect}"
437
- return buffer
438
- end
439
-
440
- # some commands DO NOT respond with OK,
441
- # even when they're successful, so check
442
- # for those exceptions manually
443
- if m = buf.match(/^\+CPIN: (.+)$/)
444
- log_decr "=#{buffer.inspect}"
445
- return buffer
446
- end
447
- end
448
- end
449
-
450
-
451
- def exclusive &blk
452
- old_lock = nil
453
-
454
- begin
455
-
456
- # prevent other threads from issuing
457
- # commands TO THIS MODDEM while this
458
- # block is working. this does not lock
459
- # threads, just the gsm device
460
- if @locked_to and (@locked_to != Thread.current)
461
- log "Locked by #{@locked_to["name"]}, waiting..."
462
-
463
- # wait for the modem to become available,
464
- # so we can issue commands from threads
465
- while @locked_to
466
- sleep 0.05
467
- end
468
- end
469
-
470
- # we got the lock!
471
- old_lock = @locked_to
472
- @locked_to = Thread.current
473
- log_incr "Got lock"
474
-
475
- # perform the command while
476
- # we have exclusive access
477
- # to the modem device
478
- yield
479
-
480
-
481
- # something went bang, which happens, but
482
- # just pass it on (after unlocking...)
483
- rescue Gsm::Error
484
- raise
485
-
486
-
487
- # no message, but always un-
488
- # indent subsequent log messages
489
- # and RELEASE THE LOCK
490
- ensure
491
- @locked_to = old_lock
492
- Thread.pass
493
- log_decr
494
- end
495
- end
496
-
497
-
498
-
499
-
500
- public
501
-
502
-
503
- # call-seq:
504
- # hardware => hash
505
- #
506
- # Returns a hash of containing information about the physical
507
- # modem. The contents of each value are entirely manufacturer
508
- # dependant, and vary wildly between devices.
509
- #
510
- # modem.hardware => { :manufacturer => "Multitech".
511
- # :model => "MTCBA-G-F4",
512
- # :revision => "123456789",
513
- # :serial => "ABCD" }
514
- def hardware
515
- return {
516
- :manufacturer => query("AT+CGMI"),
517
- :model => query("AT+CGMM"),
518
- :revision => query("AT+CGMR"),
519
- :serial => query("AT+CGSN") }
520
- end
521
-
522
-
523
- # The values accepted and returned by the AT+WMBS
524
- # command, mapped to frequency bands, in MHz. Copied
525
- # directly from the MultiTech AT command-set reference
526
- Bands = {
527
- 0 => "850",
528
- 1 => "900",
529
- 2 => "1800",
530
- 3 => "1900",
531
- 4 => "850/1900",
532
- 5 => "900E/1800",
533
- 6 => "900E/1900"
534
- }
535
-
536
- # call-seq:
537
- # bands_available => array
538
- #
539
- # Returns an array containing the bands supported by
540
- # the modem.
541
- def bands_available
542
- data = query("AT+WMBS=?")
543
-
544
- # wmbs data is returned as something like:
545
- # +WMBS: (0,1,2,3,4,5,6),(0-1)
546
- # +WMBS: (0,3,4),(0-1)
547
- # extract the numbers with a regex, and
548
- # iterate each to resolve it to a more
549
- # readable description
550
- if m = data.match(/^\+WMBS: \(([\d,]+)\),/)
551
- return m.captures[0].split(",").collect do |index|
552
- Bands[index.to_i]
553
- end
554
-
555
- else
556
- # Todo: Recover from this exception
557
- err = "Not WMBS data: #{data.inspect}"
558
- raise RuntimeError.new(err)
559
- end
560
- end
561
-
562
- # call-seq:
563
- # band => string
564
- #
565
- # Returns a string containing the band
566
- # currently selected for use by the modem.
567
- def band
568
- data = query("AT+WMBS?")
569
- if m = data.match(/^\+WMBS: (\d+),/)
570
- return Bands[m.captures[0].to_i]
571
-
572
- else
573
- # Todo: Recover from this exception
574
- err = "Not WMBS data: #{data.inspect}"
575
- raise RuntimeError.new(err)
576
- end
577
- end
578
-
579
- BandAreas = {
580
- :usa => 4,
581
- :africa => 5,
582
- :europe => 5,
583
- :asia => 5,
584
- :mideast => 5
585
- }
586
-
587
- # call-seq:
588
- # band=(_numeric_band_) => string
589
- #
590
- # Sets the band currently selected for use
591
- # by the modem, using either a literal band
592
- # number (passed directly to the modem, see
593
- # Gsm::Modem.Bands) or a named area from
594
- # Gsm::Modem.BandAreas:
595
- #
596
- # m = Gsm::Modem.new
597
- # m.band = :usa => "850/1900"
598
- # m.band = :africa => "900E/1800"
599
- # m.band = :monkey => ArgumentError
600
- #
601
- # (Note that as usual, the United States of
602
- # America is wearing its ass backwards.)
603
- #
604
- # Raises ArgumentError if an unrecognized band was
605
- # given, or raises Gsm::Error if the modem does
606
- # not support the given band.
607
- def band=(new_band)
608
-
609
- # resolve named bands into numeric
610
- # (mhz values first, then band areas)
611
- unless new_band.is_a?(Numeric)
612
-
613
- if Bands.has_value?(new_band.to_s)
614
- new_band = Bands.index(new_band.to_s)
615
-
616
- elsif BandAreas.has_key?(new_band.to_sym)
617
- new_band = BandAreas[new_band.to_sym]
618
-
619
- else
620
- err = "Invalid band: #{new_band}"
621
- raise ArgumentError.new(err)
622
- end
623
- end
624
-
625
- # set the band right now (second wmbs
626
- # argument is: 0=NEXT-BOOT, 1=NOW). if it
627
- # fails, allow Gsm::Error to propagate
628
- command("AT+WMBS=#{new_band},1")
629
- end
630
-
631
- # call-seq:
632
- # pin_required? => true or false
633
- #
634
- # Returns true if the modem is waiting for a SIM PIN. Some SIM cards will refuse
635
- # to work until the correct four-digit PIN is provided via the _use_pin_ method.
636
- def pin_required?
637
- not command("AT+CPIN?").include?("+CPIN: READY")
638
- end
639
-
640
-
641
- # call-seq:
642
- # use_pin(pin) => true or false
643
- #
644
- # Provide a SIM PIN to the modem, and return true if it was accepted.
645
- def use_pin(pin)
646
-
647
- # if the sim is already ready,
648
- # this method isn't necessary
649
- if pin_required?
650
- begin
651
- command "AT+CPIN=#{pin}"
652
-
653
- # if the command failed, then
654
- # the pin was not accepted
655
- rescue Gsm::Error
656
- return false
657
- end
658
- end
659
-
660
- # no error = SIM
661
- # PIN accepted!
662
- true
663
- end
664
-
665
-
666
- # call-seq:
667
- # signal => fixnum or nil
668
- #
669
- # Returns an fixnum between 1 and 99, representing the current
670
- # signal strength of the GSM network, or nil if we don't know.
671
- def signal_strength
672
- data = query("AT+CSQ")
673
- if m = data.match(/^\+CSQ: (\d+),/)
674
-
675
- # 99 represents "not known or not detectable",
676
- # but we'll use nil for that, since it's a bit
677
- # more ruby-ish to test for boolean equality
678
- csq = m.captures[0].to_i
679
- return (csq<99) ? csq : nil
680
-
681
- else
682
- # Todo: Recover from this exception
683
- err = "Not CSQ data: #{data.inspect}"
684
- raise RuntimeError.new(err)
685
- end
686
- end
687
-
688
-
689
- # call-seq:
690
- # wait_for_network
691
- #
692
- # Blocks until the signal strength indicates that the
693
- # device is active on the GSM network. It's a good idea
694
- # to call this before trying to send or receive anything.
695
- def wait_for_network
696
-
697
- # keep retrying until the
698
- # network comes up (if ever)
699
- until csq = signal_strength
700
- sleep 1
701
- end
702
-
703
- # return the last
704
- # signal strength
705
- return csq
706
- end
707
-
708
-
709
- # call-seq:
710
- # send_sms(message) => true or false
711
- # send_sms(recipient, text) => true or false
712
- #
713
- # Sends an SMS message via _send_sms!_, but traps
714
- # any exceptions raised, and returns false instead.
715
- # Use this when you don't really care if the message
716
- # was sent, which is... never.
717
- def send_sms(*args)
718
- begin
719
- send_sms!(*args)
720
- return true
721
-
722
- # something went wrong
723
- rescue Gsm::Error
724
- return false
725
- end
726
- end
727
-
728
-
729
- # call-seq:
730
- # send_sms!(message) => true or raises Gsm::Error
731
- # send_sms!(receipt, text) => true or raises Gsm::Error
732
- #
733
- # Sends an SMS message, and returns true if the network
734
- # accepted it for delivery. We currently can't handle read
735
- # receipts, so have no way of confirming delivery. If the
736
- # device or network rejects the message, a Gsm::Error is
737
- # raised containing (hopefully) information about what went
738
- # wrong.
739
- #
740
- # Note: the recipient is passed directly to the modem, which
741
- # in turn passes it straight to the SMSC (sms message center).
742
- # For maximum compatibility, use phone numbers in international
743
- # format, including the *plus* and *country code*.
744
- def send_sms!(*args)
745
-
746
- # extract values from Outgoing object.
747
- # for now, this does not offer anything
748
- # in addition to the recipient/text pair,
749
- # but provides an upgrade path for future
750
- # features (like FLASH and VALIDITY TIME)
751
- if args.length == 1\
752
- and args[0].is_a? Gsm::Outgoing
753
- to = args[0].recipient
754
- msg = args[0].text
755
-
756
- # the < v0.4 arguments. maybe
757
- # deprecate this one day
758
- elsif args.length == 2
759
- to, msg = *args
760
-
761
- else
762
- raise ArgumentError,\
763
- "The Gsm::Modem#send_sms method accepts" +\
764
- "a single Gsm::Outgoing instance, " +\
765
- "or recipient and text strings"
766
- end
767
-
768
- # the number must be in the international
769
- # format for some SMSCs (notably, the one
770
- # i'm on right now) so maybe add a PLUS
771
- #to = "+#{to}" unless(to[0,1]=="+")
772
-
773
- # 1..9 is a special number which does notm
774
- # result in a real sms being sent (see inject.rb)
775
- if to == "+123456789"
776
- log "Not sending test message: #{msg}"
777
- return false
778
- end
779
-
780
- # block the receiving thread while
781
- # we're sending. it can take some time
782
- exclusive do
783
- log_incr "Sending SMS to #{to}: #{msg}"
784
-
785
- # initiate the sms, and wait for either
786
- # the text prompt or an error message
787
- command "AT+CMGS=\"#{to}\"", ["\r\n", "> "]
788
-
789
- # encode the message using the setup encoding or the default
790
- msg = encode(msg)
791
-
792
- begin
793
- # send the sms, and wait until
794
- # it is accepted or rejected
795
- write "#{msg}#{26.chr}"
796
- wait
797
-
798
- # if something went wrong, we are
799
- # be stuck in entry mode (which will
800
- # result in someone getting a bunch
801
- # of AT commands via sms!) so send
802
- # an escpae, to... escape
803
- rescue Exception, Timeout::Error => err
804
- log "Rescued #{err.desc}"
805
- write 27.chr
806
-
807
- # allow the error to propagate,
808
- # so the application can catch
809
- # it for more useful info
810
- raise
811
-
812
- ensure
813
- log_decr
814
- end
815
- end
816
-
817
- # if no error was raised,
818
- # then the message was sent
819
- return true
820
- end
821
-
822
- # Encodes the message using the set encoding or, if no encoding is specified
823
- # returns the msg unchange
824
- def encode(msg)
825
- if (@encoding == :ascii)
826
- # TODO, use lucky sneaks here
827
- msg
828
- elsif (@encoding == :utf8)
829
- # Unpacking and repacking supposedly cleans out bad (non-UTF-8) stuff
830
- utf8 = msg.unpack("U*");
831
- packed = utf8.pack("U*");
832
- packed
833
- elsif (@encoding == :ucs2)
834
- ucs2 = Iconv.iconv("UCS-2", "UTF-8", msg).first
835
- ucs2 = ucs2.unpack("H*").join
836
- ucs2
837
- else
838
- msg
839
- end
840
- end
841
-
842
- # call-seq:
843
- # receive(callback_method, interval=5, join_thread=false)
844
- #
845
- # Starts a new thread, which polls the device every _interval_
846
- # seconds to capture incoming SMS and call _callback_method_
847
- # for each, and polls the device's internal storage for incoming
848
- # SMS that we weren't notified about (some modems don't support
849
- # that).
850
- #
851
- # class Receiver
852
- # def incoming(msg)
853
- # puts "From #{msg.from} at #{msg.sent}:", msg.text
854
- # end
855
- # end
856
- #
857
- # # create the instances,
858
- # # and start receiving
859
- # rcv = Receiver.new
860
- # m = Gsm::Modem.new "/dev/ttyS0"
861
- # m.receive rcv.method :incoming
862
- #
863
- # # block until ctrl+c
864
- # while(true) { sleep 2 }
865
- #
866
- # Note: New messages may arrive at any time, even if this method's
867
- # receiver thread isn't waiting to process them. They are not lost,
868
- # but cached in @incoming until this method is called.
869
- def receive(callback, interval=5, join_thread=false)
870
- @polled = 0
871
-
872
- @thr = Thread.new do
873
- Thread.current["name"] = "receiver"
874
-
875
- # keep on receiving forever
876
- while true
877
- process(callback)
878
- # re-poll every
879
- # five seconds
880
- sleep(interval)
881
- @polled += 1
882
- end
883
- end
884
-
885
- # it's sometimes handy to run single-
886
- # threaded (like debugging handsets)
887
- @thr.join if join_thread
888
- end
889
-
890
- def process(callback)
891
- command "AT"
892
-
893
- # check for messages in the default mailbox (wether read or not)
894
- # read them and then delete them
895
- fetch_and_delete_stored_messages
896
-
897
- # if there are any new incoming messages,
898
- # iterate, and pass each to the receiver
899
- # in the same format that they were built
900
- # back in _parse_incoming_sms!_
901
- unless @incoming.empty?
902
- @incoming.each do |msg|
903
- begin
904
- callback.call(msg)
905
-
906
- rescue StandardError => err
907
- log "Error in callback: #{err}"
908
- end
909
- end
910
-
911
- # we have dealt with all of the pending
912
- # messages. todo: this is a ridiculous
913
- # race condition, and i fail at ruby
914
- @incoming.clear
915
- end
916
- end
917
-
918
-
919
- def encodings?
920
- command "AT+CSCS=?"
921
- end
922
-
923
- def encoding
924
- @encoding
925
- end
926
-
927
- def encoding=(enc)
928
- @encoding = enc
929
- case enc
930
- when :ascii
931
- command "AT+CSCS=\"ASCII\""
932
- when :utf8
933
- command "AT+CSCS=\"UTF8\""
934
- when :ucs2
935
- command "AT+CSCS=\"UCS2\""
936
- when :gsm
937
- command "AT+CSCS=\"GSM\""
938
- when :iso88591
939
- command "AT+CSCS=\"8859-1\""
940
- end
941
- end
942
-
943
- def select_default_mailbox
944
- # Eventually we will select the first mailbox as the default
945
- result = command("AT+CPMS=?")
946
- boxes = result.first.scan(/\"(\w+)\"/).flatten #"
947
- mailbox = boxes.first
948
- command "AT+CPMS=\"#{mailbox}\""
949
- mailbox
950
- rescue
951
- raise RuntimeError.new("Could not select the default mailbox")
952
- nil
953
- end
954
-
955
- def fetch_and_delete_stored_messages
956
- # If there is no way to select a default mailbox we can't continue
957
- return unless select_default_mailbox
958
-
959
- # Try to read the first message from the box
960
- begin
961
- out = command("AT+CMGR=1")
962
- rescue
963
- return
964
- end
965
- return if out.nil? || out.empty?
966
-
967
- # Delete that message
968
- command("AT+CMGD=1")
969
-
970
- # Break that down
971
- header = out.first
972
- msg = out[1..out.size-2].join("\n")
973
- validity = out.last
974
-
975
- # Read the header
976
- header = header.scan(/\"([^"]+)\"/).flatten #"
977
- status = header[0]
978
- from = header[1]
979
- timestamp = header[2]
980
- # Parsing this using the incoming format failed, it need %Y instead of %y
981
- sent = DateTime.parse(timestamp)
982
-
983
- # just in case it wasn't already obvious...
984
- log "Received message from #{from}: #{msg}"
985
-
986
- @incoming.push Gsm::Incoming.new(self, from, sent, msg)
987
- end
988
-
989
- def fetch_unread_messages
990
-
991
- # fetch all/unread (see constant) messages
992
- lines = command('AT+CMGL="%s"' % CMGL_STATUS)
993
- n = 0
994
-
995
- # if the last line returned is OK
996
- # (and it SHOULD BE), remove it
997
- lines.pop if lines[-1] == "OK"
998
-
999
- # keep on iterating the data we received,
1000
- # until there's none left. if there were no
1001
- # stored messages waiting, this done nothing!
1002
- while n < lines.length
1003
-
1004
- # attempt to parse the CMGL line (we're skipping
1005
- # two lines at a time in this loop, so we will
1006
- # always land at a CMGL line here) - they look like:
1007
- # +CMGL: 0,"REC READ","+13364130840",,"09/03/04,21:59:31-20"
1008
- unless m = lines[n].match(/^\+CMGL: (\d+),"(.+?)","(.+?)",*?,"(.+?)".*?$/)
1009
- err = "Couldn't parse CMGL data: #{lines[n]}"
1010
- raise RuntimeError.new(err)
1011
- end
1012
-
1013
- # find the index of the next
1014
- # CMGL line, or the end
1015
- nn = n+1
1016
- nn += 1 until\
1017
- nn >= lines.length ||\
1018
- lines[nn][0,6] == "+CMGL:"
1019
-
1020
- # extract the meta-info from the CMGL line, and the
1021
- # message text from the lines between _n_ and _nn_
1022
- index, status, from, timestamp = *m.captures
1023
- msg_text = lines[(n+1)..(nn-1)].join("\n").strip
1024
-
1025
- # log the incoming message
1026
- log "Fetched stored message from #{from}: #{msg_text.inspect}"
1027
-
1028
- # store the incoming data to be picked up
1029
- # from the attr_accessor as a tuple (this
1030
- # is kind of ghetto, and WILL change later)
1031
- sent = parse_incoming_timestamp(timestamp)
1032
- msg = Gsm::Incoming.new(self, from, sent, msg_text)
1033
- @incoming.push(msg)
1034
-
1035
- # skip over the messge line(s),
1036
- # on to the next CMGL line
1037
- n = nn
1038
- end
1039
- end
1040
- end # Modem
1041
- end # Gsm
1042
- =end