jeffrafter-win32-sms 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :minor: 1
3
+ :patch: 0
4
+ :major: 0
data/lib/win32_sms.rb ADDED
@@ -0,0 +1,1042 @@
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
@@ -0,0 +1,7 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ class Test::Unit::TestCase
7
+ end
@@ -0,0 +1,7 @@
1
+ require File.dirname(__FILE__) + '/test_helper'
2
+
3
+ class Win32SmsTest < Test::Unit::TestCase
4
+ should "probably rename this file and start testing for real" do
5
+ flunk "hey buddy, you should probably rename this file and start testing for real"
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jeffrafter-win32-sms
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Rafter
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-03-25 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: TODO
17
+ email: jeff@baobabhealth.org
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - VERSION.yml
26
+ - lib/win32_sms.rb
27
+ - test/test_helper.rb
28
+ - test/win32_sms_test.rb
29
+ has_rdoc: false
30
+ homepage: http://github.com/jeffrafter/win32-sms
31
+ post_install_message:
32
+ rdoc_options: []
33
+
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: "0"
41
+ version:
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: "0"
47
+ version:
48
+ requirements: []
49
+
50
+ rubyforge_project:
51
+ rubygems_version: 1.2.0
52
+ signing_key:
53
+ specification_version: 2
54
+ summary: TODO
55
+ test_files: []
56
+