jeffrafter-win32-sms 0.1.0 → 0.2.0

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