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.
- data/VERSION.yml +1 -1
- data/lib/win32/sms.rb +308 -0
- metadata +3 -2
- data/lib/win32_sms.rb +0 -1042
data/VERSION.yml
CHANGED
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.
|
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/
|
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
|