Commit 329b73b1484f36df44ec910dde3ea5fdcb0514bd
0 parents
Exists in
master
initial commit
Showing 14 changed files with 3293 additions and 0 deletions Side-by-side Diff
config.ini.sample
... | ... | @@ -0,0 +1,23 @@ |
1 | +[globals] | |
2 | +PORT=/dev/ttyUSB0 | |
3 | +BAUDRATE=115200 | |
4 | +PIN_TRX=979797 | |
5 | + | |
6 | +BASE_CHIPINFO=XLTUNAI_PATRA_0 | |
7 | + | |
8 | +AAA_HOST=http://aplikasi.intranet.reload97.com:4250 | |
9 | +CITY=ALL | |
10 | +PRODUCTS=XA5,XA10,XL5 | |
11 | + | |
12 | +REDIS_HOST=redis.intranet.reload97.com | |
13 | +REDIS_PORT=6379 | |
14 | +REDIS_TTL=86400 | |
15 | +REDIS_DISABLE_PULL_TTL=300 | |
16 | + | |
17 | +PULL_INTERVAL=2 | |
18 | +SLEEP_AFTER_TOPUP=30 | |
19 | +SLEEP_BETWEEN_BALANCE_N_TOPUP=5 | |
20 | +TOPUP_USSD_TIMEOUT=30 | |
21 | + | |
22 | +MIN_BALANCE=10000 | |
23 | + |
gsmmodem/__init__.py
... | ... | @@ -0,0 +1,17 @@ |
1 | +""" Package that allows easy control of an attached GSM modem | |
2 | + | |
3 | +The main class for controlling a modem is GsmModem, which can be imported | |
4 | +directly from this module. | |
5 | + | |
6 | +Other important and useful classes are: | |
7 | +gsmmodem.modem.IncomingCall: wraps an incoming call and passed to the incoming call hanndler callback function | |
8 | +gsmmodem.modem.ReceivedSms: wraps a received SMS message and passed to the sms received hanndler callback function | |
9 | +gsmmodem.modem.SentSms: returned when sending SMS messages; used for tracking the status of the SMS message | |
10 | + | |
11 | +All python-gsmmodem-specific exceptions are defined in the gsmmodem.modem.exceptions package. | |
12 | + | |
13 | +@author: Francois Aucamp <francois.aucamp@gmail.com> | |
14 | +@license: LGPLv3+ | |
15 | +""" | |
16 | + | |
17 | +from .modem import GsmModem |
gsmmodem/compat.py
... | ... | @@ -0,0 +1,22 @@ |
1 | +""" Contains monkey-patched equivalents for a few commonly-used Python 2.7-and-higher functions. | |
2 | +Used to provide backwards-compatibility with Python 2.6 | |
3 | +""" | |
4 | +import sys | |
5 | +if sys.version_info[0] == 2 and sys.version_info[1] < 7: | |
6 | + import threading | |
7 | + | |
8 | + # threading.Event.wait() always returns None in Python < 2.7 so we need to patch it | |
9 | + if hasattr(threading, '_Event'): # threading.Event is a function that return threading._Event | |
10 | + # This is heavily Python-implementation-specific, so patch where we can, otherwise leave it | |
11 | + def wrapWait(func): | |
12 | + def newWait(self, timeout=None): | |
13 | + func(self, timeout) | |
14 | + return self.is_set() | |
15 | + return newWait | |
16 | + threading._Event.wait = wrapWait(threading._Event.wait) | |
17 | + else: | |
18 | + raise ImportError('Could not patch this version of Python 2.{0} for compatibility with python-gsmmodem.'.format(sys.version_info[1])) | |
19 | +if sys.version_info[0] == 2: | |
20 | + str = str | |
21 | +else: | |
22 | + str = lambda x: x | |
0 | 23 | \ No newline at end of file |
gsmmodem/exceptions.py
... | ... | @@ -0,0 +1,134 @@ |
1 | +""" Module defines exceptions used by gsmmodem """ | |
2 | + | |
3 | +class GsmModemException(Exception): | |
4 | + """ Base exception raised for error conditions when interacting with the GSM modem """ | |
5 | + | |
6 | + | |
7 | +class TimeoutException(GsmModemException): | |
8 | + """ Raised when a write command times out """ | |
9 | + | |
10 | + def __init__(self, data=None): | |
11 | + """ @param data: Any data that was read was read before timeout occurred (if applicable) """ | |
12 | + super(TimeoutException, self).__init__(data) | |
13 | + self.data = data | |
14 | + | |
15 | + | |
16 | +class InvalidStateException(GsmModemException): | |
17 | + """ Raised when an API method call is invoked on an object that is in an incorrect state """ | |
18 | + | |
19 | + | |
20 | +class InterruptedException(InvalidStateException): | |
21 | + """ Raised when execution of an AT command is interrupt by a state change. | |
22 | + May contain another exception that was the cause of the interruption """ | |
23 | + | |
24 | + def __init__(self, message, cause=None): | |
25 | + """ @param cause: the exception that caused this interruption (usually a CmeError) """ | |
26 | + super(InterruptedException, self).__init__(message) | |
27 | + self.cause = cause | |
28 | + | |
29 | + | |
30 | +class CommandError(GsmModemException): | |
31 | + """ Raised if the modem returns an error in response to an AT command | |
32 | + | |
33 | + May optionally include an error type (CME or CMS) and -code (error-specific). | |
34 | + """ | |
35 | + | |
36 | + _description = '' | |
37 | + | |
38 | + def __init__(self, command=None, type=None, code=None): | |
39 | + self.command = command | |
40 | + self.type = type | |
41 | + self.code = code | |
42 | + if type != None and code != None: | |
43 | + super(CommandError, self).__init__('{0} {1}{2}'.format(type, code, ' ({0})'.format(self._description) if len(self._description) > 0 else '')) | |
44 | + elif command != None: | |
45 | + super(CommandError, self).__init__(command) | |
46 | + else: | |
47 | + super(CommandError, self).__init__() | |
48 | + | |
49 | + | |
50 | +class CmeError(CommandError): | |
51 | + """ ME error result code : +CME ERROR: <error> | |
52 | + | |
53 | + Issued in response to an AT command | |
54 | + """ | |
55 | + | |
56 | + def __new__(cls, *args, **kwargs): | |
57 | + # Return a specialized version of this class if possible | |
58 | + if len(args) >= 2: | |
59 | + code = args[1] | |
60 | + if code == 11: | |
61 | + return PinRequiredError(args[0]) | |
62 | + elif code == 16: | |
63 | + return IncorrectPinError(args[0]) | |
64 | + elif code == 12: | |
65 | + return PukRequiredError(args[0]) | |
66 | + return super(CmeError, cls).__new__(cls, *args, **kwargs) | |
67 | + | |
68 | + def __init__(self, command, code): | |
69 | + super(CmeError, self).__init__(command, 'CME', code) | |
70 | + | |
71 | + | |
72 | +class SecurityException(CmeError): | |
73 | + """ Security-related CME error """ | |
74 | + | |
75 | + def __init__(self, command, code): | |
76 | + super(SecurityException, self).__init__(command, code) | |
77 | + | |
78 | + | |
79 | +class PinRequiredError(SecurityException): | |
80 | + """ Raised if an operation failed because the SIM card's PIN has not been entered """ | |
81 | + | |
82 | + _description = 'SIM card PIN is required' | |
83 | + | |
84 | + def __init__(self, command, code=11): | |
85 | + super(PinRequiredError, self).__init__(command, code) | |
86 | + | |
87 | + | |
88 | +class IncorrectPinError(SecurityException): | |
89 | + """ Raised if an incorrect PIN is entered """ | |
90 | + | |
91 | + _description = 'Incorrect PIN entered' | |
92 | + | |
93 | + def __init__(self, command, code=16): | |
94 | + super(IncorrectPinError, self).__init__(command, code) | |
95 | + | |
96 | + | |
97 | +class PukRequiredError(SecurityException): | |
98 | + """ Raised an operation failed because the SIM card's PUK is required (SIM locked) """ | |
99 | + | |
100 | + _description = "PUK required (SIM locked)" | |
101 | + | |
102 | + def __init__(self, command, code=12): | |
103 | + super(PukRequiredError, self).__init__(command, code) | |
104 | + | |
105 | + | |
106 | +class CmsError(CommandError): | |
107 | + """ Message service failure result code: +CMS ERROR : <er> | |
108 | + | |
109 | + Issued in response to an AT command | |
110 | + """ | |
111 | + | |
112 | + def __new__(cls, *args, **kwargs): | |
113 | + # Return a specialized version of this class if possible | |
114 | + if len(args) >= 2: | |
115 | + code = args[1] | |
116 | + if code == 330: | |
117 | + return SmscNumberUnknownError(args[0]) | |
118 | + return super(CmsError, cls).__new__(cls, *args, **kwargs) | |
119 | + | |
120 | + def __init__(self, command, code): | |
121 | + super(CmsError, self).__init__(command, 'CMS', code) | |
122 | + | |
123 | + | |
124 | +class SmscNumberUnknownError(CmsError): | |
125 | + """ Raised if the SMSC (service centre) address is missing when trying to send an SMS message """ | |
126 | + | |
127 | + _description = 'SMSC number not set' | |
128 | + | |
129 | + def __init__(self, command, code=330): | |
130 | + super(SmscNumberUnknownError, self).__init__(command, code) | |
131 | + | |
132 | + | |
133 | +class EncodingError(GsmModemException): | |
134 | + """ Raised if a decoding- or encoding operation failed """ |
gsmmodem/modem.py
Changes suppressed. Click to show
... | ... | @@ -0,0 +1,1354 @@ |
1 | +#!/usr/bin/env python | |
2 | + | |
3 | +""" High-level API classes for an attached GSM modem """ | |
4 | + | |
5 | +import sys, re, logging, weakref, time, threading, abc, codecs | |
6 | +from datetime import datetime | |
7 | + | |
8 | +from .serial_comms import SerialComms | |
9 | +from .exceptions import CommandError, InvalidStateException, CmeError, CmsError, InterruptedException, TimeoutException, PinRequiredError, IncorrectPinError, SmscNumberUnknownError | |
10 | +from .pdu import encodeSmsSubmitPdu, decodeSmsPdu | |
11 | +from .util import SimpleOffsetTzInfo, lineStartingWith, allLinesMatchingPattern, parseTextModeTimeStr | |
12 | + | |
13 | +from . import compat # For Python 2.6 compatibility | |
14 | +from gsmmodem.util import lineMatching | |
15 | +from gsmmodem.exceptions import EncodingError | |
16 | +PYTHON_VERSION = sys.version_info[0] | |
17 | +if PYTHON_VERSION >= 3: | |
18 | + xrange = range | |
19 | + dictValuesIter = dict.values | |
20 | + dictItemsIter = dict.items | |
21 | +else: #pragma: no cover | |
22 | + dictValuesIter = dict.itervalues | |
23 | + dictItemsIter = dict.iteritems | |
24 | + | |
25 | + | |
26 | +class Sms(object): | |
27 | + """ Abstract SMS message base class """ | |
28 | + __metaclass__ = abc.ABCMeta | |
29 | + | |
30 | + # Some constants to ease handling SMS statuses | |
31 | + STATUS_RECEIVED_UNREAD = 0 | |
32 | + STATUS_RECEIVED_READ = 1 | |
33 | + STATUS_STORED_UNSENT = 2 | |
34 | + STATUS_STORED_SENT = 3 | |
35 | + STATUS_ALL = 4 | |
36 | + # ...and a handy converter for text mode statuses | |
37 | + TEXT_MODE_STATUS_MAP = {'REC UNREAD': STATUS_RECEIVED_UNREAD, | |
38 | + 'REC READ': STATUS_RECEIVED_READ, | |
39 | + 'STO UNSENT': STATUS_STORED_UNSENT, | |
40 | + 'STO SENT': STATUS_STORED_SENT, | |
41 | + 'ALL': STATUS_ALL} | |
42 | + | |
43 | + def __init__(self, number, text, smsc=None): | |
44 | + self.number = number | |
45 | + self.text = text | |
46 | + self.smsc = smsc | |
47 | + | |
48 | + | |
49 | +class ReceivedSms(Sms): | |
50 | + """ An SMS message that has been received (MT) """ | |
51 | + | |
52 | + def __init__(self, gsmModem, status, number, time, text, smsc=None): | |
53 | + super(ReceivedSms, self).__init__(number, text, smsc) | |
54 | + self._gsmModem = weakref.proxy(gsmModem) | |
55 | + self.status = status | |
56 | + self.time = time | |
57 | + | |
58 | + def reply(self, message): | |
59 | + """ Convenience method that sends a reply SMS to the sender of this message """ | |
60 | + return self._gsmModem.sendSms(self.number, message) | |
61 | + | |
62 | + | |
63 | +class SentSms(Sms): | |
64 | + """ An SMS message that has been sent (MO) """ | |
65 | + | |
66 | + ENROUTE = 0 # Status indicating message is still enroute to destination | |
67 | + DELIVERED = 1 # Status indicating message has been received by destination handset | |
68 | + FAILED = 2 # Status indicating message delivery has failed | |
69 | + | |
70 | + def __init__(self, number, text, reference, smsc=None): | |
71 | + super(SentSms, self).__init__(number, text, smsc) | |
72 | + self.report = None # Status report for this SMS (StatusReport object) | |
73 | + self.reference = reference | |
74 | + | |
75 | + @property | |
76 | + def status(self): | |
77 | + """ Status of this SMS. Can be ENROUTE, DELIVERED or FAILED | |
78 | + | |
79 | + The actual status report object may be accessed via the 'report' attribute | |
80 | + if status is 'DELIVERED' or 'FAILED' | |
81 | + """ | |
82 | + if self.report == None: | |
83 | + return SentSms.ENROUTE | |
84 | + else: | |
85 | + return SentSms.DELIVERED if self.report.deliveryStatus == StatusReport.DELIVERED else SentSms.FAILED | |
86 | + | |
87 | + | |
88 | +class StatusReport(Sms): | |
89 | + """ An SMS status/delivery report | |
90 | + | |
91 | + Note: the 'status' attribute of this class refers to this status report SM's status (whether | |
92 | + it has been read, etc). To find the status of the message that caused this status report, | |
93 | + use the 'deliveryStatus' attribute. | |
94 | + """ | |
95 | + | |
96 | + DELIVERED = 0 # SMS delivery status: delivery successful | |
97 | + FAILED = 68 # SMS delivery status: delivery failed | |
98 | + | |
99 | + def __init__(self, gsmModem, status, reference, number, timeSent, timeFinalized, deliveryStatus, smsc=None): | |
100 | + super(StatusReport, self).__init__(number, None, smsc) | |
101 | + self._gsmModem = weakref.proxy(gsmModem) | |
102 | + self.status = status | |
103 | + self.reference = reference | |
104 | + self.timeSent = timeSent | |
105 | + self.timeFinalized = timeFinalized | |
106 | + self.deliveryStatus = deliveryStatus | |
107 | + | |
108 | + | |
109 | +class GsmModem(SerialComms): | |
110 | + """ Main class for interacting with an attached GSM modem """ | |
111 | + | |
112 | + log = logging.getLogger('gsmmodem.modem.GsmModem') | |
113 | + | |
114 | + # Used for parsing AT command errors | |
115 | + CM_ERROR_REGEX = re.compile(r'^\+(CM[ES]) ERROR: (\d+)$') | |
116 | + # Used for parsing signal strength query responses | |
117 | + CSQ_REGEX = re.compile(r'^\+CSQ:\s*(\d+),') | |
118 | + # Used for parsing caller ID announcements for incoming calls. Group 1 is the number | |
119 | + CLIP_REGEX = re.compile(r'^\+CLIP:\s*"(\+{0,1}\d+)",(\d+).*$') | |
120 | + # Used for parsing new SMS message indications | |
121 | + CMTI_REGEX = re.compile(r'^\+CMTI:\s*"([^"]+)",(\d+)$') | |
122 | + # Used for parsing SMS message reads (text mode) | |
123 | + CMGR_SM_DELIVER_REGEX_TEXT = None | |
124 | + # Used for parsing SMS status report message reads (text mode) | |
125 | + CMGR_SM_REPORT_REGEXT_TEXT = None | |
126 | + # Used for parsing SMS message reads (PDU mode) | |
127 | + CMGR_REGEX_PDU = None | |
128 | + # Used for parsing USSD event notifications | |
129 | + CUSD_REGEX = re.compile(r'\+CUSD:\s*(\d),"(.*?)",(\d+)', re.DOTALL) | |
130 | + # Used for parsing SMS status reports | |
131 | + CDSI_REGEX = re.compile(r'\+CDSI:\s*"([^"]+)",(\d+)$') | |
132 | + | |
133 | + def __init__(self, port, baudrate=115200, incomingCallCallbackFunc=None, smsReceivedCallbackFunc=None, smsStatusReportCallback=None): | |
134 | + super(GsmModem, self).__init__(port, baudrate, notifyCallbackFunc=self._handleModemNotification) | |
135 | + self.incomingCallCallback = incomingCallCallbackFunc or self._placeholderCallback | |
136 | + self.smsReceivedCallback = smsReceivedCallbackFunc or self._placeholderCallback | |
137 | + self.smsStatusReportCallback = smsStatusReportCallback or self._placeholderCallback | |
138 | + # Flag indicating whether caller ID for incoming call notification has been set up | |
139 | + self._callingLineIdentification = False | |
140 | + # Flag indicating whether incoming call notifications have extended information | |
141 | + self._extendedIncomingCallIndication = False | |
142 | + # Current active calls (ringing and/or answered), key is the unique call ID (not the remote number) | |
143 | + self.activeCalls = {} | |
144 | + # Dict containing sent SMS messages (for auto-tracking their delivery status) | |
145 | + self.sentSms = weakref.WeakValueDictionary() | |
146 | + self._ussdSessionEvent = None # threading.Event | |
147 | + self._ussdResponse = None # gsmmodem.modem.Ussd | |
148 | + self._smsStatusReportEvent = None # threading.Event | |
149 | + self._dialEvent = None # threading.Event | |
150 | + self._dialResponse = None # gsmmodem.modem.Call | |
151 | + self._waitForAtdResponse = True # Flag that controls if we should wait for an immediate response to ATD, or not | |
152 | + self._waitForCallInitUpdate = True # Flag that controls if we should wait for a ATD "call initiated" message | |
153 | + self._callStatusUpdates = [] # populated during connect() - contains regexes and handlers for detecting/handling call status updates | |
154 | + self._mustPollCallStatus = False # whether or not the modem must be polled for outgoing call status updates | |
155 | + self._pollCallStatusRegex = None # Regular expression used when polling outgoing call status | |
156 | + self._writeWait = 0 # Time (in seconds to wait after writing a command (adjusted when 515 errors are detected) | |
157 | + self._smsTextMode = False # Storage variable for the smsTextMode property | |
158 | + self._smscNumber = None # Default SMSC number | |
159 | + self._smsRef = 0 # Sent SMS reference counter | |
160 | + self._smsMemReadDelete = None # Preferred message storage memory for reads/deletes (<mem1> parameter used for +CPMS) | |
161 | + self._smsMemWrite = None # Preferred message storage memory for writes (<mem2> parameter used for +CPMS) | |
162 | + self._smsReadSupported = True # Whether or not reading SMS messages is supported via AT commands | |
163 | + | |
164 | + def connect(self, pin=None): | |
165 | + """ Opens the port and initializes the modem and SIM card | |
166 | + | |
167 | + :param pin: The SIM card PIN code, if any | |
168 | + :type pin: str | |
169 | + | |
170 | + :raise PinRequiredError: if the SIM card requires a PIN but none was provided | |
171 | + :raise IncorrectPinError: if the specified PIN is incorrect | |
172 | + """ | |
173 | + self.log.info('Connecting to modem on port %s at %dbps', self.port, self.baudrate) | |
174 | + super(GsmModem, self).connect() | |
175 | + # Send some initialization commands to the modem | |
176 | + try: | |
177 | + self.write('ATZ') # reset configuration | |
178 | + except CommandError: | |
179 | + # Some modems require a SIM PIN at this stage already; unlock it now | |
180 | + # Attempt to enable detailed error messages (to catch incorrect PIN error) | |
181 | + # but ignore if it fails | |
182 | + self.write('AT+CMEE=1', parseError=False) | |
183 | + self._unlockSim(pin) | |
184 | + pinCheckComplete = True | |
185 | + self.write('ATZ') # reset configuration | |
186 | + else: | |
187 | + pinCheckComplete = False | |
188 | + self.write('ATE0') # echo off | |
189 | + try: | |
190 | + cfun = int(lineStartingWith('+CFUN:', self.write('AT+CFUN?'))[7:]) # example response: +CFUN: 1 | |
191 | + if cfun != 1: | |
192 | + self.write('AT+CFUN=1') | |
193 | + except CommandError: | |
194 | + pass # just ignore if the +CFUN command isn't supported | |
195 | + | |
196 | + self.write('AT+CMEE=1') # enable detailed error messages (even if it has already been set - ATZ may reset this) | |
197 | + if not pinCheckComplete: | |
198 | + self._unlockSim(pin) | |
199 | + | |
200 | + # Get list of supported commands from modem | |
201 | + commands = self.supportedCommands | |
202 | + | |
203 | + # Device-specific settings | |
204 | + callUpdateTableHint = 0 # unknown modem | |
205 | + enableWind = False | |
206 | + if commands != None: | |
207 | + if '^CVOICE' in commands: | |
208 | + self.write('AT^CVOICE=0', parseError=False) # Enable voice calls | |
209 | + if '+VTS' in commands: # Check for DTMF sending support | |
210 | + Call.dtmfSupport = True | |
211 | + elif '^DTMF' in commands: | |
212 | + # Huawei modems use ^DTMF to send DTMF tones | |
213 | + callUpdateTableHint = 1 # Huawei | |
214 | + if '^USSDMODE' in commands: | |
215 | + # Enable Huawei text-mode USSD | |
216 | + self.write('AT^USSDMODE=0', parseError=False) | |
217 | + if '+WIND' in commands: | |
218 | + callUpdateTableHint = 2 # Wavecom | |
219 | + enableWind = True | |
220 | + elif '+ZPAS' in commands: | |
221 | + callUpdateTableHint = 3 # ZTE | |
222 | + else: | |
223 | + # Try to enable general notifications on Wavecom-like device | |
224 | + enableWind = True | |
225 | + | |
226 | + if enableWind: | |
227 | + try: | |
228 | + wind = lineStartingWith('+WIND:', self.write('AT+WIND?')) # Check current WIND value; example response: +WIND: 63 | |
229 | + except CommandError: | |
230 | + # Modem does not support +WIND notifications. See if we can detect other known call update notifications | |
231 | + pass | |
232 | + else: | |
233 | + # Enable notifications for call setup, hangup, etc | |
234 | + if int(wind[7:]) != 50: | |
235 | + self.write('AT+WIND=50') | |
236 | + callUpdateTableHint = 2 # Wavecom | |
237 | + | |
238 | + # Attempt to identify modem type directly (if not already) - for outgoing call status updates | |
239 | + if callUpdateTableHint == 0: | |
240 | + if self.manufacturer.lower() == 'huawei': | |
241 | + callUpdateTableHint = 1 # huawei | |
242 | + else: | |
243 | + # See if this is a ZTE modem that has not yet been identified based on supported commands | |
244 | + try: | |
245 | + self.write('AT+ZPAS?') | |
246 | + except CommandError: | |
247 | + pass # Not a ZTE modem | |
248 | + else: | |
249 | + callUpdateTableHint = 3 # ZTE | |
250 | + # Load outgoing call status updates based on identified modem features | |
251 | + if callUpdateTableHint == 1: | |
252 | + # Use Hauwei's ^NOTIFICATIONs | |
253 | + self.log.info('Loading Huawei call state update table') | |
254 | + self._callStatusUpdates = ((re.compile(r'^\^ORIG:(\d),(\d)$'), self._handleCallInitiated), | |
255 | + (re.compile(r'^\^CONN:(\d),(\d)$'), self._handleCallAnswered), | |
256 | + (re.compile(r'^\^CEND:(\d),(\d),(\d)+,(\d)+$'), self._handleCallEnded)) | |
257 | + self._mustPollCallStatus = False | |
258 | + # Huawei modems use ^DTMF to send DTMF tones; use that instead | |
259 | + Call.DTMF_COMMAND_BASE = '^DTMF={cid},' | |
260 | + Call.dtmfSupport = True | |
261 | + elif callUpdateTableHint == 2: | |
262 | + # Wavecom modem: +WIND notifications supported | |
263 | + self.log.info('Loading Wavecom call state update table') | |
264 | + self._callStatusUpdates = ((re.compile(r'^\+WIND: 5,(\d)$'), self._handleCallInitiated), | |
265 | + (re.compile(r'^OK$'), self._handleCallAnswered), | |
266 | + (re.compile(r'^\+WIND: 6,(\d)$'), self._handleCallEnded)) | |
267 | + self._waitForAtdResponse = False # Wavecom modems return OK only when the call is answered | |
268 | + self._mustPollCallStatus = False | |
269 | + if commands == None: # older modem, assume it has standard DTMF support | |
270 | + Call.dtmfSupport = True | |
271 | + elif callUpdateTableHint == 3: # ZTE | |
272 | + # Use ZTE notifications ("CONNECT"/"HANGUP", but no "call initiated" notification) | |
273 | + self.log.info('Loading ZTE call state update table') | |
274 | + self._callStatusUpdates = ((re.compile(r'^CONNECT$'), self._handleCallAnswered), | |
275 | + (re.compile(r'^HANGUP:\s*(\d+)$'), self._handleCallEnded), | |
276 | + (re.compile(r'^OK$'), self._handleCallRejected)) | |
277 | + self._waitForAtdResponse = False # ZTE modems do not return an immediate OK only when the call is answered | |
278 | + self._mustPollCallStatus = False | |
279 | + self._waitForCallInitUpdate = False # ZTE modems do not provide "call initiated" updates | |
280 | + if commands == None: # ZTE uses standard +VTS for DTMF | |
281 | + Call.dtmfSupport = True | |
282 | + else: | |
283 | + # Unknown modem - we do not know what its call updates look like. Use polling instead | |
284 | + self.log.info('Unknown/generic modem type - will use polling for call state updates') | |
285 | + self._mustPollCallStatus = True | |
286 | + self._pollCallStatusRegex = re.compile('^\+CLCC:\s+(\d+),(\d),(\d),(\d),([^,]),"([^,]*)",(\d+)$') | |
287 | + self._waitForAtdResponse = True # Most modems return OK immediately after issuing ATD | |
288 | + | |
289 | + # General meta-information setup | |
290 | + self.write('AT+COPS=3,0', parseError=False) # Use long alphanumeric name format | |
291 | + | |
292 | + # SMS setup | |
293 | + self.write('AT+CMGF={0}'.format(1 if self._smsTextMode else 0)) # Switch to text or PDU mode for SMS messages | |
294 | + self._compileSmsRegexes() | |
295 | + if self._smscNumber != None: | |
296 | + self.write('AT+CSCA="{0}"'.format(self._smscNumber)) # Set default SMSC number | |
297 | + currentSmscNumber = self._smscNumber | |
298 | + else: | |
299 | + currentSmscNumber = self.smsc | |
300 | + # Some modems delete the SMSC number when setting text-mode SMS parameters; preserve it if needed | |
301 | + if currentSmscNumber != None: | |
302 | + self._smscNumber = None # clear cache | |
303 | + self.write('AT+CSMP=49,167,0,0', parseError=False) # Enable delivery reports | |
304 | + # ...check SMSC again to ensure it did not change | |
305 | + if currentSmscNumber != None and self.smsc != currentSmscNumber: | |
306 | + self.smsc = currentSmscNumber | |
307 | + | |
308 | + # Set message storage, but first check what the modem supports - example response: +CPMS: (("SM","BM","SR"),("SM")) | |
309 | + try: | |
310 | + cpmsLine = lineStartingWith('+CPMS', self.write('AT+CPMS=?')) | |
311 | + except CommandError: | |
312 | + # Modem does not support AT+CPMS; SMS reading unavailable | |
313 | + self._smsReadSupported = False | |
314 | + self.log.warning('SMS preferred message storage query not supported by modem. SMS reading unavailable.') | |
315 | + else: | |
316 | + cpmsSupport = cpmsLine.split(' ', 1)[1].split('),(') | |
317 | + # Do a sanity check on the memory types returned - Nokia S60 devices return empty strings, for example | |
318 | + for memItem in cpmsSupport: | |
319 | + if len(memItem) == 0: | |
320 | + # No support for reading stored SMS via AT commands - probably a Nokia S60 | |
321 | + self._smsReadSupported = False | |
322 | + self.log.warning('Invalid SMS message storage support returned by modem. SMS reading unavailable. Response was: "%s"', cpmsLine) | |
323 | + break | |
324 | + else: | |
325 | + # Suppported memory types look fine, continue | |
326 | + preferredMemoryTypes = ('"ME"', '"SM"', '"SR"') | |
327 | + cpmsItems = [''] * len(cpmsSupport) | |
328 | + for i in xrange(len(cpmsSupport)): | |
329 | + for memType in preferredMemoryTypes: | |
330 | + if memType in cpmsSupport[i]: | |
331 | + if i == 0: | |
332 | + self._smsMemReadDelete = memType | |
333 | + cpmsItems[i] = memType | |
334 | + break | |
335 | + self.write('AT+CPMS={0}'.format(','.join(cpmsItems))) # Set message storage | |
336 | + del cpmsSupport | |
337 | + del cpmsLine | |
338 | + | |
339 | + if self._smsReadSupported: | |
340 | + try: | |
341 | + self.write('AT+CNMI=2,1,0,2') # Set message notifications | |
342 | + except CommandError: | |
343 | + # Message notifications not supported | |
344 | + self._smsReadSupported = False | |
345 | + self.log.warning('Incoming SMS notifications not supported by modem. SMS receiving unavailable.') | |
346 | + | |
347 | + # Incoming call notification setup | |
348 | + try: | |
349 | + self.write('AT+CLIP=1') # Enable calling line identification presentation | |
350 | + except CommandError as clipError: | |
351 | + self._callingLineIdentification = False | |
352 | + self.log.warning('Incoming call calling line identification (caller ID) not supported by modem. Error: {0}'.format(clipError)) | |
353 | + else: | |
354 | + self._callingLineIdentification = True | |
355 | + try: | |
356 | + self.write('AT+CRC=1') # Enable extended format of incoming indication (optional) | |
357 | + except CommandError as crcError: | |
358 | + self._extendedIncomingCallIndication = False | |
359 | + self.log.warning('Extended format incoming call indication not supported by modem. Error: {0}'.format(crcError)) | |
360 | + else: | |
361 | + self._extendedIncomingCallIndication = True | |
362 | + | |
363 | + # Call control setup | |
364 | + self.write('AT+CVHU=0', parseError=False) # Enable call hang-up with ATH command (ignore if command not supported) | |
365 | + | |
366 | + def _unlockSim(self, pin): | |
367 | + """ Unlocks the SIM card using the specified PIN (if necessary, else does nothing) """ | |
368 | + # Unlock the SIM card if needed | |
369 | + try: | |
370 | + cpinResponse = lineStartingWith('+CPIN', self.write('AT+CPIN?', timeout=0.25)) | |
371 | + except TimeoutException as timeout: | |
372 | + # Wavecom modems do not end +CPIN responses with "OK" (github issue #19) - see if just the +CPIN response was returned | |
373 | + if timeout.data != None: | |
374 | + cpinResponse = lineStartingWith('+CPIN', timeout.data) | |
375 | + if cpinResponse == None: | |
376 | + # No useful response read | |
377 | + raise timeout | |
378 | + else: | |
379 | + # Nothing read (real timeout) | |
380 | + raise timeout | |
381 | + if cpinResponse != '+CPIN: READY': | |
382 | + if pin != None: | |
383 | + self.write('AT+CPIN="{0}"'.format(pin)) | |
384 | + else: | |
385 | + raise PinRequiredError('AT+CPIN') | |
386 | + | |
387 | + def write(self, data, waitForResponse=True, timeout=5, parseError=True, writeTerm='\r', expectedResponseTermSeq=None): | |
388 | + """ Write data to the modem. | |
389 | + | |
390 | + This method adds the ``\\r\\n`` end-of-line sequence to the data parameter, and | |
391 | + writes it to the modem. | |
392 | + | |
393 | + :param data: Command/data to be written to the modem | |
394 | + :type data: str | |
395 | + :param waitForResponse: Whether this method should block and return the response from the modem or not | |
396 | + :type waitForResponse: bool | |
397 | + :param timeout: Maximum amount of time in seconds to wait for a response from the modem | |
398 | + :type timeout: int | |
399 | + :param parseError: If True, a CommandError is raised if the modem responds with an error (otherwise the response is returned as-is) | |
400 | + :type parseError: bool | |
401 | + :param writeTerm: The terminating sequence to append to the written data | |
402 | + :type writeTerm: str | |
403 | + :param expectedResponseTermSeq: The expected terminating sequence that marks the end of the modem's response (defaults to ``\\r\\n``) | |
404 | + :type expectedResponseTermSeq: str | |
405 | + | |
406 | + :raise CommandError: if the command returns an error (only if parseError parameter is True) | |
407 | + :raise TimeoutException: if no response to the command was received from the modem | |
408 | + | |
409 | + :return: A list containing the response lines from the modem, or None if waitForResponse is False | |
410 | + :rtype: list | |
411 | + """ | |
412 | + self.log.debug('write: %s', data) | |
413 | + responseLines = super(GsmModem, self).write(data + writeTerm, waitForResponse=waitForResponse, timeout=timeout, expectedResponseTermSeq=expectedResponseTermSeq) | |
414 | + if self._writeWait > 0: # Sleep a bit if required (some older modems suffer under load) | |
415 | + time.sleep(self._writeWait) | |
416 | + if waitForResponse: | |
417 | + cmdStatusLine = responseLines[-1] | |
418 | + if parseError: | |
419 | + if 'ERROR' in cmdStatusLine: | |
420 | + cmErrorMatch = self.CM_ERROR_REGEX.match(cmdStatusLine) | |
421 | + if cmErrorMatch: | |
422 | + errorType = cmErrorMatch.group(1) | |
423 | + errorCode = int(cmErrorMatch.group(2)) | |
424 | + if errorCode == 515 or errorCode == 14: | |
425 | + # 515 means: "Please wait, init or command processing in progress." | |
426 | + # 14 means "SIM busy" | |
427 | + self._writeWait += 0.2 # Increase waiting period temporarily | |
428 | + # Retry the command after waiting a bit | |
429 | + self.log.debug('Device/SIM busy error detected; self._writeWait adjusted to %fs', self._writeWait) | |
430 | + time.sleep(self._writeWait) | |
431 | + result = self.write(data, waitForResponse, timeout, parseError, writeTerm, expectedResponseTermSeq) | |
432 | + self.log.debug('self_writeWait set to 0.1 because of recovering from device busy (515) error') | |
433 | + if errorCode == 515: | |
434 | + self._writeWait = 0.1 # Set this to something sane for further commands (slow modem) | |
435 | + else: | |
436 | + self._writeWait = 0 # The modem was just waiting for the SIM card | |
437 | + return result | |
438 | + if errorType == 'CME': | |
439 | + raise CmeError(data, int(errorCode)) | |
440 | + else: # CMS error | |
441 | + raise CmsError(data, int(errorCode)) | |
442 | + else: | |
443 | + raise CommandError(data) | |
444 | + elif cmdStatusLine == 'COMMAND NOT SUPPORT': # Some Huawei modems respond with this for unknown commands | |
445 | + raise CommandError(data + '({0})'.format(cmdStatusLine)) | |
446 | + return responseLines | |
447 | + | |
448 | + @property | |
449 | + def signalStrength(self): | |
450 | + """ Checks the modem's cellular network signal strength | |
451 | + | |
452 | + :raise CommandError: if an error occurs | |
453 | + | |
454 | + :return: The network signal strength as an integer between 0 and 99, or -1 if it is unknown | |
455 | + :rtype: int | |
456 | + """ | |
457 | + csq = self.CSQ_REGEX.match(self.write('AT+CSQ')[0]) | |
458 | + if csq: | |
459 | + ss = int(csq.group(1)) | |
460 | + return ss if ss != 99 else -1 | |
461 | + else: | |
462 | + raise CommandError() | |
463 | + | |
464 | + @property | |
465 | + def manufacturer(self): | |
466 | + """ :return: The modem's manufacturer's name """ | |
467 | + return self.write('AT+CGMI')[0] | |
468 | + | |
469 | + @property | |
470 | + def model(self): | |
471 | + """ :return: The modem's model name """ | |
472 | + return self.write('AT+CGMM')[0] | |
473 | + | |
474 | + @property | |
475 | + def revision(self): | |
476 | + """ :return: The modem's software revision, or None if not known/supported """ | |
477 | + try: | |
478 | + return self.write('AT+CGMR')[0] | |
479 | + except CommandError: | |
480 | + return None | |
481 | + | |
482 | + @property | |
483 | + def imei(self): | |
484 | + """ :return: The modem's serial number (IMEI number) """ | |
485 | + return self.write('AT+CGSN')[0] | |
486 | + | |
487 | + @property | |
488 | + def imsi(self): | |
489 | + """ :return: The IMSI (International Mobile Subscriber Identity) of the SIM card. The PIN may need to be entered before reading the IMSI """ | |
490 | + return self.write('AT+CIMI')[0] | |
491 | + | |
492 | + @property | |
493 | + def networkName(self): | |
494 | + """ :return: the name of the GSM Network Operator to which the modem is connected """ | |
495 | + copsMatch = lineMatching(r'^\+COPS: (\d),(\d),"(.+)",{0,1}\d*$', self.write('AT+COPS?')) # response format: +COPS: mode,format,"operator_name",x | |
496 | + if copsMatch: | |
497 | + return copsMatch.group(3) | |
498 | + | |
499 | + @property | |
500 | + def supportedCommands(self): | |
501 | + """ :return: list of AT commands supported by this modem (without the AT prefix). Returns None if not known """ | |
502 | + try: | |
503 | + # AT+CLAC responses differ between modems. Most respond with +CLAC: and then a comma-separated list of commands | |
504 | + # while others simply return each command on a new line, with no +CLAC: prefix | |
505 | + response = self.write('AT+CLAC') | |
506 | + if len(response) == 2: # Single-line response, comma separated | |
507 | + commands = response[0] | |
508 | + if commands.startswith('+CLAC'): | |
509 | + commands = commands[6:] # remove the +CLAC: prefix before splitting | |
510 | + return commands.split(',') | |
511 | + elif len(response) > 2: # Multi-line response | |
512 | + return [cmd.strip() for cmd in response[:-1]] | |
513 | + else: | |
514 | + self.log.debug('Unhandled +CLAC response: {0}'.format(response)) | |
515 | + return None | |
516 | + except CommandError: | |
517 | + return None | |
518 | + | |
519 | + @property | |
520 | + def smsTextMode(self): | |
521 | + """ :return: True if the modem is set to use text mode for SMS, False if it is set to use PDU mode """ | |
522 | + return self._smsTextMode | |
523 | + @smsTextMode.setter | |
524 | + def smsTextMode(self, textMode): | |
525 | + """ Set to True for the modem to use text mode for SMS, or False for it to use PDU mode """ | |
526 | + if textMode != self._smsTextMode: | |
527 | + if self.alive: | |
528 | + self.write('AT+CMGF={0}'.format(1 if textMode else 0)) | |
529 | + self._smsTextMode = textMode | |
530 | + self._compileSmsRegexes() | |
531 | + | |
532 | + def _setSmsMemory(self, readDelete=None, write=None): | |
533 | + """ Set the current SMS memory to use for read/delete/write operations """ | |
534 | + # Switch to the correct memory type if required | |
535 | + if write != None and write != self._smsMemWrite: | |
536 | + self.write() | |
537 | + readDel = readDelete or self._smsMemReadDelete | |
538 | + self.write('AT+CPMS="{0}","{1}"'.format(readDel, write)) | |
539 | + self._smsMemReadDelete = readDel | |
540 | + self._smsMemWrite = write | |
541 | + elif readDelete != None and readDelete != self._smsMemReadDelete: | |
542 | + self.write('AT+CPMS="{0}"'.format(readDelete)) | |
543 | + self._smsMemReadDelete = readDelete | |
544 | + | |
545 | + def _compileSmsRegexes(self): | |
546 | + """ Compiles regular expression used for parsing SMS messages based on current mode """ | |
547 | + if self._smsTextMode: | |
548 | + if self.CMGR_SM_DELIVER_REGEX_TEXT == None: | |
549 | + self.CMGR_SM_DELIVER_REGEX_TEXT = re.compile(r'^\+CMGR: "([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') | |
550 | + self.CMGR_SM_REPORT_REGEXT_TEXT = re.compile(r'^\+CMGR: ([^,]*),\d+,(\d+),"{0,1}([^"]*)"{0,1},\d*,"([^"]+)","([^"]+)",(\d+)$') | |
551 | + elif self.CMGR_REGEX_PDU == None: | |
552 | + self.CMGR_REGEX_PDU = re.compile(r'^\+CMGR: (\d*),"{0,1}([^"]*)"{0,1},(\d+)$') | |
553 | + | |
554 | + @property | |
555 | + def smsc(self): | |
556 | + """ :return: The default SMSC number stored on the SIM card """ | |
557 | + if self._smscNumber == None: | |
558 | + try: | |
559 | + readSmsc = self.write('AT+CSCA?') | |
560 | + except SmscNumberUnknownError: | |
561 | + pass # Some modems return a CMS 330 error if the value isn't set | |
562 | + else: | |
563 | + cscaMatch = lineMatching(r'\+CSCA:\s*"([^,]+)",(\d+)$', readSmsc) | |
564 | + if cscaMatch: | |
565 | + self._smscNumber = cscaMatch.group(1) | |
566 | + return self._smscNumber | |
567 | + @smsc.setter | |
568 | + def smsc(self, smscNumber): | |
569 | + """ Set the default SMSC number to use when sending SMS messages """ | |
570 | + if smscNumber != self._smscNumber: | |
571 | + if self.alive: | |
572 | + self.write('AT+CSCA="{0}"'.format(smscNumber)) | |
573 | + self._smscNumber = smscNumber | |
574 | + | |
575 | + def waitForNetworkCoverage(self, timeout=None): | |
576 | + """ Block until the modem has GSM network coverage. | |
577 | + | |
578 | + This method blocks until the modem is registered with the network | |
579 | + and the signal strength is greater than 0, optionally timing out | |
580 | + if a timeout was specified | |
581 | + | |
582 | + :param timeout: Maximum time to wait for network coverage, in seconds | |
583 | + :type timeout: int or float | |
584 | + | |
585 | + :raise TimeoutException: if a timeout was specified and reached | |
586 | + :raise InvalidStateException: if the modem is not going to receive network coverage (SIM blocked, etc) | |
587 | + | |
588 | + :return: the current signal strength | |
589 | + :rtype: int | |
590 | + """ | |
591 | + block = [True] | |
592 | + if timeout != None: | |
593 | + # Set up a timeout mechanism | |
594 | + def _cancelBlock(): | |
595 | + block[0] = False | |
596 | + t = threading.Timer(timeout, _cancelBlock) | |
597 | + t.start() | |
598 | + ss = -1 | |
599 | + checkCreg = True | |
600 | + while block[0]: | |
601 | + if checkCreg: | |
602 | + cregResult = lineMatching(r'^\+CREG:\s*(\d),(\d)$', self.write('AT+CREG?', parseError=False)) # example result: +CREG: 0,1 | |
603 | + if cregResult: | |
604 | + status = int(cregResult.group(2)) | |
605 | + if status in (1, 5): | |
606 | + # 1: registered, home network, 5: registered, roaming | |
607 | + # Now simply check and return network signal strength | |
608 | + checkCreg = False | |
609 | + elif status == 3: | |
610 | + raise InvalidStateException('Network registration denied') | |
611 | + elif status == 0: | |
612 | + raise InvalidStateException('Device not searching for network operator') | |
613 | + else: | |
614 | + # Disable network registration check; only use signal strength | |
615 | + self.log.info('+CREG check disabled due to invalid response or unsupported command') | |
616 | + checkCreg = False | |
617 | + else: | |
618 | + # Check signal strength | |
619 | + ss = self.signalStrength | |
620 | + if ss > 0: | |
621 | + return ss | |
622 | + time.sleep(1) | |
623 | + else: | |
624 | + # If this is reached, the timer task has triggered | |
625 | + raise TimeoutException() | |
626 | + | |
627 | + def sendSms(self, destination, text, waitForDeliveryReport=False, deliveryTimeout=15, sendFlash=False): | |
628 | + """ Send an SMS text message | |
629 | + | |
630 | + :param destination: the recipient's phone number | |
631 | + :type destination: str | |
632 | + :param text: the message text | |
633 | + :type text: str | |
634 | + :param waitForDeliveryReport: if True, this method blocks until a delivery report is received for the sent message | |
635 | + :type waitForDeliveryReport: boolean | |
636 | + :param deliveryReport: the maximum time in seconds to wait for a delivery report (if "waitForDeliveryReport" is True) | |
637 | + :type deliveryTimeout: int or float | |
638 | + | |
639 | + :raise CommandError: if an error occurs while attempting to send the message | |
640 | + :raise TimeoutException: if the operation times out | |
641 | + """ | |
642 | + if self._smsTextMode: | |
643 | + self.write('AT+CMGS="{0}"'.format(destination), timeout=3, expectedResponseTermSeq='> ') | |
644 | + result = lineStartingWith('+CMGS:', self.write(text, timeout=15, writeTerm=chr(26))) | |
645 | + else: | |
646 | + pdus = encodeSmsSubmitPdu(destination, text, reference=self._smsRef, sendFlash=sendFlash) | |
647 | + for pdu in pdus: | |
648 | + self.write('AT+CMGS={0}'.format(pdu.tpduLength), timeout=3, expectedResponseTermSeq='> ') | |
649 | + result = lineStartingWith('+CMGS:', self.write(str(pdu), timeout=15, writeTerm=chr(26))) # example: +CMGS: xx | |
650 | + if result == None: | |
651 | + raise CommandError('Modem did not respond with +CMGS response') | |
652 | + reference = int(result[7:]) | |
653 | + self._smsRef = reference + 1 | |
654 | + if self._smsRef > 255: | |
655 | + self._smsRef = 0 | |
656 | + sms = SentSms(destination, text, reference) | |
657 | + # Add a weak-referenced entry for this SMS (allows us to update the SMS state if a status report is received) | |
658 | + self.sentSms[reference] = sms | |
659 | + if waitForDeliveryReport: | |
660 | + self._smsStatusReportEvent = threading.Event() | |
661 | + if self._smsStatusReportEvent.wait(deliveryTimeout): | |
662 | + self._smsStatusReportEvent = None | |
663 | + else: # Response timed out | |
664 | + self._smsStatusReportEvent = None | |
665 | + raise TimeoutException() | |
666 | + return sms | |
667 | + | |
668 | + def sendUssd(self, ussdString, responseTimeout=15): | |
669 | + """ Starts a USSD session by dialing the the specified USSD string, or \ | |
670 | + sends the specified string in the existing USSD session (if any) | |
671 | + | |
672 | + :param ussdString: The USSD access number to dial | |
673 | + :param responseTimeout: Maximum time to wait a response, in seconds | |
674 | + | |
675 | + :raise TimeoutException: if no response is received in time | |
676 | + | |
677 | + :return: The USSD response message/session (as a Ussd object) | |
678 | + :rtype: gsmmodem.modem.Ussd | |
679 | + """ | |
680 | + self._ussdSessionEvent = threading.Event() | |
681 | + try: | |
682 | + cusdResponse = self.write('AT+CUSD=1,"{0}",15'.format(ussdString), timeout=responseTimeout) # Should respond with "OK" | |
683 | + except Exception: | |
684 | + self._ussdSessionEvent = None # Cancel the thread sync lock | |
685 | + raise | |
686 | + | |
687 | + # Some modems issue the +CUSD response before the acknowledgment "OK" - check for that | |
688 | + if len(cusdResponse) > 1: | |
689 | + cusdResponseFound = lineStartingWith('+CUSD', cusdResponse) != None | |
690 | + if cusdResponseFound: | |
691 | + self._ussdSessionEvent = None # Cancel thread sync lock | |
692 | + return self._parseCusdResponse(cusdResponse) | |
693 | + # Wait for the +CUSD notification message | |
694 | + if self._ussdSessionEvent.wait(responseTimeout): | |
695 | + self._ussdSessionEvent = None | |
696 | + return self._ussdResponse | |
697 | + else: # Response timed out | |
698 | + self._ussdSessionEvent = None | |
699 | + raise TimeoutException() | |
700 | + | |
701 | + def dial(self, number, timeout=5, callStatusUpdateCallbackFunc=None): | |
702 | + """ Calls the specified phone number using a voice phone call | |
703 | + | |
704 | + :param number: The phone number to dial | |
705 | + :param timeout: Maximum time to wait for the call to be established | |
706 | + :param callStatusUpdateCallbackFunc: Callback function that is executed if the call's status changes due to | |
707 | + remote events (i.e. when it is answered, the call is ended by the remote party) | |
708 | + | |
709 | + :return: The outgoing call | |
710 | + :rtype: gsmmodem.modem.Call | |
711 | + """ | |
712 | + if self._waitForCallInitUpdate: | |
713 | + # Wait for the "call originated" notification message | |
714 | + self._dialEvent = threading.Event() | |
715 | + try: | |
716 | + self.write('ATD{0};'.format(number), timeout=timeout, waitForResponse=self._waitForAtdResponse) | |
717 | + except Exception: | |
718 | + self._dialEvent = None # Cancel the thread sync lock | |
719 | + raise | |
720 | + else: | |
721 | + # Don't wait for a call init update - base the call ID on the number of active calls | |
722 | + self.write('ATD{0};'.format(number), timeout=timeout, waitForResponse=self._waitForAtdResponse) | |
723 | + self.log.debug("Not waiting for outgoing call init update message") | |
724 | + callId = len(self.activeCalls) + 1 | |
725 | + callType = 0 # Assume voice | |
726 | + call = Call(self, callId, callType, number, callStatusUpdateCallbackFunc) | |
727 | + self.activeCalls[callId] = call | |
728 | + return call | |
729 | + | |
730 | + if self._mustPollCallStatus: | |
731 | + # Fake a call notification by polling call status until the status indicates that the call is being dialed | |
732 | + threading.Thread(target=self._pollCallStatus, kwargs={'expectedState': 0, 'timeout': timeout}).start() | |
733 | + | |
734 | + if self._dialEvent.wait(timeout): | |
735 | + self._dialEvent = None | |
736 | + callId, callType = self._dialResponse | |
737 | + call = Call(self, callId, callType, number, callStatusUpdateCallbackFunc) | |
738 | + self.activeCalls[callId] = call | |
739 | + return call | |
740 | + else: # Call establishing timed out | |
741 | + self._dialEvent = None | |
742 | + raise TimeoutException() | |
743 | + | |
744 | + def processStoredSms(self, unreadOnly=False): | |
745 | + """ Process all SMS messages currently stored on the device/SIM card. | |
746 | + | |
747 | + Reads all (or just unread) received SMS messages currently stored on the | |
748 | + device/SIM card, initiates "SMS received" events for them, and removes | |
749 | + them from the SIM card. | |
750 | + This is useful if SMS messages were received during a period that | |
751 | + python-gsmmodem was not running but the modem was powered on. | |
752 | + | |
753 | + :param unreadOnly: If True, only process unread SMS messages | |
754 | + :type unreadOnly: boolean | |
755 | + """ | |
756 | + states = [Sms.STATUS_RECEIVED_UNREAD] | |
757 | + if not unreadOnly: | |
758 | + states.insert(0, Sms.STATUS_RECEIVED_READ) | |
759 | + for msgStatus in states: | |
760 | + messages = self.listStoredSms(status=msgStatus, delete=True) | |
761 | + for sms in messages: | |
762 | + self.smsReceivedCallback(sms) | |
763 | + | |
764 | + def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): | |
765 | + """ Returns SMS messages currently stored on the device/SIM card. | |
766 | + | |
767 | + The messages are read from the memory set by the "memory" parameter. | |
768 | + | |
769 | + :param status: Filter messages based on this read status; must be 0-4 (see Sms class) | |
770 | + :type status: int | |
771 | + :param memory: The memory type to read from. If None, use the current default SMS read memory | |
772 | + :type memory: str or None | |
773 | + :param delete: If True, delete returned messages from the device/SIM card | |
774 | + :type delete: bool | |
775 | + | |
776 | + :return: A list of Sms objects containing the messages read | |
777 | + :rtype: list | |
778 | + """ | |
779 | + self._setSmsMemory(readDelete=memory) | |
780 | + messages = [] | |
781 | + delMessages = set() | |
782 | + if self._smsTextMode: | |
783 | + cmglRegex= re.compile(r'^\+CMGL: (\d+),"([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') | |
784 | + for key, val in dictItemsIter(Sms.TEXT_MODE_STATUS_MAP): | |
785 | + if status == val: | |
786 | + statusStr = key | |
787 | + break | |
788 | + else: | |
789 | + raise ValueError('Invalid status value: {0}'.format(status)) | |
790 | + result = self.write('AT+CMGL="{0}"'.format(statusStr)) | |
791 | + msgLines = [] | |
792 | + msgIndex = msgStatus = number = msgTime = None | |
793 | + for line in result: | |
794 | + cmglMatch = cmglRegex.match(line) | |
795 | + if cmglMatch: | |
796 | + # New message; save old one if applicable | |
797 | + if msgIndex != None and len(msgLines) > 0: | |
798 | + msgText = '\n'.join(msgLines) | |
799 | + msgLines = [] | |
800 | + messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText)) | |
801 | + delMessages.add(int(msgIndex)) | |
802 | + msgIndex, msgStatus, number, msgTime = cmglMatch.groups() | |
803 | + msgLines = [] | |
804 | + else: | |
805 | + if line != 'OK': | |
806 | + msgLines.append(line) | |
807 | + if msgIndex != None and len(msgLines) > 0: | |
808 | + msgText = '\n'.join(msgLines) | |
809 | + msgLines = [] | |
810 | + messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText)) | |
811 | + delMessages.add(int(msgIndex)) | |
812 | + else: | |
813 | + cmglRegex = re.compile(r'^\+CMGL:\s*(\d+),\s*(\d+),.*$') | |
814 | + readPdu = False | |
815 | + result = self.write('AT+CMGL={0}'.format(status)) | |
816 | + for line in result: | |
817 | + if not readPdu: | |
818 | + cmglMatch = cmglRegex.match(line) | |
819 | + if cmglMatch: | |
820 | + msgIndex = int(cmglMatch.group(1)) | |
821 | + msgStat = int(cmglMatch.group(2)) | |
822 | + readPdu = True | |
823 | + else: | |
824 | + try: | |
825 | + smsDict = decodeSmsPdu(line) | |
826 | + except EncodingError: | |
827 | + self.log.debug('Discarding line from +CMGL response: %s', line) | |
828 | + else: | |
829 | + if smsDict['type'] == 'SMS-DELIVER': | |
830 | + sms = ReceivedSms(self, int(msgStat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc']) | |
831 | + elif smsDict['type'] == 'SMS-STATUS-REPORT': | |
832 | + sms = StatusReport(self, int(msgStat), smsDict['reference'], smsDict['number'], smsDict['time'], smsDict['discharge'], smsDict['status']) | |
833 | + else: | |
834 | + raise CommandError('Invalid PDU type for readStoredSms(): {0}'.format(smsDict['type'])) | |
835 | + messages.append(sms) | |
836 | + delMessages.add(msgIndex) | |
837 | + readPdu = False | |
838 | + if delete: | |
839 | + if status == Sms.STATUS_ALL: | |
840 | + # Delete all messages | |
841 | + self.deleteMultipleStoredSms() | |
842 | + else: | |
843 | + for msgIndex in delMessages: | |
844 | + self.deleteStoredSms(msgIndex) | |
845 | + return messages | |
846 | + | |
847 | + def _handleModemNotification(self, lines): | |
848 | + """ Handler for unsolicited notifications from the modem | |
849 | + | |
850 | + This method simply spawns a separate thread to handle the actual notification | |
851 | + (in order to release the read thread so that the handlers are able to write back to the modem, etc) | |
852 | + | |
853 | + :param lines The lines that were read | |
854 | + """ | |
855 | + threading.Thread(target=self.__threadedHandleModemNotification, kwargs={'lines': lines}).start() | |
856 | + | |
857 | + def __threadedHandleModemNotification(self, lines): | |
858 | + """ Implementation of _handleModemNotification() to be run in a separate thread | |
859 | + | |
860 | + :param lines The lines that were read | |
861 | + """ | |
862 | + for line in lines: | |
863 | + if 'RING' in line: | |
864 | + # Incoming call (or existing call is ringing) | |
865 | + self._handleIncomingCall(lines) | |
866 | + return | |
867 | + elif line.startswith('+CMTI'): | |
868 | + # New SMS message indication | |
869 | + self._handleSmsReceived(line) | |
870 | + return | |
871 | + elif line.startswith('+CUSD'): | |
872 | + # USSD notification - either a response or a MT-USSD ("push USSD") message | |
873 | + self._handleUssd(lines) | |
874 | + return | |
875 | + elif line.startswith('+CDSI'): | |
876 | + # SMS status report | |
877 | + self._handleSmsStatusReport(line) | |
878 | + return | |
879 | + else: | |
880 | + # Check for call status updates | |
881 | + for updateRegex, handlerFunc in self._callStatusUpdates: | |
882 | + match = updateRegex.match(line) | |
883 | + if match: | |
884 | + # Handle the update | |
885 | + handlerFunc(match) | |
886 | + return | |
887 | + # If this is reached, the notification wasn't handled | |
888 | + self.log.debug('Unhandled unsolicited modem notification: %s', lines) | |
889 | + | |
890 | + def _handleIncomingCall(self, lines): | |
891 | + self.log.debug('Handling incoming call') | |
892 | + ringLine = lines.pop(0) | |
893 | + if self._extendedIncomingCallIndication: | |
894 | + try: | |
895 | + callType = ringLine.split(' ', 1)[1] | |
896 | + except IndexError: | |
897 | + # Some external 3G scripts modify incoming call indication settings (issue #18) | |
898 | + self.log.debug('Extended incoming call indication format changed externally; re-enabling...') | |
899 | + callType = None | |
900 | + try: | |
901 | + # Re-enable extended format of incoming indication (optional) | |
902 | + self.write('AT+CRC=1') | |
903 | + except CommandError: | |
904 | + self.log.warn('Extended incoming call indication format changed externally; unable to re-enable') | |
905 | + self._extendedIncomingCallIndication = False | |
906 | + else: | |
907 | + callType = None | |
908 | + if self._callingLineIdentification and len(lines) > 0: | |
909 | + clipLine = lines.pop(0) | |
910 | + clipMatch = self.CLIP_REGEX.match(clipLine) | |
911 | + if clipMatch: | |
912 | + callerNumber = clipMatch.group(1) | |
913 | + ton = clipMatch.group(2) | |
914 | + #TODO: re-add support for this | |
915 | + callerName = None | |
916 | + #callerName = clipMatch.group(3) | |
917 | + #if callerName != None and len(callerName) == 0: | |
918 | + # callerName = None | |
919 | + else: | |
920 | + callerNumber = ton = callerName = None | |
921 | + else: | |
922 | + callerNumber = ton = callerName = None | |
923 | + | |
924 | + call = None | |
925 | + for activeCall in dictValuesIter(self.activeCalls): | |
926 | + if activeCall.number == callerNumber: | |
927 | + call = activeCall | |
928 | + call.ringCount += 1 | |
929 | + if call == None: | |
930 | + callId = len(self.activeCalls) + 1; | |
931 | + call = IncomingCall(self, callerNumber, ton, callerName, callId, callType) | |
932 | + self.activeCalls[callId] = call | |
933 | + self.incomingCallCallback(call) | |
934 | + | |
935 | + def _handleCallInitiated(self, regexMatch, callId=None, callType=1): | |
936 | + """ Handler for "outgoing call initiated" event notification line """ | |
937 | + if self._dialEvent: | |
938 | + if regexMatch: | |
939 | + groups = regexMatch.groups() | |
940 | + # Set self._dialReponse to (callId, callType) | |
941 | + if len(groups) >= 2: | |
942 | + self._dialResponse = (int(groups[0]) , int(groups[1])) | |
943 | + else: | |
944 | + self._dialResponse = (int(groups[0]), 1) # assume call type: VOICE | |
945 | + else: | |
946 | + self._dialResponse = callId, callType | |
947 | + self._dialEvent.set() | |
948 | + | |
949 | + def _handleCallAnswered(self, regexMatch, callId=None): | |
950 | + """ Handler for "outgoing call answered" event notification line """ | |
951 | + if regexMatch: | |
952 | + groups = regexMatch.groups() | |
953 | + if len(groups) > 1: | |
954 | + callId = int(groups[0]) | |
955 | + self.activeCalls[callId].answered = True | |
956 | + else: | |
957 | + # Call ID not available for this notificition - check for the first outgoing call that has not been answered | |
958 | + for call in dictValuesIter(self.activeCalls): | |
959 | + if call.answered == False and type(call) == Call: | |
960 | + call.answered = True | |
961 | + return | |
962 | + else: | |
963 | + # Use supplied values | |
964 | + self.activeCalls[callId].answered = True | |
965 | + | |
966 | + def _handleCallEnded(self, regexMatch, callId=None, filterUnanswered=False): | |
967 | + if regexMatch: | |
968 | + groups = regexMatch.groups() | |
969 | + if len(groups) > 0: | |
970 | + callId = int(groups[0]) | |
971 | + else: | |
972 | + # Call ID not available for this notification - check for the first outgoing call that is active | |
973 | + for call in dictValuesIter(self.activeCalls): | |
974 | + if type(call) == Call: | |
975 | + if not filterUnanswered or (filterUnanswered == True and call.answered == False): | |
976 | + callId = call.id | |
977 | + break | |
978 | + if callId and callId in self.activeCalls: | |
979 | + self.activeCalls[callId].answered = False | |
980 | + self.activeCalls[callId].active = False | |
981 | + del self.activeCalls[callId] | |
982 | + | |
983 | + def _handleCallRejected(self, regexMatch, callId=None): | |
984 | + """ Handler for rejected (unanswered calls being ended) | |
985 | + | |
986 | + Most modems use _handleCallEnded for handling both call rejections and remote hangups. | |
987 | + This method does the same, but filters for unanswered calls only. | |
988 | + """ | |
989 | + return self._handleCallEnded(regexMatch, callId, True) | |
990 | + | |
991 | + def _handleSmsReceived(self, notificationLine): | |
992 | + """ Handler for "new SMS" unsolicited notification line """ | |
993 | + self.log.debug('SMS message received') | |
994 | + cmtiMatch = self.CMTI_REGEX.match(notificationLine) | |
995 | + if cmtiMatch: | |
996 | + msgMemory = cmtiMatch.group(1) | |
997 | + msgIndex = cmtiMatch.group(2) | |
998 | + sms = self.readStoredSms(msgIndex, msgMemory) | |
999 | + self.deleteStoredSms(msgIndex) | |
1000 | + self.smsReceivedCallback(sms) | |
1001 | + | |
1002 | + def _handleSmsStatusReport(self, notificationLine): | |
1003 | + """ Handler for SMS status reports """ | |
1004 | + self.log.debug('SMS status report received') | |
1005 | + cdsiMatch = self.CDSI_REGEX.match(notificationLine) | |
1006 | + if cdsiMatch: | |
1007 | + msgMemory = cdsiMatch.group(1) | |
1008 | + msgIndex = cdsiMatch.group(2) | |
1009 | + report = self.readStoredSms(msgIndex, msgMemory) | |
1010 | + self.deleteStoredSms(msgIndex) | |
1011 | + # Update sent SMS status if possible | |
1012 | + if report.reference in self.sentSms: | |
1013 | + self.sentSms[report.reference].report = report | |
1014 | + if self._smsStatusReportEvent: | |
1015 | + # A sendSms() call is waiting for this response - notify waiting thread | |
1016 | + self._smsStatusReportEvent.set() | |
1017 | + else: | |
1018 | + # Nothing is waiting for this report directly - use callback | |
1019 | + self.smsStatusReportCallback(report) | |
1020 | + | |
1021 | + def readStoredSms(self, index, memory=None): | |
1022 | + """ Reads and returns the SMS message at the specified index | |
1023 | + | |
1024 | + :param index: The index of the SMS message in the specified memory | |
1025 | + :type index: int | |
1026 | + :param memory: The memory type to read from. If None, use the current default SMS read memory | |
1027 | + :type memory: str or None | |
1028 | + | |
1029 | + :raise CommandError: if unable to read the stored message | |
1030 | + | |
1031 | + :return: The SMS message | |
1032 | + :rtype: subclass of gsmmodem.modem.Sms (either ReceivedSms or StatusReport) | |
1033 | + """ | |
1034 | + # Switch to the correct memory type if required | |
1035 | + self._setSmsMemory(readDelete=memory) | |
1036 | + msgData = self.write('AT+CMGR={0}'.format(index)) | |
1037 | + # Parse meta information | |
1038 | + if self._smsTextMode: | |
1039 | + cmgrMatch = self.CMGR_SM_DELIVER_REGEX_TEXT.match(msgData[0]) | |
1040 | + if cmgrMatch: | |
1041 | + msgStatus, number, msgTime = cmgrMatch.groups() | |
1042 | + msgText = '\n'.join(msgData[1:-1]) | |
1043 | + return ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText) | |
1044 | + else: | |
1045 | + # Try parsing status report | |
1046 | + cmgrMatch = self.CMGR_SM_REPORT_REGEXT_TEXT.match(msgData[0]) | |
1047 | + if cmgrMatch: | |
1048 | + msgStatus, reference, number, sentTime, deliverTime, deliverStatus = cmgrMatch.groups() | |
1049 | + if msgStatus.startswith('"'): | |
1050 | + msgStatus = msgStatus[1:-1] | |
1051 | + if len(msgStatus) == 0: | |
1052 | + msgStatus = "REC UNREAD" | |
1053 | + return StatusReport(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], int(reference), number, parseTextModeTimeStr(sentTime), parseTextModeTimeStr(deliverTime), int(deliverStatus)) | |
1054 | + else: | |
1055 | + raise CommandError('Failed to parse text-mode SMS message +CMGR response: {0}'.format(msgData)) | |
1056 | + else: | |
1057 | + cmgrMatch = self.CMGR_REGEX_PDU.match(msgData[0]) | |
1058 | + if not cmgrMatch: | |
1059 | + raise CommandError('Failed to parse PDU-mode SMS message +CMGR response: {0}'.format(msgData)) | |
1060 | + stat, alpha, length = cmgrMatch.groups() | |
1061 | + try: | |
1062 | + stat = int(stat) | |
1063 | + except Exception: | |
1064 | + # Some modems (ZTE) do not always read return status - default to RECEIVED UNREAD | |
1065 | + stat = Sms.STATUS_RECEIVED_UNREAD | |
1066 | + pdu = msgData[1] | |
1067 | + smsDict = decodeSmsPdu(pdu) | |
1068 | + if smsDict['type'] == 'SMS-DELIVER': | |
1069 | + return ReceivedSms(self, int(stat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc']) | |
1070 | + elif smsDict['type'] == 'SMS-STATUS-REPORT': | |
1071 | + return StatusReport(self, int(stat), smsDict['reference'], smsDict['number'], smsDict['time'], smsDict['discharge'], smsDict['status']) | |
1072 | + else: | |
1073 | + raise CommandError('Invalid PDU type for readStoredSms(): {0}'.format(smsDict['type'])) | |
1074 | + | |
1075 | + def deleteStoredSms(self, index, memory=None): | |
1076 | + """ Deletes the SMS message stored at the specified index in modem/SIM card memory | |
1077 | + | |
1078 | + :param index: The index of the SMS message in the specified memory | |
1079 | + :type index: int | |
1080 | + :param memory: The memory type to delete from. If None, use the current default SMS read/delete memory | |
1081 | + :type memory: str or None | |
1082 | + | |
1083 | + :raise CommandError: if unable to delete the stored message | |
1084 | + """ | |
1085 | + self._setSmsMemory(readDelete=memory) | |
1086 | + self.write('AT+CMGD={0},0'.format(index)) | |
1087 | + | |
1088 | + def deleteMultipleStoredSms(self, delFlag=4, memory=None): | |
1089 | + """ Deletes all SMS messages that have the specified read status. | |
1090 | + | |
1091 | + The messages are read from the memory set by the "memory" parameter. | |
1092 | + The value of the "delFlag" paramater is the same as the "DelFlag" parameter of the +CMGD command: | |
1093 | + 1: Delete All READ messages | |
1094 | + 2: Delete All READ and SENT messages | |
1095 | + 3: Delete All READ, SENT and UNSENT messages | |
1096 | + 4: Delete All messages (this is the default) | |
1097 | + | |
1098 | + :param delFlag: Controls what type of messages to delete; see description above. | |
1099 | + :type delFlag: int | |
1100 | + :param memory: The memory type to delete from. If None, use the current default SMS read/delete memory | |
1101 | + :type memory: str or None | |
1102 | + :param delete: If True, delete returned messages from the device/SIM card | |
1103 | + :type delete: bool | |
1104 | + | |
1105 | + :raise ValueErrror: if "delFlag" is not in range [1,4] | |
1106 | + :raise CommandError: if unable to delete the stored messages | |
1107 | + """ | |
1108 | + if 0 < delFlag <= 4: | |
1109 | + self._setSmsMemory(readDelete=memory) | |
1110 | + self.write('AT+CMGD=1,{0}'.format(delFlag)) | |
1111 | + else: | |
1112 | + raise ValueError('"delFlag" must be in range [1,4]') | |
1113 | + | |
1114 | + def _handleUssd(self, lines): | |
1115 | + """ Handler for USSD event notification line(s) """ | |
1116 | + if self._ussdSessionEvent: | |
1117 | + # A sendUssd() call is waiting for this response - parse it | |
1118 | + self._ussdResponse = self._parseCusdResponse(lines) | |
1119 | + # Notify waiting thread | |
1120 | + self._ussdSessionEvent.set() | |
1121 | + | |
1122 | + def _parseCusdResponse(self, lines): | |
1123 | + """ Parses one or more +CUSD notification lines (for USSD) | |
1124 | + :return: USSD response object | |
1125 | + :rtype: gsmmodem.modem.Ussd | |
1126 | + """ | |
1127 | + if len(lines) > 1: | |
1128 | + # Issue #20: Some modem/network combinations use \r\n as in-message EOL indicators; | |
1129 | + # - join lines to compensate for that (thanks to davidjb for the fix) | |
1130 | + # Also, look for more than one +CUSD response because of certain modems' strange behaviour | |
1131 | + cusdMatches = list(self.CUSD_REGEX.finditer('\r\n'.join(lines))) | |
1132 | + else: | |
1133 | + # Single standard +CUSD response | |
1134 | + cusdMatches = [self.CUSD_REGEX.match(lines[0])] | |
1135 | + message = None | |
1136 | + sessionActive = True | |
1137 | + if len(cusdMatches) > 1: | |
1138 | + self.log.debug('Multiple +CUSD responses received; filtering...') | |
1139 | + # Some modems issue a non-standard "extra" +CUSD notification for releasing the session | |
1140 | + for cusdMatch in cusdMatches: | |
1141 | + if cusdMatch.group(1) == '2': | |
1142 | + # Set the session to inactive, but ignore the message | |
1143 | + self.log.debug('Ignoring "session release" message: %s', cusdMatch.group(2)) | |
1144 | + sessionActive = False | |
1145 | + else: | |
1146 | + # Not a "session release" message | |
1147 | + message = cusdMatch.group(2) | |
1148 | + if sessionActive and cusdMatch.group(1) != '1': | |
1149 | + sessionActive = False | |
1150 | + else: | |
1151 | + sessionActive = cusdMatches[0].group(1) == '1' | |
1152 | + message = cusdMatches[0].group(2) | |
1153 | + return Ussd(self, sessionActive, message) | |
1154 | + | |
1155 | + def _placeHolderCallback(self, *args): | |
1156 | + """ Does nothing """ | |
1157 | + self.log.debug('called with args: {0}'.format(args)) | |
1158 | + | |
1159 | + def _pollCallStatus(self, expectedState, callId=None, timeout=None): | |
1160 | + """ Poll the status of outgoing calls. | |
1161 | + This is used for modems that do not have a known set of call status update notifications. | |
1162 | + | |
1163 | + :param expectedState: The internal state we are waiting for. 0 == initiated, 1 == answered, 2 = hangup | |
1164 | + :type expectedState: int | |
1165 | + | |
1166 | + :raise TimeoutException: If a timeout was specified, and has occurred | |
1167 | + """ | |
1168 | + callDone = False | |
1169 | + timeLeft = timeout or 999999 | |
1170 | + while self.alive and not callDone and timeLeft > 0: | |
1171 | + time.sleep(0.5) | |
1172 | + if expectedState == 0: # Only call initializing can timeout | |
1173 | + timeLeft -= 0.5 | |
1174 | + try: | |
1175 | + clcc = self._pollCallStatusRegex.match(self.write('AT+CLCC')[0]) | |
1176 | + except TimeoutException as timeout: | |
1177 | + # Can happend if the call was ended during our time.sleep() call | |
1178 | + clcc = None | |
1179 | + if clcc: | |
1180 | + direction = int(clcc.group(2)) | |
1181 | + if direction == 0: # Outgoing call | |
1182 | + # Determine call state | |
1183 | + stat = int(clcc.group(3)) | |
1184 | + if expectedState == 0: # waiting for call initiated | |
1185 | + if stat == 2 or stat == 3: # Dialing or ringing ("alerting") | |
1186 | + callId = int(clcc.group(1)) | |
1187 | + callType = int(clcc.group(4)) | |
1188 | + self._handleCallInitiated(None, callId, callType) # if self_dialEvent is None, this does nothing | |
1189 | + expectedState = 1 # Now wait for call answer | |
1190 | + elif expectedState == 1: # waiting for call to be answered | |
1191 | + if stat == 0: # Call active | |
1192 | + callId = int(clcc.group(1)) | |
1193 | + self._handleCallAnswered(None, callId) | |
1194 | + expectedState = 2 # Now wait for call hangup | |
1195 | + elif expectedState == 2 : # waiting for remote hangup | |
1196 | + # Since there was no +CLCC response, the call is no longer active | |
1197 | + callDone = True | |
1198 | + self._handleCallEnded(None, callId=callId) | |
1199 | + elif expectedState == 1: # waiting for call to be answered | |
1200 | + # Call was rejected | |
1201 | + callDone = True | |
1202 | + self._handleCallRejected(None, callId=callId) | |
1203 | + if timeLeft <= 0: | |
1204 | + raise TimeoutException() | |
1205 | + | |
1206 | + | |
1207 | +class Call(object): | |
1208 | + """ A voice call """ | |
1209 | + | |
1210 | + DTMF_COMMAND_BASE = '+VTS=' | |
1211 | + dtmfSupport = False # Indicates whether or not DTMF tones can be sent in calls | |
1212 | + | |
1213 | + def __init__(self, gsmModem, callId, callType, number, callStatusUpdateCallbackFunc=None): | |
1214 | + """ | |
1215 | + :param gsmModem: GsmModem instance that created this object | |
1216 | + :param number: The number that is being called | |
1217 | + """ | |
1218 | + self._gsmModem = weakref.proxy(gsmModem) | |
1219 | + self._callStatusUpdateCallbackFunc = callStatusUpdateCallbackFunc | |
1220 | + # Unique ID of this call | |
1221 | + self.id = callId | |
1222 | + # Call type (VOICE == 0, etc) | |
1223 | + self.type = callType | |
1224 | + # The remote number of this call (destination or origin) | |
1225 | + self.number = number | |
1226 | + # Flag indicating whether the call has been answered or not (backing field for "answered" property) | |
1227 | + self._answered = False | |
1228 | + # Flag indicating whether or not the call is active | |
1229 | + # (meaning it may be ringing or answered, but not ended because of a hangup event) | |
1230 | + self.active = True | |
1231 | + | |
1232 | + @property | |
1233 | + def answered(self): | |
1234 | + return self._answered | |
1235 | + @answered.setter | |
1236 | + def answered(self, answered): | |
1237 | + self._answered = answered | |
1238 | + if self._callStatusUpdateCallbackFunc: | |
1239 | + self._callStatusUpdateCallbackFunc(self) | |
1240 | + | |
1241 | + def sendDtmfTone(self, tones): | |
1242 | + """ Send one or more DTMF tones to the remote party (only allowed for an answered call) | |
1243 | + | |
1244 | + Note: this is highly device-dependent, and might not work | |
1245 | + | |
1246 | + :param digits: A str containining one or more DTMF tones to play, e.g. "3" or "\*123#" | |
1247 | + | |
1248 | + :raise CommandError: if the command failed/is not supported | |
1249 | + :raise InvalidStateException: if the call has not been answered, or is ended while the command is still executing | |
1250 | + """ | |
1251 | + if self.answered: | |
1252 | + dtmfCommandBase = self.DTMF_COMMAND_BASE.format(cid=self.id) | |
1253 | + toneLen = len(tones) | |
1254 | + if len(tones) > 1: | |
1255 | + cmd = ('AT{0}{1};{0}' + ';{0}'.join(tones[1:])).format(dtmfCommandBase, tones[0]) | |
1256 | + else: | |
1257 | + cmd = 'AT{0}{1}'.format(dtmfCommandBase, tones) | |
1258 | + try: | |
1259 | + self._gsmModem.write(cmd, timeout=(5 + toneLen)) | |
1260 | + except CmeError as e: | |
1261 | + if e.code == 30: | |
1262 | + # No network service - can happen if call is ended during DTMF transmission (but also if DTMF is sent immediately after call is answered) | |
1263 | + raise InterruptedException('No network service', e) | |
1264 | + elif e.code == 3: | |
1265 | + # Operation not allowed - can happen if call is ended during DTMF transmission | |
1266 | + raise InterruptedException('Operation not allowed', e) | |
1267 | + else: | |
1268 | + raise e | |
1269 | + else: | |
1270 | + raise InvalidStateException('Call is not active (it has not yet been answered, or it has ended).') | |
1271 | + | |
1272 | + def hangup(self): | |
1273 | + """ End the phone call. | |
1274 | + | |
1275 | + Does nothing if the call is already inactive. | |
1276 | + """ | |
1277 | + if self.active: | |
1278 | + self._gsmModem.write('ATH') | |
1279 | + self.answered = False | |
1280 | + self.active = False | |
1281 | + if self.id in self._gsmModem.activeCalls: | |
1282 | + del self._gsmModem.activeCalls[self.id] | |
1283 | + | |
1284 | + | |
1285 | +class IncomingCall(Call): | |
1286 | + | |
1287 | + CALL_TYPE_MAP = {'VOICE': 0} | |
1288 | + | |
1289 | + """ Represents an incoming call, conveniently allowing access to call meta information and -control """ | |
1290 | + def __init__(self, gsmModem, number, ton, callerName, callId, callType): | |
1291 | + """ | |
1292 | + :param gsmModem: GsmModem instance that created this object | |
1293 | + :param number: Caller number | |
1294 | + :param ton: TON (type of number/address) in integer format | |
1295 | + :param callType: Type of the incoming call (VOICE, FAX, DATA, etc) | |
1296 | + """ | |
1297 | + if type(callType) == str: | |
1298 | + callType = self.CALL_TYPE_MAP[callType] | |
1299 | + super(IncomingCall, self).__init__(gsmModem, callId, callType, number) | |
1300 | + # Type attribute of the incoming call | |
1301 | + self.ton = ton | |
1302 | + self.callerName = callerName | |
1303 | + # Flag indicating whether the call is ringing or not | |
1304 | + self.ringing = True | |
1305 | + # Amount of times this call has rung (before answer/hangup) | |
1306 | + self.ringCount = 1 | |
1307 | + | |
1308 | + def answer(self): | |
1309 | + """ Answer the phone call. | |
1310 | + :return: self (for chaining method calls) | |
1311 | + """ | |
1312 | + if self.ringing: | |
1313 | + self._gsmModem.write('ATA') | |
1314 | + self.ringing = False | |
1315 | + self.answered = True | |
1316 | + return self | |
1317 | + | |
1318 | + def hangup(self): | |
1319 | + """ End the phone call. """ | |
1320 | + self.ringing = False | |
1321 | + super(IncomingCall, self).hangup() | |
1322 | + | |
1323 | +class Ussd(object): | |
1324 | + """ Unstructured Supplementary Service Data (USSD) message. | |
1325 | + | |
1326 | + This class contains convenient methods for replying to a USSD prompt | |
1327 | + and to cancel the USSD session | |
1328 | + """ | |
1329 | + | |
1330 | + def __init__(self, gsmModem, sessionActive, message): | |
1331 | + self._gsmModem = weakref.proxy(gsmModem) | |
1332 | + # Indicates if the session is active (True) or has been closed (False) | |
1333 | + self.sessionActive = sessionActive | |
1334 | + self.message = message | |
1335 | + | |
1336 | + def reply(self, message): | |
1337 | + """ Sends a reply to this USSD message in the same USSD session | |
1338 | + | |
1339 | + :raise InvalidStateException: if the USSD session is not active (i.e. it has ended) | |
1340 | + | |
1341 | + :return: The USSD response message/session (as a Ussd object) | |
1342 | + """ | |
1343 | + if self.sessionActive: | |
1344 | + return self._gsmModem.sendUssd(message) | |
1345 | + else: | |
1346 | + raise InvalidStateException('USSD session is inactive') | |
1347 | + | |
1348 | + def cancel(self): | |
1349 | + """ Terminates/cancels the USSD session (without sending a reply) | |
1350 | + | |
1351 | + Does nothing if the USSD session is inactive. | |
1352 | + """ | |
1353 | + if self.sessionActive: | |
1354 | + self._gsmModem.write('AT+CUSD=2') |
gsmmodem/pdu.py
... | ... | @@ -0,0 +1,822 @@ |
1 | +# -*- coding: utf8 -*- | |
2 | + | |
3 | +""" SMS PDU encoding methods """ | |
4 | + | |
5 | +from __future__ import unicode_literals | |
6 | + | |
7 | +import sys, codecs, math | |
8 | +from datetime import datetime, timedelta, tzinfo | |
9 | +from copy import copy | |
10 | +from .exceptions import EncodingError | |
11 | + | |
12 | +# For Python 3 support | |
13 | +PYTHON_VERSION = sys.version_info[0] | |
14 | +if PYTHON_VERSION >= 3: | |
15 | + MAX_INT = sys.maxsize | |
16 | + dictItemsIter = dict.items | |
17 | + xrange = range | |
18 | + unichr = chr | |
19 | + toByteArray = lambda x: bytearray(codecs.decode(x, 'hex_codec')) if type(x) == bytes else bytearray(codecs.decode(bytes(x, 'ascii'), 'hex_codec')) if type(x) == str else x | |
20 | + rawStrToByteArray = lambda x: bytearray(bytes(x, 'latin-1')) | |
21 | +else: #pragma: no cover | |
22 | + MAX_INT = sys.maxint | |
23 | + dictItemsIter = dict.iteritems | |
24 | + toByteArray = lambda x: bytearray(x.decode('hex')) if type(x) in (str, unicode) else x | |
25 | + rawStrToByteArray = bytearray | |
26 | + | |
27 | +# Tables can be found at: http://en.wikipedia.org/wiki/GSM_03.38#GSM_7_bit_default_alphabet_and_extension_table_of_3GPP_TS_23.038_.2F_GSM_03.38 | |
28 | +GSM7_BASIC = ('@ยฃ$ยฅรจรฉรนรฌรฒร\nรรธ\rร รฅฮ_ฮฆฮฮฮฉฮ ฮจฮฃฮฮ\x1bรรฆรร !\"#ยค%&\'()*+,-./0123456789:;<=>?ยกABCDEFGHIJKLMNOPQRSTUVWXYZรรรร`ยฟabcdefghijklmnopqrstuvwxyzรครถรฑรผร ') | |
29 | +GSM7_EXTENDED = {chr(0xFF): 0x0A, | |
30 | + #CR2: chr(0x0D), | |
31 | + '^': chr(0x14), | |
32 | + #SS2: chr(0x1B), | |
33 | + '{': chr(0x28), | |
34 | + '}': chr(0x29), | |
35 | + '\\': chr(0x2F), | |
36 | + '[': chr(0x3C), | |
37 | + '~': chr(0x3D), | |
38 | + ']': chr(0x3E), | |
39 | + '|': chr(0x40), | |
40 | + 'โฌ': chr(0x65)} | |
41 | +# Maximum message sizes for each data coding | |
42 | +MAX_MESSAGE_LENGTH = {0x00: 160, # GSM-7 | |
43 | + 0x04: 140, # 8-bit | |
44 | + 0x08: 70} # UCS2 | |
45 | + | |
46 | +class SmsPduTzInfo(tzinfo): | |
47 | + """ Simple implementation of datetime.tzinfo for handling timestamp GMT offsets specified in SMS PDUs """ | |
48 | + | |
49 | + def __init__(self, pduOffsetStr=None): | |
50 | + """ | |
51 | + :param pduOffset: 2 semi-octet timezone offset as specified by PDU (see GSM 03.40 spec) | |
52 | + :type pduOffset: str | |
53 | + | |
54 | + Note: pduOffsetStr is optional in this constructor due to the special requirement for pickling | |
55 | + mentioned in the Python docs. It should, however, be used (or otherwise pduOffsetStr must be | |
56 | + manually set) | |
57 | + """ | |
58 | + self._offset = None | |
59 | + if pduOffsetStr != None: | |
60 | + self._setPduOffsetStr(pduOffsetStr) | |
61 | + | |
62 | + def _setPduOffsetStr(self, pduOffsetStr): | |
63 | + # See if the timezone difference is positive/negative by checking MSB of first semi-octet | |
64 | + tzHexVal = int(pduOffsetStr, 16) | |
65 | + if tzHexVal & 0x80 == 0: # positive | |
66 | + self._offset = timedelta(minutes=(int(pduOffsetStr) * 15)) | |
67 | + else: # negative | |
68 | + self._offset = timedelta(minutes=(int('{0:0>2X}'.format(tzHexVal & 0x7F)) * -15)) | |
69 | + | |
70 | + def utcoffset(self, dt): | |
71 | + return self._offset | |
72 | + | |
73 | + def dst(self, dt): | |
74 | + """ We do not have enough info in the SMS PDU to implement daylight savings time """ | |
75 | + return timedelta(0) | |
76 | + | |
77 | + | |
78 | +class InformationElement(object): | |
79 | + """ User Data Header (UDH) Information Element (IE) implementation | |
80 | + | |
81 | + This represents a single field ("information element") in the PDU's | |
82 | + User Data Header. The UDH itself contains one or more of these | |
83 | + information elements. | |
84 | + | |
85 | + If the IEI (IE identifier) is recognized, the class will automatically | |
86 | + specialize into one of the subclasses of InformationElement, | |
87 | + e.g. Concatenation or PortAddress, allowing the user to easily | |
88 | + access the specific (and useful) attributes of these special cases. | |
89 | + """ | |
90 | + | |
91 | + def __new__(cls, *args, **kwargs): #iei, ieLen, ieData): | |
92 | + """ Causes a new InformationElement class, or subclass | |
93 | + thereof, to be created. If the IEI is recognized, a specific | |
94 | + subclass of InformationElement is returned """ | |
95 | + if len(args) > 0: | |
96 | + targetClass = IEI_CLASS_MAP.get(args[0], cls) | |
97 | + elif 'iei' in kwargs: | |
98 | + targetClass = IEI_CLASS_MAP.get(kwargs['iei'], cls) | |
99 | + else: | |
100 | + return super(InformationElement, cls).__new__(cls) | |
101 | + return super(InformationElement, targetClass).__new__(targetClass) | |
102 | + | |
103 | + def __init__(self, iei, ieLen=0, ieData=None): | |
104 | + self.id = iei # IEI | |
105 | + self.dataLength = ieLen # IE Length | |
106 | + self.data = ieData or [] # raw IE data | |
107 | + | |
108 | + @classmethod | |
109 | + def decode(cls, byteIter): | |
110 | + """ Decodes a single IE at the current position in the specified | |
111 | + byte iterator | |
112 | + | |
113 | + :return: An InformationElement (or subclass) instance for the decoded IE | |
114 | + :rtype: InformationElement, or subclass thereof | |
115 | + """ | |
116 | + iei = next(byteIter) | |
117 | + ieLen = next(byteIter) | |
118 | + ieData = [] | |
119 | + for i in xrange(ieLen): | |
120 | + ieData.append(next(byteIter)) | |
121 | + return InformationElement(iei, ieLen, ieData) | |
122 | + | |
123 | + def encode(self): | |
124 | + """ Encodes this IE and returns the resulting bytes """ | |
125 | + result = bytearray() | |
126 | + result.append(self.id) | |
127 | + result.append(self.dataLength) | |
128 | + result.extend(self.data) | |
129 | + return result | |
130 | + | |
131 | + def __len__(self): | |
132 | + """ Exposes the IE's total length (including the IEI and IE length octet) in octets """ | |
133 | + return self.dataLength + 2 | |
134 | + | |
135 | + | |
136 | +class Concatenation(InformationElement): | |
137 | + """ IE that indicates SMS concatenation. | |
138 | + | |
139 | + This implementation handles both 8-bit and 16-bit concatenation | |
140 | + indication, and exposes the specific useful details of this | |
141 | + IE as instance variables. | |
142 | + | |
143 | + Exposes: | |
144 | + | |
145 | + reference | |
146 | + CSMS reference number, must be same for all the SMS parts in the CSMS | |
147 | + parts | |
148 | + total number of parts. The value shall remain constant for every short | |
149 | + message which makes up the concatenated short message. If the value is zero then | |
150 | + the receiving entity shall ignore the whole information element | |
151 | + number | |
152 | + this part's number in the sequence. The value shall start at 1 and | |
153 | + increment for every short message which makes up the concatenated short message | |
154 | + """ | |
155 | + | |
156 | + def __init__(self, iei=0x00, ieLen=0, ieData=None): | |
157 | + super(Concatenation, self).__init__(iei, ieLen, ieData) | |
158 | + if ieData != None: | |
159 | + if iei == 0x00: # 8-bit reference | |
160 | + self.reference, self.parts, self.number = ieData | |
161 | + else: # 0x08: 16-bit reference | |
162 | + self.reference = ieData[0] << 8 | ieData[1] | |
163 | + self.parts = ieData[2] | |
164 | + self.number = ieData[3] | |
165 | + | |
166 | + def encode(self): | |
167 | + if self.reference > 0xFF: | |
168 | + self.id = 0x08 # 16-bit reference | |
169 | + self.data = [self.reference >> 8, self.reference & 0xFF, self.parts, self.number] | |
170 | + else: | |
171 | + self.id = 0x00 # 8-bit reference | |
172 | + self.data = [self.reference, self.parts, self.number] | |
173 | + self.dataLength = len(self.data) | |
174 | + return super(Concatenation, self).encode() | |
175 | + | |
176 | + | |
177 | +class PortAddress(InformationElement): | |
178 | + """ IE that indicates an Application Port Addressing Scheme. | |
179 | + | |
180 | + This implementation handles both 8-bit and 16-bit concatenation | |
181 | + indication, and exposes the specific useful details of this | |
182 | + IE as instance variables. | |
183 | + | |
184 | + Exposes: | |
185 | + destination: The destination port number | |
186 | + source: The source port number | |
187 | + """ | |
188 | + | |
189 | + def __init__(self, iei=0x04, ieLen=0, ieData=None): | |
190 | + super(PortAddress, self).__init__(iei, ieLen, ieData) | |
191 | + if ieData != None: | |
192 | + if iei == 0x04: # 8-bit port addressing scheme | |
193 | + self.destination, self.source = ieData | |
194 | + else: # 0x05: 16-bit port addressing scheme | |
195 | + self.destination = ieData[0] << 8 | ieData[1] | |
196 | + self.source = ieData[2] << 8 | ieData[3] | |
197 | + | |
198 | + def encode(self): | |
199 | + if self.destination > 0xFF or self.source > 0xFF: | |
200 | + self.id = 0x05 # 16-bit | |
201 | + self.data = [self.destination >> 8, self.destination & 0xFF, self.source >> 8, self.source & 0xFF] | |
202 | + else: | |
203 | + self.id = 0x04 # 8-bit | |
204 | + self.data = [self.destination, self.source] | |
205 | + self.dataLength = len(self.data) | |
206 | + return super(PortAddress, self).encode() | |
207 | + | |
208 | + | |
209 | +# Map of recognized IEIs | |
210 | +IEI_CLASS_MAP = {0x00: Concatenation, # Concatenated short messages, 8-bit reference number | |
211 | + 0x08: Concatenation, # Concatenated short messages, 16-bit reference number | |
212 | + 0x04: PortAddress, # Application port addressing scheme, 8 bit address | |
213 | + 0x05: PortAddress # Application port addressing scheme, 16 bit address | |
214 | + } | |
215 | + | |
216 | + | |
217 | +class Pdu(object): | |
218 | + """ Encoded SMS PDU. Contains raw PDU data and related meta-information """ | |
219 | + | |
220 | + def __init__(self, data, tpduLength): | |
221 | + """ Constructor | |
222 | + :param data: the raw PDU data (as bytes) | |
223 | + :type data: bytearray | |
224 | + :param tpduLength: Length (in bytes) of the TPDU | |
225 | + :type tpduLength: int | |
226 | + """ | |
227 | + self.data = data | |
228 | + self.tpduLength = tpduLength | |
229 | + | |
230 | + def __str__(self): | |
231 | + global PYTHON_VERSION | |
232 | + if PYTHON_VERSION < 3: | |
233 | + return str(self.data).encode('hex').upper() | |
234 | + else: #pragma: no cover | |
235 | + return str(codecs.encode(self.data, 'hex_codec'), 'ascii').upper() | |
236 | + | |
237 | + | |
238 | +def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requestStatusReport=True, rejectDuplicates=False, sendFlash=False): | |
239 | + """ Creates an SMS-SUBMIT PDU for sending a message with the specified text to the specified number | |
240 | + | |
241 | + :param number: the destination mobile number | |
242 | + :type number: str | |
243 | + :param text: the message text | |
244 | + :type text: str | |
245 | + :param reference: message reference number (see also: rejectDuplicates parameter) | |
246 | + :type reference: int | |
247 | + :param validity: message validity period (absolute or relative) | |
248 | + :type validity: datetime.timedelta (relative) or datetime.datetime (absolute) | |
249 | + :param smsc: SMSC number to use (leave None to use default) | |
250 | + :type smsc: str | |
251 | + :param rejectDuplicates: Flag that controls the TP-RD parameter (messages with same destination and reference may be rejected if True) | |
252 | + :type rejectDuplicates: bool | |
253 | + | |
254 | + :return: A list of one or more tuples containing the SMS PDU (as a bytearray, and the length of the TPDU part | |
255 | + :rtype: list of tuples | |
256 | + """ | |
257 | + tpduFirstOctet = 0x01 # SMS-SUBMIT PDU | |
258 | + if validity != None: | |
259 | + # Validity period format (TP-VPF) is stored in bits 4,3 of the first TPDU octet | |
260 | + if type(validity) == timedelta: | |
261 | + # Relative (TP-VP is integer) | |
262 | + tpduFirstOctet |= 0x10 # bit4 == 1, bit3 == 0 | |
263 | + validityPeriod = [_encodeRelativeValidityPeriod(validity)] | |
264 | + elif type(validity) == datetime: | |
265 | + # Absolute (TP-VP is semi-octet encoded date) | |
266 | + tpduFirstOctet |= 0x18 # bit4 == 1, bit3 == 1 | |
267 | + validityPeriod = _encodeTimestamp(validity) | |
268 | + else: | |
269 | + raise TypeError('"validity" must be of type datetime.timedelta (for relative value) or datetime.datetime (for absolute value)') | |
270 | + else: | |
271 | + validityPeriod = None | |
272 | + if rejectDuplicates: | |
273 | + tpduFirstOctet |= 0x04 # bit2 == 1 | |
274 | + if requestStatusReport: | |
275 | + tpduFirstOctet |= 0x20 # bit5 == 1 | |
276 | + | |
277 | + # Encode message text and set data coding scheme based on text contents | |
278 | + try: | |
279 | + encodedText = encodeGsm7(text) | |
280 | + except ValueError: | |
281 | + # Cannot encode text using GSM-7; use UCS2 instead | |
282 | + alphabet = 0x08 # UCS2 | |
283 | + else: | |
284 | + alphabet = 0x00 # GSM-7 | |
285 | + | |
286 | + # Check if message should be concatenated | |
287 | + if len(text) > MAX_MESSAGE_LENGTH[alphabet]: | |
288 | + # Text too long for single PDU - add "concatenation" User Data Header | |
289 | + concatHeaderPrototype = Concatenation() | |
290 | + concatHeaderPrototype.reference = reference | |
291 | + pduCount = int(len(text) / MAX_MESSAGE_LENGTH[alphabet]) + 1 | |
292 | + concatHeaderPrototype.parts = pduCount | |
293 | + tpduFirstOctet |= 0x40 | |
294 | + else: | |
295 | + concatHeaderPrototype = None | |
296 | + pduCount = 1 | |
297 | + | |
298 | + # Construct required PDU(s) | |
299 | + pdus = [] | |
300 | + for i in xrange(pduCount): | |
301 | + pdu = bytearray() | |
302 | + if smsc: | |
303 | + pdu.extend(_encodeAddressField(smsc, smscField=True)) | |
304 | + else: | |
305 | + pdu.append(0x00) # Don't supply an SMSC number - use the one configured in the device | |
306 | + | |
307 | + udh = bytearray() | |
308 | + if concatHeaderPrototype != None: | |
309 | + concatHeader = copy(concatHeaderPrototype) | |
310 | + concatHeader.number = i + 1 | |
311 | + if alphabet == 0x00: | |
312 | + pduText = text[i*153:(i+1) * 153] | |
313 | + elif alphabet == 0x08: | |
314 | + pduText = text[i * 67 : (i + 1) * 67] | |
315 | + udh.extend(concatHeader.encode()) | |
316 | + else: | |
317 | + pduText = text | |
318 | + | |
319 | + udhLen = len(udh) | |
320 | + | |
321 | + pdu.append(tpduFirstOctet) | |
322 | + pdu.append(reference) # message reference | |
323 | + # Add destination number | |
324 | + pdu.extend(_encodeAddressField(number)) | |
325 | + pdu.append(0x00) # Protocol identifier - no higher-level protocol | |
326 | + | |
327 | + pdu.append(alphabet if not sendFlash else (0x10 if alphabet == 0x00 else 0x18)) | |
328 | + if validityPeriod: | |
329 | + pdu.extend(validityPeriod) | |
330 | + | |
331 | + if alphabet == 0x00: # GSM-7 | |
332 | + encodedText = encodeGsm7(pduText) | |
333 | + userDataLength = len(encodedText) # Payload size in septets/characters | |
334 | + if udhLen > 0: | |
335 | + shift = ((udhLen + 1) * 8) % 7 # "fill bits" needed to make the UDH end on a septet boundary | |
336 | + userData = packSeptets(encodedText, padBits=shift) | |
337 | + if shift > 0: | |
338 | + userDataLength += 1 # take padding bits into account | |
339 | + else: | |
340 | + userData = packSeptets(encodedText) | |
341 | + elif alphabet == 0x08: # UCS2 | |
342 | + userData = encodeUcs2(pduText) | |
343 | + userDataLength = len(userData) | |
344 | + | |
345 | + if udhLen > 0: | |
346 | + userDataLength += udhLen + 1 # +1 for the UDH length indicator byte | |
347 | + pdu.append(userDataLength) | |
348 | + pdu.append(udhLen) | |
349 | + pdu.extend(udh) # UDH | |
350 | + else: | |
351 | + pdu.append(userDataLength) | |
352 | + pdu.extend(userData) # User Data (message payload) | |
353 | + tpdu_length = len(pdu) - 1 | |
354 | + pdus.append(Pdu(pdu, tpdu_length)) | |
355 | + return pdus | |
356 | + | |
357 | +def decodeSmsPdu(pdu): | |
358 | + """ Decodes SMS pdu data and returns a tuple in format (number, text) | |
359 | + | |
360 | + :param pdu: PDU data as a hex string, or a bytearray containing PDU octects | |
361 | + :type pdu: str or bytearray | |
362 | + | |
363 | + :raise EncodingError: If the specified PDU data cannot be decoded | |
364 | + | |
365 | + :return: The decoded SMS data as a dictionary | |
366 | + :rtype: dict | |
367 | + """ | |
368 | + try: | |
369 | + pdu = toByteArray(pdu) | |
370 | + except Exception as e: | |
371 | + # Python 2 raises TypeError, Python 3 raises binascii.Error | |
372 | + raise EncodingError(e) | |
373 | + result = {} | |
374 | + pduIter = iter(pdu) | |
375 | + | |
376 | + smscNumber, smscBytesRead = _decodeAddressField(pduIter, smscField=True) | |
377 | + result['smsc'] = smscNumber | |
378 | + result['tpdu_length'] = len(pdu) - smscBytesRead | |
379 | + | |
380 | + tpduFirstOctet = next(pduIter) | |
381 | + | |
382 | + pduType = tpduFirstOctet & 0x03 # bits 1-0 | |
383 | + if pduType == 0x00: # SMS-DELIVER or SMS-DELIVER REPORT | |
384 | + result['type'] = 'SMS-DELIVER' | |
385 | + result['number'] = _decodeAddressField(pduIter)[0] | |
386 | + result['protocol_id'] = next(pduIter) | |
387 | + dataCoding = _decodeDataCoding(next(pduIter)) | |
388 | + result['time'] = _decodeTimestamp(pduIter) | |
389 | + userDataLen = next(pduIter) | |
390 | + udhPresent = (tpduFirstOctet & 0x40) != 0 | |
391 | + ud = _decodeUserData(pduIter, userDataLen, dataCoding, udhPresent) | |
392 | + result.update(ud) | |
393 | + elif pduType == 0x01: # SMS-SUBMIT or SMS-SUBMIT-REPORT | |
394 | + result['type'] = 'SMS-SUBMIT' | |
395 | + result['reference'] = next(pduIter) # message reference - we don't really use this | |
396 | + result['number'] = _decodeAddressField(pduIter)[0] | |
397 | + result['protocol_id'] = next(pduIter) | |
398 | + dataCoding = _decodeDataCoding(next(pduIter)) | |
399 | + validityPeriodFormat = (tpduFirstOctet & 0x18) >> 3 # bits 4,3 | |
400 | + if validityPeriodFormat == 0x02: # TP-VP field present and integer represented (relative) | |
401 | + result['validity'] = _decodeRelativeValidityPeriod(next(pduIter)) | |
402 | + elif validityPeriodFormat == 0x03: # TP-VP field present and semi-octet represented (absolute) | |
403 | + result['validity'] = _decodeTimestamp(pduIter) | |
404 | + userDataLen = next(pduIter) | |
405 | + udhPresent = (tpduFirstOctet & 0x40) != 0 | |
406 | + ud = _decodeUserData(pduIter, userDataLen, dataCoding, udhPresent) | |
407 | + result.update(ud) | |
408 | + elif pduType == 0x02: # SMS-STATUS-REPORT or SMS-COMMAND | |
409 | + result['type'] = 'SMS-STATUS-REPORT' | |
410 | + result['reference'] = next(pduIter) | |
411 | + result['number'] = _decodeAddressField(pduIter)[0] | |
412 | + result['time'] = _decodeTimestamp(pduIter) | |
413 | + result['discharge'] = _decodeTimestamp(pduIter) | |
414 | + result['status'] = next(pduIter) | |
415 | + else: | |
416 | + raise EncodingError('Unknown SMS message type: {0}. First TPDU octet was: {1}'.format(pduType, tpduFirstOctet)) | |
417 | + | |
418 | + return result | |
419 | + | |
420 | +def _decodeUserData(byteIter, userDataLen, dataCoding, udhPresent): | |
421 | + """ Decodes PDU user data (UDHI (if present) and message text) """ | |
422 | + result = {} | |
423 | + if udhPresent: | |
424 | + # User Data Header is present | |
425 | + result['udh'] = [] | |
426 | + udhLen = next(byteIter) | |
427 | + ieLenRead = 0 | |
428 | + # Parse and store UDH fields | |
429 | + while ieLenRead < udhLen: | |
430 | + ie = InformationElement.decode(byteIter) | |
431 | + ieLenRead += len(ie) | |
432 | + result['udh'].append(ie) | |
433 | + del ieLenRead | |
434 | + if dataCoding == 0x00: # GSM-7 | |
435 | + # Since we are using 7-bit data, "fill bits" may have been added to make the UDH end on a septet boundary | |
436 | + shift = ((udhLen + 1) * 8) % 7 # "fill bits" needed to make the UDH end on a septet boundary | |
437 | + # Simulate another "shift" in the unpackSeptets algorithm in order to ignore the fill bits | |
438 | + prevOctet = next(byteIter) | |
439 | + shift += 1 | |
440 | + | |
441 | + if dataCoding == 0x00: # GSM-7 | |
442 | + if udhPresent: | |
443 | + userDataSeptets = unpackSeptets(byteIter, userDataLen, prevOctet, shift) | |
444 | + else: | |
445 | + userDataSeptets = unpackSeptets(byteIter, userDataLen) | |
446 | + result['text'] = decodeGsm7(userDataSeptets) | |
447 | + elif dataCoding == 0x02: # UCS2 | |
448 | + result['text'] = decodeUcs2(byteIter, userDataLen) | |
449 | + else: # 8-bit (data) | |
450 | + userData = [] | |
451 | + for b in byteIter: | |
452 | + userData.append(unichr(b)) | |
453 | + result['text'] = ''.join(userData) | |
454 | + return result | |
455 | + | |
456 | +def _decodeRelativeValidityPeriod(tpVp): | |
457 | + """ Calculates the relative SMS validity period (based on the table in section 9.2.3.12 of GSM 03.40) | |
458 | + :rtype: datetime.timedelta | |
459 | + """ | |
460 | + if tpVp <= 143: | |
461 | + return timedelta(minutes=((tpVp + 1) * 5)) | |
462 | + elif 144 <= tpVp <= 167: | |
463 | + return timedelta(hours=12, minutes=((tpVp - 143) * 30)) | |
464 | + elif 168 <= tpVp <= 196: | |
465 | + return timedelta(days=(tpVp - 166)) | |
466 | + elif 197 <= tpVp <= 255: | |
467 | + return timedelta(weeks=(tpVp - 192)) | |
468 | + else: | |
469 | + raise ValueError('tpVp must be in range [0, 255]') | |
470 | + | |
471 | +def _encodeRelativeValidityPeriod(validityPeriod): | |
472 | + """ Encodes the specified relative validity period timedelta into an integer for use in an SMS PDU | |
473 | + (based on the table in section 9.2.3.12 of GSM 03.40) | |
474 | + | |
475 | + :param validityPeriod: The validity period to encode | |
476 | + :type validityPeriod: datetime.timedelta | |
477 | + :rtype: int | |
478 | + """ | |
479 | + # Python 2.6 does not have timedelta.total_seconds(), so compute it manually | |
480 | + #seconds = validityPeriod.total_seconds() | |
481 | + seconds = validityPeriod.seconds + (validityPeriod.days * 24 * 3600) | |
482 | + if seconds <= 43200: # 12 hours | |
483 | + tpVp = int(seconds / 300) - 1 # divide by 5 minutes, subtract 1 | |
484 | + elif seconds <= 86400: # 24 hours | |
485 | + tpVp = int((seconds - 43200) / 1800) + 143 # subtract 12 hours, divide by 30 minutes. add 143 | |
486 | + elif validityPeriod.days <= 30: # 30 days | |
487 | + tpVp = validityPeriod.days + 166 # amount of days + 166 | |
488 | + elif validityPeriod.days <= 441: # max value of tpVp is 255 | |
489 | + tpVp = int(validityPeriod.days / 7) + 192 # amount of weeks + 192 | |
490 | + else: | |
491 | + raise ValueError('Validity period too long; tpVp limited to 1 octet (max value: 255)') | |
492 | + return tpVp | |
493 | + | |
494 | +def _decodeTimestamp(byteIter): | |
495 | + """ Decodes a 7-octet timestamp """ | |
496 | + dateStr = decodeSemiOctets(byteIter, 7) | |
497 | + timeZoneStr = dateStr[-2:] | |
498 | + return datetime.strptime(dateStr[:-2], '%y%m%d%H%M%S').replace(tzinfo=SmsPduTzInfo(timeZoneStr)) | |
499 | + | |
500 | +def _encodeTimestamp(timestamp): | |
501 | + """ Encodes a 7-octet timestamp from the specified date | |
502 | + | |
503 | + Note: the specified timestamp must have a UTC offset set; you can use gsmmodem.util.SimpleOffsetTzInfo for simple cases | |
504 | + | |
505 | + :param timestamp: The timestamp to encode | |
506 | + :type timestamp: datetime.datetime | |
507 | + | |
508 | + :return: The encoded timestamp | |
509 | + :rtype: bytearray | |
510 | + """ | |
511 | + if timestamp.tzinfo == None: | |
512 | + raise ValueError('Please specify time zone information for the timestamp (e.g. by using gsmmodem.util.SimpleOffsetTzInfo)') | |
513 | + | |
514 | + # See if the timezone difference is positive/negative | |
515 | + tzDelta = timestamp.utcoffset() | |
516 | + if tzDelta.days >= 0: | |
517 | + tzValStr = '{0:0>2}'.format(int(tzDelta.seconds / 60 / 15)) | |
518 | + else: # negative | |
519 | + tzVal = int((tzDelta.days * -3600 * 24 - tzDelta.seconds) / 60 / 15) # calculate offset in 0.25 hours | |
520 | + # Cast as literal hex value and set MSB of first semi-octet of timezone to 1 to indicate negative value | |
521 | + tzVal = int('{0:0>2}'.format(tzVal), 16) | 0x80 | |
522 | + tzValStr = '{0:0>2X}'.format(tzVal) | |
523 | + | |
524 | + dateStr = timestamp.strftime('%y%m%d%H%M%S') + tzValStr | |
525 | + | |
526 | + return encodeSemiOctets(dateStr) | |
527 | + | |
528 | +def _decodeDataCoding(octet): | |
529 | + if octet & 0xC0 == 0: | |
530 | + #compressed = octect & 0x20 | |
531 | + alphabet = (octet & 0x0C) >> 2 | |
532 | + return alphabet # 0x00 == GSM-7, 0x01 == 8-bit data, 0x02 == UCS2 | |
533 | + # We ignore other coding groups | |
534 | + return 0 | |
535 | + | |
536 | +def _decodeAddressField(byteIter, smscField=False, log=False): | |
537 | + """ Decodes the address field at the current position of the bytearray iterator | |
538 | + | |
539 | + :param byteIter: Iterator over bytearray | |
540 | + :type byteIter: iter(bytearray) | |
541 | + | |
542 | + :return: Tuple containing the address value and amount of bytes read (value is or None if it is empty (zero-length)) | |
543 | + :rtype: tuple | |
544 | + """ | |
545 | + addressLen = next(byteIter) | |
546 | + if addressLen > 0: | |
547 | + toa = next(byteIter) | |
548 | + ton = (toa & 0x70) # bits 6,5,4 of type-of-address == type-of-number | |
549 | + if ton == 0x50: | |
550 | + # Alphanumberic number | |
551 | + addressLen = int(math.ceil(addressLen / 2.0)) | |
552 | + septets = unpackSeptets(byteIter, addressLen) | |
553 | + addressValue = decodeGsm7(septets) | |
554 | + return (addressValue, (addressLen + 2)) | |
555 | + else: | |
556 | + # ton == 0x00: Unknown (might be international, local, etc) - leave as is | |
557 | + # ton == 0x20: National number | |
558 | + if smscField: | |
559 | + addressValue = decodeSemiOctets(byteIter, addressLen-1) | |
560 | + else: | |
561 | + if addressLen % 2: | |
562 | + addressLen = int(addressLen / 2) + 1 | |
563 | + else: | |
564 | + addressLen = int(addressLen / 2) | |
565 | + addressValue = decodeSemiOctets(byteIter, addressLen) | |
566 | + addressLen += 1 # for the return value, add the toa byte | |
567 | + if ton == 0x10: # International number | |
568 | + addressValue = '+' + addressValue | |
569 | + return (addressValue, (addressLen + 1)) | |
570 | + else: | |
571 | + return (None, 1) | |
572 | + | |
573 | +def _encodeAddressField(address, smscField=False): | |
574 | + """ Encodes the address into an address field | |
575 | + | |
576 | + :param address: The address to encode (phone number or alphanumeric) | |
577 | + :type byteIter: str | |
578 | + | |
579 | + :return: Encoded SMS PDU address field | |
580 | + :rtype: bytearray | |
581 | + """ | |
582 | + # First, see if this is a number or an alphanumeric string | |
583 | + toa = 0x80 | 0x00 | 0x01 # Type-of-address start | Unknown type-of-number | ISDN/tel numbering plan | |
584 | + alphaNumeric = False | |
585 | + if address.isalnum(): | |
586 | + # Might just be a local number | |
587 | + if address.isdigit(): | |
588 | + # Local number | |
589 | + toa |= 0x20 | |
590 | + else: | |
591 | + # Alphanumeric address | |
592 | + toa |= 0x50 | |
593 | + toa &= 0xFE # switch to "unknown" numbering plan | |
594 | + alphaNumeric = True | |
595 | + else: | |
596 | + if address[0] == '+' and address[1:].isdigit(): | |
597 | + # International number | |
598 | + toa |= 0x10 | |
599 | + # Remove the '+' prefix | |
600 | + address = address[1:] | |
601 | + else: | |
602 | + # Alphanumeric address | |
603 | + toa |= 0x50 | |
604 | + toa &= 0xFE # switch to "unknown" numbering plan | |
605 | + alphaNumeric = True | |
606 | + if alphaNumeric: | |
607 | + addressValue = packSeptets(encodeGsm7(address, False)) | |
608 | + addressLen = len(addressValue) * 2 | |
609 | + else: | |
610 | + addressValue = encodeSemiOctets(address) | |
611 | + if smscField: | |
612 | + addressLen = len(addressValue) + 1 | |
613 | + else: | |
614 | + addressLen = len(address) | |
615 | + result = bytearray() | |
616 | + result.append(addressLen) | |
617 | + result.append(toa) | |
618 | + result.extend(addressValue) | |
619 | + return result | |
620 | + | |
621 | +def encodeSemiOctets(number): | |
622 | + """ Semi-octet encoding algorithm (e.g. for phone numbers) | |
623 | + | |
624 | + :return: bytearray containing the encoded octets | |
625 | + :rtype: bytearray | |
626 | + """ | |
627 | + if len(number) % 2 == 1: | |
628 | + number = number + 'F' # append the "end" indicator | |
629 | + octets = [int(number[i+1] + number[i], 16) for i in xrange(0, len(number), 2)] | |
630 | + return bytearray(octets) | |
631 | + | |
632 | +def decodeSemiOctets(encodedNumber, numberOfOctets=None): | |
633 | + """ Semi-octet decoding algorithm(e.g. for phone numbers) | |
634 | + | |
635 | + :param encodedNumber: The semi-octet-encoded telephone number (in bytearray format or hex string) | |
636 | + :type encodedNumber: bytearray, str or iter(bytearray) | |
637 | + :param numberOfOctets: The expected amount of octets after decoding (i.e. when to stop) | |
638 | + :type numberOfOctets: int | |
639 | + | |
640 | + :return: decoded telephone number | |
641 | + :rtype: string | |
642 | + """ | |
643 | + number = [] | |
644 | + if type(encodedNumber) in (str, bytes): | |
645 | + encodedNumber = bytearray(codecs.decode(encodedNumber, 'hex_codec')) | |
646 | + i = 0 | |
647 | + for octet in encodedNumber: | |
648 | + hexVal = hex(octet)[2:].zfill(2) | |
649 | + number.append(hexVal[1]) | |
650 | + if hexVal[0] != 'f': | |
651 | + number.append(hexVal[0]) | |
652 | + else: | |
653 | + break | |
654 | + if numberOfOctets != None: | |
655 | + i += 1 | |
656 | + if i == numberOfOctets: | |
657 | + break | |
658 | + return ''.join(number) | |
659 | + | |
660 | +def encodeGsm7(plaintext, discardInvalid=False): | |
661 | + """ GSM-7 text encoding algorithm | |
662 | + | |
663 | + Encodes the specified text string into GSM-7 octets (characters). This method does not pack | |
664 | + the characters into septets. | |
665 | + | |
666 | + :param text: the text string to encode | |
667 | + :param discardInvalid: if True, characters that cannot be encoded will be silently discarded | |
668 | + | |
669 | + :raise ValueError: if the text string cannot be encoded using GSM-7 encoding (unless discardInvalid == True) | |
670 | + | |
671 | + :return: A bytearray containing the string encoded in GSM-7 encoding | |
672 | + :rtype: bytearray | |
673 | + """ | |
674 | + result = bytearray() | |
675 | + if PYTHON_VERSION >= 3: | |
676 | + plaintext = str(plaintext) | |
677 | + for char in plaintext: | |
678 | + idx = GSM7_BASIC.find(char) | |
679 | + if idx != -1: | |
680 | + result.append(idx) | |
681 | + elif char in GSM7_EXTENDED: | |
682 | + result.append(0x1B) # ESC - switch to extended table | |
683 | + result.append(ord(GSM7_EXTENDED[char])) | |
684 | + elif not discardInvalid: | |
685 | + raise ValueError('Cannot encode char "{0}" using GSM-7 encoding'.format(char)) | |
686 | + return result | |
687 | + | |
688 | +def decodeGsm7(encodedText): | |
689 | + """ GSM-7 text decoding algorithm | |
690 | + | |
691 | + Decodes the specified GSM-7-encoded string into a plaintext string. | |
692 | + | |
693 | + :param encodedText: the text string to encode | |
694 | + :type encodedText: bytearray or str | |
695 | + | |
696 | + :return: A string containing the decoded text | |
697 | + :rtype: str | |
698 | + """ | |
699 | + result = [] | |
700 | + if type(encodedText) == str: | |
701 | + encodedText = rawStrToByteArray(encodedText) #bytearray(encodedText) | |
702 | + iterEncoded = iter(encodedText) | |
703 | + for b in iterEncoded: | |
704 | + if b == 0x1B: # ESC - switch to extended table | |
705 | + c = chr(next(iterEncoded)) | |
706 | + for char, value in dictItemsIter(GSM7_EXTENDED): | |
707 | + if c == value: | |
708 | + result.append(char) | |
709 | + break | |
710 | + else: | |
711 | + result.append(GSM7_BASIC[b]) | |
712 | + return ''.join(result) | |
713 | + | |
714 | +def packSeptets(octets, padBits=0): | |
715 | + """ Packs the specified octets into septets | |
716 | + | |
717 | + Typically the output of encodeGsm7 would be used as input to this function. The resulting | |
718 | + bytearray contains the original GSM-7 characters packed into septets ready for transmission. | |
719 | + | |
720 | + :rtype: bytearray | |
721 | + """ | |
722 | + result = bytearray() | |
723 | + if type(octets) == str: | |
724 | + octets = iter(rawStrToByteArray(octets)) | |
725 | + elif type(octets) == bytearray: | |
726 | + octets = iter(octets) | |
727 | + shift = padBits | |
728 | + if padBits == 0: | |
729 | + prevSeptet = next(octets) | |
730 | + else: | |
731 | + prevSeptet = 0x00 | |
732 | + for octet in octets: | |
733 | + septet = octet & 0x7f; | |
734 | + if shift == 7: | |
735 | + # prevSeptet has already been fully added to result | |
736 | + shift = 0 | |
737 | + prevSeptet = septet | |
738 | + continue | |
739 | + b = ((septet << (7 - shift)) & 0xFF) | (prevSeptet >> shift) | |
740 | + prevSeptet = septet | |
741 | + shift += 1 | |
742 | + result.append(b) | |
743 | + if shift != 7: | |
744 | + # There is a bit "left over" from prevSeptet | |
745 | + result.append(prevSeptet >> shift) | |
746 | + return result | |
747 | + | |
748 | +def unpackSeptets(septets, numberOfSeptets=None, prevOctet=None, shift=7): | |
749 | + """ Unpacks the specified septets into octets | |
750 | + | |
751 | + :param septets: Iterator or iterable containing the septets packed into octets | |
752 | + :type septets: iter(bytearray), bytearray or str | |
753 | + :param numberOfSeptets: The amount of septets to unpack (or None for all remaining in "septets") | |
754 | + :type numberOfSeptets: int or None | |
755 | + | |
756 | + :return: The septets unpacked into octets | |
757 | + :rtype: bytearray | |
758 | + """ | |
759 | + result = bytearray() | |
760 | + if type(septets) == str: | |
761 | + septets = iter(rawStrToByteArray(septets)) | |
762 | + elif type(septets) == bytearray: | |
763 | + septets = iter(septets) | |
764 | + if numberOfSeptets == None: | |
765 | + numberOfSeptets = MAX_INT # Loop until StopIteration | |
766 | + i = 0 | |
767 | + for octet in septets: | |
768 | + i += 1 | |
769 | + if shift == 7: | |
770 | + shift = 1 | |
771 | + if prevOctet != None: | |
772 | + result.append(prevOctet >> 1) | |
773 | + if i <= numberOfSeptets: | |
774 | + result.append(octet & 0x7F) | |
775 | + prevOctet = octet | |
776 | + if i == numberOfSeptets: | |
777 | + break | |
778 | + else: | |
779 | + continue | |
780 | + b = ((octet << shift) & 0x7F) | (prevOctet >> (8 - shift)) | |
781 | + | |
782 | + prevOctet = octet | |
783 | + result.append(b) | |
784 | + shift += 1 | |
785 | + | |
786 | + if i == numberOfSeptets: | |
787 | + break | |
788 | + if shift == 7: | |
789 | + b = prevOctet >> (8 - shift) | |
790 | + if b: | |
791 | + # The final septet value still needs to be unpacked | |
792 | + result.append(b) | |
793 | + return result | |
794 | + | |
795 | +def decodeUcs2(byteIter, numBytes): | |
796 | + """ Decodes UCS2-encoded text from the specified byte iterator, up to a maximum of numBytes """ | |
797 | + userData = [] | |
798 | + i = 0 | |
799 | + try: | |
800 | + while i < numBytes: | |
801 | + userData.append(unichr((next(byteIter) << 8) | next(byteIter))) | |
802 | + i += 2 | |
803 | + except StopIteration: | |
804 | + # Not enough bytes in iterator to reach numBytes; return what we have | |
805 | + pass | |
806 | + return ''.join(userData) | |
807 | + | |
808 | +def encodeUcs2(text): | |
809 | + """ UCS2 text encoding algorithm | |
810 | + | |
811 | + Encodes the specified text string into UCS2-encoded bytes. | |
812 | + | |
813 | + :param text: the text string to encode | |
814 | + | |
815 | + :return: A bytearray containing the string encoded in UCS2 encoding | |
816 | + :rtype: bytearray | |
817 | + """ | |
818 | + result = bytearray() | |
819 | + for b in map(ord, text): | |
820 | + result.append(b >> 8) | |
821 | + result.append(b & 0xFF) | |
822 | + return result |
gsmmodem/serial_comms.py
... | ... | @@ -0,0 +1,141 @@ |
1 | +#!/usr/bin/env python | |
2 | + | |
3 | +""" Low-level serial communications handling """ | |
4 | + | |
5 | +import sys, threading, logging | |
6 | + | |
7 | +import re | |
8 | +import serial # pyserial: http://pyserial.sourceforge.net | |
9 | + | |
10 | +from .exceptions import TimeoutException | |
11 | +from . import compat # For Python 2.6 compatibility | |
12 | + | |
13 | +class SerialComms(object): | |
14 | + """ Wraps all low-level serial communications (actual read/write operations) """ | |
15 | + | |
16 | + log = logging.getLogger('gsmmodem.serial_comms.SerialComms') | |
17 | + | |
18 | + # End-of-line read terminator | |
19 | + RX_EOL_SEQ = '\r\n' | |
20 | + # End-of-response terminator | |
21 | + RESPONSE_TERM = re.compile(r'^OK|ERROR|(\+CM[ES] ERROR: \d+)|(COMMAND NOT SUPPORT)$') | |
22 | + # Default timeout for serial port reads (in seconds) | |
23 | + timeout = 1 | |
24 | + | |
25 | + def __init__(self, port, baudrate=115200, notifyCallbackFunc=None, fatalErrorCallbackFunc=None, *args, **kwargs): | |
26 | + """ Constructor | |
27 | + | |
28 | + :param fatalErrorCallbackFunc: function to call if a fatal error occurs in the serial device reading thread | |
29 | + :type fatalErrorCallbackFunc: func | |
30 | + """ | |
31 | + self.alive = False | |
32 | + self.port = port | |
33 | + self.baudrate = baudrate | |
34 | + | |
35 | + self._responseEvent = None # threading.Event() | |
36 | + self._expectResponseTermSeq = None # expected response terminator sequence | |
37 | + self._response = None # Buffer containing response to a written command | |
38 | + self._notification = [] # Buffer containing lines from an unsolicited notification from the modem | |
39 | + # Reentrant lock for managing concurrent write access to the underlying serial port | |
40 | + self._txLock = threading.RLock() | |
41 | + | |
42 | + self.notifyCallback = notifyCallbackFunc or self._placeholderCallback | |
43 | + self.fatalErrorCallback = fatalErrorCallbackFunc or self._placeholderCallback | |
44 | + | |
45 | + def connect(self): | |
46 | + """ Connects to the device and starts the read thread """ | |
47 | + self.serial = serial.Serial(port=self.port, baudrate=self.baudrate, timeout=self.timeout) | |
48 | + # Start read thread | |
49 | + self.alive = True | |
50 | + self.rxThread = threading.Thread(target=self._readLoop) | |
51 | + self.rxThread.daemon = True | |
52 | + self.rxThread.start() | |
53 | + | |
54 | + def close(self): | |
55 | + """ Stops the read thread, waits for it to exit cleanly, then closes the underlying serial port """ | |
56 | + self.alive = False | |
57 | + self.rxThread.join() | |
58 | + self.serial.close() | |
59 | + | |
60 | + def _handleLineRead(self, line, checkForResponseTerm=True): | |
61 | + #print 'sc.hlineread:',line | |
62 | + if self._responseEvent and not self._responseEvent.is_set(): | |
63 | + # A response event has been set up (another thread is waiting for this response) | |
64 | + self._response.append(line) | |
65 | + if not checkForResponseTerm or self.RESPONSE_TERM.match(line): | |
66 | + # End of response reached; notify waiting thread | |
67 | + #print 'response:', self._response | |
68 | + self.log.debug('response: %s', self._response) | |
69 | + self._responseEvent.set() | |
70 | + else: | |
71 | + # Nothing was waiting for this - treat it as a notification | |
72 | + self._notification.append(line) | |
73 | + if self.serial.inWaiting() == 0: | |
74 | + # No more chars on the way for this notification - notify higher-level callback | |
75 | + #print 'notification:', self._notification | |
76 | + self.log.debug('notification: %s', self._notification) | |
77 | + self.notifyCallback(self._notification) | |
78 | + self._notification = [] | |
79 | + | |
80 | + def _placeholderCallback(self, *args, **kwargs): | |
81 | + """ Placeholder callback function (does nothing) """ | |
82 | + | |
83 | + def _readLoop(self): | |
84 | + """ Read thread main loop | |
85 | + | |
86 | + Reads lines from the connected device | |
87 | + """ | |
88 | + try: | |
89 | + readTermSeq = list(self.RX_EOL_SEQ) | |
90 | + readTermLen = len(readTermSeq) | |
91 | + rxBuffer = [] | |
92 | + while self.alive: | |
93 | + data = self.serial.read(1) | |
94 | + if data != '': # check for timeout | |
95 | + #print >> sys.stderr, ' RX:', data,'({0})'.format(ord(data)) | |
96 | + rxBuffer.append(data) | |
97 | + if rxBuffer[-readTermLen:] == readTermSeq: | |
98 | + # A line (or other logical segment) has been read | |
99 | + line = ''.join(rxBuffer[:-readTermLen]) | |
100 | + rxBuffer = [] | |
101 | + if len(line) > 0: | |
102 | + #print 'calling handler' | |
103 | + self._handleLineRead(line) | |
104 | + elif self._expectResponseTermSeq: | |
105 | + if rxBuffer[-len(self._expectResponseTermSeq):] == self._expectResponseTermSeq: | |
106 | + line = ''.join(rxBuffer) | |
107 | + rxBuffer = [] | |
108 | + self._handleLineRead(line, checkForResponseTerm=False) | |
109 | + #else: | |
110 | + #' <RX timeout>' | |
111 | + except serial.SerialException as e: | |
112 | + self.alive = False | |
113 | + try: | |
114 | + self.serial.close() | |
115 | + except Exception: #pragma: no cover | |
116 | + pass | |
117 | + # Notify the fatal error handler | |
118 | + self.fatalErrorCallback(e) | |
119 | + | |
120 | + def write(self, data, waitForResponse=True, timeout=5, expectedResponseTermSeq=None): | |
121 | + with self._txLock: | |
122 | + if waitForResponse: | |
123 | + if expectedResponseTermSeq: | |
124 | + self._expectResponseTermSeq = list(expectedResponseTermSeq) | |
125 | + self._response = [] | |
126 | + self._responseEvent = threading.Event() | |
127 | + self.serial.write(data) | |
128 | + if self._responseEvent.wait(timeout): | |
129 | + self._responseEvent = None | |
130 | + self._expectResponseTermSeq = False | |
131 | + return self._response | |
132 | + else: # Response timed out | |
133 | + self._responseEvent = None | |
134 | + self._expectResponseTermSeq = False | |
135 | + if len(self._response) > 0: | |
136 | + # Add the partial response to the timeout exception | |
137 | + raise TimeoutException(self._response) | |
138 | + else: | |
139 | + raise TimeoutException() | |
140 | + else: | |
141 | + self.serial.write(data) |
gsmmodem/util.py
... | ... | @@ -0,0 +1,110 @@ |
1 | +#!/usr/bin/env python | |
2 | +# -*- coding: utf-8 -*- | |
3 | + | |
4 | +""" Some common utility classes used by tests """ | |
5 | + | |
6 | +from datetime import datetime, timedelta, tzinfo | |
7 | +import re | |
8 | + | |
9 | +class SimpleOffsetTzInfo(tzinfo): | |
10 | + """ Very simple implementation of datetime.tzinfo offering set timezone offset for datetime instances """ | |
11 | + | |
12 | + def __init__(self, offsetInHours=None): | |
13 | + """ Constructs a new tzinfo instance using an amount of hours as an offset | |
14 | + | |
15 | + :param offsetInHours: The timezone offset, in hours (may be negative) | |
16 | + :type offsetInHours: int or float | |
17 | + """ | |
18 | + if offsetInHours != None: #pragma: no cover | |
19 | + self.offsetInHours = offsetInHours | |
20 | + | |
21 | + def utcoffset(self, dt): | |
22 | + return timedelta(hours=self.offsetInHours) | |
23 | + | |
24 | + def dst(self, dt): | |
25 | + return timedelta(0) | |
26 | + | |
27 | + def __repr__(self): | |
28 | + return 'gsmmodem.util.SimpleOffsetTzInfo({0})'.format(self.offsetInHours) | |
29 | + | |
30 | +def parseTextModeTimeStr(timeStr): | |
31 | + """ Parses the specified SMS text mode time string | |
32 | + | |
33 | + The time stamp format is "yy/MM/dd,hh:mm:ssยฑzz" | |
34 | + (yy = year, MM = month, dd = day, hh = hour, mm = minute, ss = second, zz = time zone | |
35 | + [Note: the unit of time zone is a quarter of an hour]) | |
36 | + | |
37 | + :param timeStr: The time string to parse | |
38 | + :type timeStr: str | |
39 | + | |
40 | + :return: datetime object representing the specified time string | |
41 | + :rtype: datetime.datetime | |
42 | + """ | |
43 | + msgTime = timeStr[:-3] | |
44 | + tzOffsetHours = int(int(timeStr[-3:]) * 0.25) | |
45 | + return datetime.strptime(msgTime, '%y/%m/%d,%H:%M:%S').replace(tzinfo=SimpleOffsetTzInfo(tzOffsetHours)) | |
46 | + | |
47 | +def lineStartingWith(string, lines): | |
48 | + """ Searches through the specified list of strings and returns the | |
49 | + first line starting with the specified search string, or None if not found | |
50 | + """ | |
51 | + for line in lines: | |
52 | + if line.startswith(string): | |
53 | + return line | |
54 | + else: | |
55 | + return None | |
56 | + | |
57 | +def lineMatching(regexStr, lines): | |
58 | + """ Searches through the specified list of strings and returns the regular expression | |
59 | + match for the first line that matches the specified regex string, or None if no match was found | |
60 | + | |
61 | + Note: if you have a pre-compiled regex pattern, use lineMatchingPattern() instead | |
62 | + | |
63 | + :type regexStr: Regular expression string to use | |
64 | + :type lines: List of lines to search | |
65 | + | |
66 | + :return: the regular expression match for the first line that matches the specified regex, or None if no match was found | |
67 | + :rtype: re.Match | |
68 | + """ | |
69 | + regex = re.compile(regexStr) | |
70 | + for line in lines: | |
71 | + m = regex.match(line) | |
72 | + if m: | |
73 | + return m | |
74 | + else: | |
75 | + return None | |
76 | + | |
77 | +def lineMatchingPattern(pattern, lines): | |
78 | + """ Searches through the specified list of strings and returns the regular expression | |
79 | + match for the first line that matches the specified pre-compiled regex pattern, or None if no match was found | |
80 | + | |
81 | + Note: if you are using a regex pattern string (i.e. not already compiled), use lineMatching() instead | |
82 | + | |
83 | + :type pattern: Compiled regular expression pattern to use | |
84 | + :type lines: List of lines to search | |
85 | + | |
86 | + :return: the regular expression match for the first line that matches the specified regex, or None if no match was found | |
87 | + :rtype: re.Match | |
88 | + """ | |
89 | + for line in lines: | |
90 | + m = pattern.match(line) | |
91 | + if m: | |
92 | + return m | |
93 | + else: | |
94 | + return None | |
95 | + | |
96 | +def allLinesMatchingPattern(pattern, lines): | |
97 | + """ Like lineMatchingPattern, but returns all lines that match the specified pattern | |
98 | + | |
99 | + :type pattern: Compiled regular expression pattern to use | |
100 | + :type lines: List of lines to search | |
101 | + | |
102 | + :return: list of re.Match objects for each line matched, or an empty list if none matched | |
103 | + :rtype: list | |
104 | + """ | |
105 | + result = [] | |
106 | + for line in lines: | |
107 | + m = pattern.match(line) | |
108 | + if m: | |
109 | + result.append(m) | |
110 | + return result |
logs/empty
main.py
... | ... | @@ -0,0 +1,374 @@ |
1 | +#!/usr/bin/env python | |
2 | + | |
3 | +import logging | |
4 | +from logging.handlers import TimedRotatingFileHandler | |
5 | +import ConfigParser | |
6 | + | |
7 | +config = ConfigParser.RawConfigParser() | |
8 | +config.read('config.ini') | |
9 | + | |
10 | +PORT = config.get('globals','PORT') | |
11 | +BAUDRATE = config.getint('globals', 'BAUDRATE') | |
12 | +PIN = None # SIM card PIN (if any) | |
13 | +PIN_TRX = config.get('globals', 'PIN_TRX') | |
14 | + | |
15 | +BASE_CHIPINFO = config.get('globals', 'BASE_CHIPINFO') | |
16 | +CHIPINFO = BASE_CHIPINFO | |
17 | + | |
18 | +AAA_HOST = config.get('globals', 'AAA_HOST') | |
19 | +CITY = config.get('globals', 'CITY') | |
20 | +PRODUCTS = config.get('globals', 'PRODUCTS') | |
21 | + | |
22 | +REDIS_HOST = config.get('globals', 'REDIS_HOST') | |
23 | +REDIS_PORT = config.getint('globals', 'REDIS_PORT') | |
24 | +REDIS_TTL = config.getint('globals', 'REDIS_TTL') | |
25 | +REDIS_DISABLE_PULL_TTL = config.getint('globals', 'REDIS_DISABLE_PULL_TTL') | |
26 | + | |
27 | +PULL_INTERVAL = config.getint('globals', 'PULL_INTERVAL') | |
28 | +SLEEP_AFTER_TOPUP = config.getint('globals', 'SLEEP_AFTER_TOPUP') | |
29 | +SLEEP_BETWEEN_BALANCE_N_TOPUP = config.getint('globals', 'SLEEP_BETWEEN_BALANCE_N_TOPUP') | |
30 | +TOPUP_USSD_TIMEOUT = config.getint('globals', 'TOPUP_USSD_TIMEOUT') | |
31 | + | |
32 | +MIN_BALANCE = config.getint('globals', 'MIN_BALANCE') | |
33 | + | |
34 | +PULL_COUNT = 0 | |
35 | +MSISDN = '' | |
36 | +BALANCE = 0 | |
37 | + | |
38 | +logger = None | |
39 | + | |
40 | +from gsmmodem.modem import GsmModem | |
41 | +from gsmmodem.exceptions import TimeoutException | |
42 | + | |
43 | +from time import sleep | |
44 | +from time import strftime | |
45 | + | |
46 | +import redis | |
47 | +import requests | |
48 | + | |
49 | +import sate24 | |
50 | +import xltunai | |
51 | + | |
52 | +redis_client = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, db=0) | |
53 | + | |
54 | +def handleSms(sms): | |
55 | + logger.info(u'== SMS From: {0}; Time: {1}; Message: {2}'.format(sms.number, sms.time, sms.text)) | |
56 | + | |
57 | + if not xltunai.isValidSender(sms.number): | |
58 | + return | |
59 | + | |
60 | + if sms.text.find('Terimakasih, transaksi CASH IN ke akun') >= 0: | |
61 | + logger.info('handleSms: CASH IN, aktivasi pull jika non aktif') | |
62 | + enablePull() | |
63 | + return | |
64 | + | |
65 | + | |
66 | + destination = xltunai.getDestinationFromMessage(sms.text) | |
67 | + if destination == '': | |
68 | + logger.warning('handleSms: gagal parsing nomor tujuan') | |
69 | + return | |
70 | + | |
71 | + nominal = xltunai.getNominalFromMessage(sms.text) | |
72 | + if nominal == '': | |
73 | + logger.warning('handleSms: gagal parsing nominal') | |
74 | + return | |
75 | + | |
76 | + sn = xltunai.getSNFromMessage(sms.text) | |
77 | + if sn == '': | |
78 | + logger.warning('handleSms: gagal parsing SN') | |
79 | + return | |
80 | + | |
81 | + | |
82 | + response_code = xltunai.getResponseCodeByMessage(sms.text) | |
83 | + | |
84 | + request_id = getRequestIdByNominalDestination(nominal, destination) | |
85 | + if not request_id: | |
86 | + logger.info('Unknown request id for nominal:{0} destination:{1}'.format(nominal, destination)) | |
87 | + | |
88 | + pushTopupStatus(request_id, response_code, sms.text) | |
89 | + | |
90 | + | |
91 | +def getRequestIdByNominalDestination(nominal, destination): | |
92 | + redis_key = sate24.keyByNominalDestination(CHIPINFO, nominal, destination) | |
93 | + return redis_client.spop(redis_key) | |
94 | + | |
95 | +def pushTopupStatus(request_id, response_code, _message): | |
96 | + global BALANCE | |
97 | + | |
98 | + message = "{0} -- Prev balance: {1}".format(_message, BALANCE) | |
99 | + | |
100 | + timestamp = strftime('%Y%m%d%H%M%S') | |
101 | + | |
102 | + if response_code == '00': | |
103 | + sn = xltunai.getSNFromMessage(message) | |
104 | + message = 'SN={0};{1}'.format(sn, message) | |
105 | + | |
106 | + push_message = CHIPINFO + '$' + message | |
107 | + | |
108 | + payload = { | |
109 | + 'trans_id': request_id, | |
110 | + 'trans_date': timestamp, | |
111 | + 'resp_code': response_code, | |
112 | + 'ussd_msg': push_message | |
113 | + } | |
114 | + | |
115 | + url = AAA_HOST + '/topup' | |
116 | + | |
117 | + try: | |
118 | + logger.info('Sending topup status to AAA') | |
119 | + logger.info(payload) | |
120 | + r = requests.get(url, params=payload) | |
121 | + except: | |
122 | + return | |
123 | + | |
124 | +def getIsDisableRedisKey(): | |
125 | + return CHIPINFO + '.pulldisable' | |
126 | + | |
127 | +def isPullEnable(): | |
128 | + redis_key = getIsDisableRedisKey() | |
129 | + result = 'FALSE' | |
130 | + | |
131 | + try: | |
132 | + result = redis_client.get(redis_key) | |
133 | + redis_client.expire(redis_key, REDIS_DISABLE_PULL_TTL) | |
134 | + except: | |
135 | + return False | |
136 | + | |
137 | + if not result: | |
138 | + return True | |
139 | + | |
140 | + return result == 'FALSE' | |
141 | + | |
142 | +def enablePull(): | |
143 | + logger.info('Enabling Pull') | |
144 | + redis_key = getIsDisableRedisKey() | |
145 | + try: | |
146 | + redis_client.set(redis_key, 'FALSE') | |
147 | + redis_client.expire(redis_key, REDIS_DISABLE_PULL_TTL) | |
148 | + except: | |
149 | + return | |
150 | + | |
151 | +def disablePull(): | |
152 | + logger.info('Disabling Pull') | |
153 | + redis_key = getIsDisableRedisKey() | |
154 | + try: | |
155 | + redis_client.set(redis_key, 'TRUE') | |
156 | + redis_client.expire(redis_key, REDIS_DISABLE_PULL_TTL) | |
157 | + except: | |
158 | + return | |
159 | + | |
160 | +def topupTask(task, modem): | |
161 | + if not task: | |
162 | + return | |
163 | + | |
164 | + if task['status'] != 'OK': | |
165 | + return | |
166 | + | |
167 | + checkSignal(modem) | |
168 | + | |
169 | + redis_key = sate24.keyByRequestId(CHIPINFO, task['requestId']) | |
170 | + redis_client.set(redis_key, task) | |
171 | + redis_client.expire(redis_key, REDIS_TTL) | |
172 | + | |
173 | + | |
174 | + nominal = xltunai.getNominalFromProduct(task['product']) | |
175 | + intl_destination = xltunai.toInternationalNumber(task['destination']) | |
176 | + | |
177 | + redis_key = sate24.keyByNominalDestination(CHIPINFO, nominal, intl_destination) | |
178 | + redis_client.sadd(redis_key, task['requestId']) | |
179 | + redis_client.expire(redis_key, REDIS_TTL) | |
180 | + | |
181 | + pushTopupStatus(task['requestId'], '68', 'Siap mengirimkan trx ke operator') | |
182 | + | |
183 | + message = 'Executing USSD' | |
184 | + response_code = '68' | |
185 | + | |
186 | + ussd_command = xltunai.getTopupUSSDCommand(task['destination'], task['product'], PIN_TRX) | |
187 | + | |
188 | + logger.info(u'Executing {0}'.format(ussd_command)) | |
189 | + try: | |
190 | + response = modem.sendUssd(ussd_command, TOPUP_USSD_TIMEOUT) | |
191 | + | |
192 | + message = response.message.strip() | |
193 | + logger.info(u'USSD response: {0}'.format(message)) | |
194 | + | |
195 | + response_code = xltunai.getResponseCodeByUSSDResponse(message) | |
196 | + | |
197 | + if response.sessionActive: | |
198 | + response.cancel() | |
199 | + | |
200 | + except TimeoutException: | |
201 | + message = "USSD Error: Timeout when executing USSD topup" | |
202 | + logger.warning(message) | |
203 | + except: | |
204 | + message = "USSD Error: Something wrong when executing USSD topup" | |
205 | + logger.warning(message) | |
206 | + | |
207 | + pushTopupStatus(task['requestId'], response_code, message) | |
208 | + | |
209 | +def pull(modem): | |
210 | + global PULL_COUNT | |
211 | + global BALANCE | |
212 | + | |
213 | + if not isPullEnable(): | |
214 | + return | |
215 | + | |
216 | + r = None | |
217 | + try: | |
218 | + r = requests.get(AAA_HOST + '/pull?city=' + CITY + '&nom=' + PRODUCTS) | |
219 | + except: | |
220 | + logger.warning("Error requesting to AAA") | |
221 | + return | |
222 | + | |
223 | + if not r: | |
224 | + return | |
225 | + | |
226 | + task = sate24.parsePullMessage(r.text) | |
227 | + | |
228 | + if task['status'] == 'OK': | |
229 | + | |
230 | + logger.info(r.text) | |
231 | + if not checkBalance(modem) or BALANCE == 0: | |
232 | + pushTopupStatus(task['requestId'], '40', 'Transaksi dibatalkan karena gagal check balance') | |
233 | + sleep(SLEEP_BETWEEN_BALANCE_N_TOPUP) | |
234 | + return | |
235 | + | |
236 | + | |
237 | + sleep(SLEEP_BETWEEN_BALANCE_N_TOPUP) | |
238 | + | |
239 | + topupTask(task, modem) | |
240 | + sleep(SLEEP_AFTER_TOPUP) | |
241 | + | |
242 | +def pullLoop(modem): | |
243 | + while (True): | |
244 | + pull(modem) | |
245 | + sleep(PULL_INTERVAL) | |
246 | + | |
247 | +def checkSignal(modem): | |
248 | + logger.info('Signal: {0}'.format(modem.signalStrength)) | |
249 | + try: | |
250 | + redis_client.set(CHIPINFO + '.signal', modem.signalStrength) | |
251 | + redis_client.expire(CHIPINFO + '.signal', 3600*24) | |
252 | + except: | |
253 | + logger.warning("Can not save signal strength to redis") | |
254 | + | |
255 | +def checkBalance(modem): | |
256 | + global BALANCE | |
257 | + | |
258 | + #BALANCE = 0 | |
259 | + try: | |
260 | + | |
261 | + ussd_string = '*123*120#' | |
262 | + response = modem.sendUssd(ussd_string, 30) | |
263 | + BALANCE = xltunai.getBalanceFromUSSDResponse(response.message) | |
264 | + logger.info('Balance: {0}'.format(BALANCE)) | |
265 | + if response.sessionActive: | |
266 | + response.cancel() | |
267 | + | |
268 | + if BALANCE != 0 and BALANCE < MIN_BALANCE: | |
269 | + logger.info('Disabling pull, balance {0} < {1}'.format(BALANCE, MIN_BALANCE)) | |
270 | + disablePull() | |
271 | + | |
272 | + except: | |
273 | + logger.warning('Error when requesting BALANCE by USSD') | |
274 | + return False | |
275 | + | |
276 | + try: | |
277 | + redis_client.set(CHIPINFO + '.balance', BALANCE) | |
278 | + except: | |
279 | + logger.warning('Failed to save balance to redis') | |
280 | + | |
281 | + return True | |
282 | + | |
283 | +def getSIMCardInfo(modem): | |
284 | + try: | |
285 | + ussd_string = xltunai.getSIMCardInfoUSSDCommand() | |
286 | + response = modem.sendUssd(ussd_string) | |
287 | + | |
288 | + message = response.message.strip() | |
289 | + logger.info('SIM INFO: {0}'.format(message)) | |
290 | + if response.sessionActive: | |
291 | + response.cancel() | |
292 | + | |
293 | + return message | |
294 | + | |
295 | + except: | |
296 | + logger.warning('Error when requesting SIM card info by USSD') | |
297 | + return '' | |
298 | + | |
299 | +def updateChipInfo(msisdn): | |
300 | + global BASE_CHIPINFO | |
301 | + global CHIPINFO | |
302 | + global MSISDN | |
303 | + | |
304 | + MSISDN = msisdn | |
305 | + if CHIPINFO.find(msisdn) == -1: | |
306 | + CHIPINFO = BASE_CHIPINFO + '_' + msisdn | |
307 | + | |
308 | + logger.info('CHIPINFO: {0}'.format(CHIPINFO)) | |
309 | + | |
310 | +def main(): | |
311 | + global logger | |
312 | + | |
313 | + log_format = '%(asctime)s %(levelname)s: %(message)s' | |
314 | + | |
315 | + logging.basicConfig(format=log_format, level=logging.INFO) | |
316 | + logger = logging.getLogger(__name__) | |
317 | + | |
318 | + logger_formatter = logging.Formatter(log_format) | |
319 | + logger_handler = TimedRotatingFileHandler('logs/log', when='midnight') | |
320 | + logger_handler.setFormatter(logger_formatter) | |
321 | + logger.addHandler(logger_handler) | |
322 | + | |
323 | + requests_logger = logging.getLogger('requests') | |
324 | + requests_logger.setLevel(logging.WARNING) | |
325 | + | |
326 | + logger.info('Initializing modem...') | |
327 | + | |
328 | + modem = GsmModem(PORT, BAUDRATE, smsReceivedCallbackFunc=handleSms) | |
329 | + modem.smsTextMode = True | |
330 | + modem.connect(PIN) | |
331 | + | |
332 | + logger.info('Waiting for network coverage...') | |
333 | + modem.waitForNetworkCoverage(10) | |
334 | + | |
335 | + logger.info('Modem ready') | |
336 | + | |
337 | + simcard_info = getSIMCardInfo(modem) | |
338 | + msisdn = xltunai.getMSISDNFromSIMCardInfo(simcard_info) | |
339 | + imsi = xltunai.getIMSIFromSIMCardInfo(simcard_info) | |
340 | + logger.info('MSISDN: {0} -- IMSI: {1}'.format(msisdn, imsi)) | |
341 | + | |
342 | + updateChipInfo(msisdn) | |
343 | + | |
344 | + sleep(2) | |
345 | + | |
346 | + checkSignal(modem) | |
347 | + | |
348 | + logger.info('Process stored SMS') | |
349 | + try: | |
350 | + modem.processStoredSms() | |
351 | + except: | |
352 | + logger.warning('Failed on Process stored SMS') | |
353 | + | |
354 | + logger.info('Delete stored SMS') | |
355 | + try: | |
356 | + modem.deleteMultipleStoredSms() | |
357 | + except: | |
358 | + logger.warning('Failed on delete SMS') | |
359 | + | |
360 | + sleep(2) | |
361 | + | |
362 | + checkBalance(modem) | |
363 | + sleep(SLEEP_BETWEEN_BALANCE_N_TOPUP) | |
364 | + | |
365 | + enablePull() | |
366 | + pullLoop(modem) | |
367 | + logger.info('Waiting for SMS message...') | |
368 | + try: | |
369 | + modem.rxThread.join(2**31) # Specify a (huge) timeout so that it essentially blocks indefinitely, but still receives CTRL+C interrupt signal | |
370 | + finally: | |
371 | + modem.close(); | |
372 | + | |
373 | +if __name__ == '__main__': | |
374 | + main() |
sate24.py
... | ... | @@ -0,0 +1,38 @@ |
1 | +def parsePullMessage(message): | |
2 | + task = { | |
3 | + 'status': 'NONE' | |
4 | + } | |
5 | + | |
6 | + if message == 'NONE': | |
7 | + return task | |
8 | + | |
9 | + values = message.split(';') | |
10 | + try: | |
11 | + | |
12 | + task = { | |
13 | + 'status': values[0], | |
14 | + 'requestId': values[1], | |
15 | + 'timestamp': values[3], | |
16 | + 'destination': values[4], | |
17 | + 'customer': values[5], | |
18 | + 'gateway_type': values[6], | |
19 | + 'product': values[7], | |
20 | + 'city': values[8] | |
21 | + } | |
22 | + | |
23 | + except: | |
24 | + task | |
25 | + | |
26 | + return task | |
27 | + | |
28 | +def keyByRequestId(chipinfo, requestId): | |
29 | + return str(chipinfo) + '.trx.requestId:' + str(requestId) | |
30 | + | |
31 | +def keyByNominalDestination(chipinfo, nominal, destination): | |
32 | + return str(chipinfo) + '.trx.nominal:' + str(nominal) + '.destination:' + str(destination) | |
33 | + | |
34 | +def main(): | |
35 | + return | |
36 | + | |
37 | +if __name__ == '__main__': | |
38 | + main() |
test_sate24.py
... | ... | @@ -0,0 +1,21 @@ |
1 | +import sate24 | |
2 | + | |
3 | +def test_parsePullMessage(): | |
4 | + task = sate24.parsePullMessage('NONE') | |
5 | + assert task['status'] == 'NONE' | |
6 | + | |
7 | + task = sate24.parsePullMessage('OK;181380;181359;20150323155308;02199994004;ADHISIMON;aaa_pull;E5;DKI_JAKARTA;0;;') | |
8 | + assert task['status'] == 'OK' | |
9 | + assert task['requestId'] == '181380' | |
10 | + assert task['timestamp'] == '20150323155308' | |
11 | + assert task['destination'] == '02199994004' | |
12 | + assert task['customer'] == 'ADHISIMON' | |
13 | + assert task['gateway_type'] == 'aaa_pull' | |
14 | + assert task['product'] == 'E5' | |
15 | + assert task['city'] == 'DKI_JAKARTA' | |
16 | + | |
17 | +def test_keyByRequestId(): | |
18 | + assert sate24.keyByRequestId('TEST', '123') == 'TEST.trx.requestId:123' | |
19 | + | |
20 | +def test_keyByNominalDestination(): | |
21 | + assert sate24.keyByNominalDestination('TEST', '5000', '08180818') == 'TEST.trx.nominal:5000.destination:08180818' |
test_xltunai.py
... | ... | @@ -0,0 +1,113 @@ |
1 | +import xltunai | |
2 | + | |
3 | +def test_getOperatorFromProduct(): | |
4 | + assert xltunai.getOperatorFromProduct('XL5') == 'XL' | |
5 | + assert xltunai.getOperatorFromProduct('XL10') == 'XL' | |
6 | + assert xltunai.getOperatorFromProduct('XL25') == 'XL' | |
7 | + assert xltunai.getOperatorFromProduct('XL50') == 'XL' | |
8 | + assert xltunai.getOperatorFromProduct('XL100') == 'XL' | |
9 | + | |
10 | + assert xltunai.getOperatorFromProduct('XA5') == 'XA' | |
11 | + assert xltunai.getOperatorFromProduct('XA10') == 'XA' | |
12 | + assert xltunai.getOperatorFromProduct('XA25') == 'XA' | |
13 | + assert xltunai.getOperatorFromProduct('XA50') == 'XA' | |
14 | + assert xltunai.getOperatorFromProduct('XA100') == 'XA' | |
15 | + | |
16 | + assert xltunai.getOperatorFromProduct('XL100H') == 'XL' | |
17 | + | |
18 | +def test_getNominalFromProduct(): | |
19 | + assert xltunai.getNominalFromProduct('XL5') == 5000 | |
20 | + assert xltunai.getNominalFromProduct('XL10') == 10000 | |
21 | + assert xltunai.getNominalFromProduct('XL25') == 25000 | |
22 | + assert xltunai.getNominalFromProduct('XL50') == 50000 | |
23 | + assert xltunai.getNominalFromProduct('XL100') == 100000 | |
24 | + | |
25 | + assert xltunai.getNominalFromProduct('XA5') == 5000 | |
26 | + assert xltunai.getNominalFromProduct('XA10') == 10000 | |
27 | + assert xltunai.getNominalFromProduct('XA25') == 25000 | |
28 | + assert xltunai.getNominalFromProduct('XA50') == 50000 | |
29 | + assert xltunai.getNominalFromProduct('XA100') == 100000 | |
30 | + | |
31 | + assert xltunai.getNominalFromProduct('XL100H') == 100000 | |
32 | + | |
33 | +def test_getDenomCodeFromProduct(): | |
34 | + assert xltunai.getDenomCodeFromProduct('XL5') == 1 | |
35 | + assert xltunai.getDenomCodeFromProduct('XL10') == 2 | |
36 | + assert xltunai.getDenomCodeFromProduct('XL25') == 3 | |
37 | + assert xltunai.getDenomCodeFromProduct('XL50') == 4 | |
38 | + assert xltunai.getDenomCodeFromProduct('XL100') == 5 | |
39 | + assert xltunai.getDenomCodeFromProduct('XL200') == 6 | |
40 | + | |
41 | + assert xltunai.getDenomCodeFromProduct('XL300') == None | |
42 | + | |
43 | +def test_getCostFromProduct(): | |
44 | + assert xltunai.getCostFromProduct('XL5') == 5500 | |
45 | + assert xltunai.getCostFromProduct('XL10') == 10500 | |
46 | + assert xltunai.getCostFromProduct('XL25') == 25000 | |
47 | + assert xltunai.getCostFromProduct('XL50') == 50000 | |
48 | + assert xltunai.getCostFromProduct('XL100') == 100000 | |
49 | + assert xltunai.getCostFromProduct('XL200') == 200000 | |
50 | + | |
51 | + assert xltunai.getCostFromProduct('XL300') == 0 | |
52 | + | |
53 | +def test_getSNFromMessage(): | |
54 | + assert xltunai.getSNFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.5000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '75614092695337' | |
55 | + assert xltunai.getSNFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.5000. Nikmati transaksi XL Tunai lainnya. SN ID :75614092695337') == '' | |
56 | + | |
57 | +def test_toInternationalNumber(): | |
58 | + assert xltunai.toInternationalNumber('081808180818') == '6281808180818' | |
59 | + assert xltunai.toInternationalNumber('6281808180818') == '6281808180818' | |
60 | + assert xltunai.toInternationalNumber('4114') == '4114' | |
61 | + | |
62 | +def test_getDestinationFromMessage(): | |
63 | + assert xltunai.getDestinationFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.5000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '6287884153131' | |
64 | + | |
65 | +def test_getNominalFromMessage(): | |
66 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.5000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '5000' | |
67 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.10000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '10000' | |
68 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.25000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '25000' | |
69 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.50000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '50000' | |
70 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.100000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '100000' | |
71 | + | |
72 | + assert xltunai.getNominalFromMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '' | |
73 | + assert xltunai.getNominalFromMessage('Bla bla') == '' | |
74 | + | |
75 | + | |
76 | +def test_getBalanceFromUSSDResponse(): | |
77 | + message = """USSD response message: Saldo Rp134500 | |
78 | +Isi Pulsa 5000, Pilih menu no.2 | |
79 | +1 Belanja di toko | |
80 | +2 Isi Pulsa | |
81 | +3 Pembayaran | |
82 | +4 Belanja Online | |
83 | +5 Kirim Uang | |
84 | +6 Tarik Uang | |
85 | +7 Pengaturan | |
86 | +8 Info""" | |
87 | + assert xltunai.getBalanceFromUSSDResponse(message) == 134500 | |
88 | + | |
89 | +def test_getTopupUSSDCommand(): | |
90 | + assert xltunai.getTopupUSSDCommand('081808180818', 'XL2', '1234') == None | |
91 | + assert xltunai.getTopupUSSDCommand('081808180818', 'XL5', '1234') == '*123*120*2*2*081808180818*1*1234#' | |
92 | + assert xltunai.getTopupUSSDCommand('6281808180818', 'XL5', '1234') == '*123*120*2*2*6281808180818*1*1234#' | |
93 | + assert xltunai.getTopupUSSDCommand('081808180818', 'XL50', '1234') == '*123*120*2*2*081808180818*4*1234#' | |
94 | + | |
95 | +def test_valid_sender(): | |
96 | + assert xltunai.isValidSender('120') == True | |
97 | + assert xltunai.isValidSender('121') == False | |
98 | + | |
99 | +def test_getResponseCodeByMessage(): | |
100 | + assert xltunai.getResponseCodeByMessage('Nomor 6287884153131 telah berhasil diisi pulsa sebesar Rp.5000. Nikmati transaksi XL Tunai lainnya. Ref ID :75614092695337') == '00' | |
101 | + assert xltunai.getResponseCodeByMessage('Bla bla bla') == '68' | |
102 | + | |
103 | +def test_getResponseCodeByUSSDResponse(): | |
104 | + assert xltunai.getResponseCodeByUSSDResponse('Mohon maaf, nomor yang Anda masukkan tidak valid') == '14' | |
105 | + assert xltunai.getResponseCodeByUSSDResponse('Bla bla bla') == '68' | |
106 | + | |
107 | +def test_getMSISDNFromSIMCardInfo(): | |
108 | + assert xltunai.getMSISDNFromSIMCardInfo('Nomor 6287880852347 adalah nomor dengan POC JK0 dengan ICCID 8962116820756544479') == '6287880852347' | |
109 | + assert xltunai.getMSISDNFromSIMCardInfo('bla bla bla') == '' | |
110 | + | |
111 | +def test_getIMSIFromSIMCardInfo(): | |
112 | + assert xltunai.getIMSIFromSIMCardInfo('Nomor 6287880852347 adalah nomor dengan POC JK0 dengan ICCID 8962116820756544479') == '8962116820756544479' | |
113 | + assert xltunai.getIMSIFromSIMCardInfo('bla bla bla') == '' |
xltunai.py
... | ... | @@ -0,0 +1,124 @@ |
1 | +import re | |
2 | + | |
3 | +nominalCodes = { | |
4 | + 5000: 1, | |
5 | + 10000: 2, | |
6 | + 25000: 3, | |
7 | + 50000: 4, | |
8 | + 100000: 5, | |
9 | + 200000: 6 | |
10 | +} | |
11 | + | |
12 | +nominalCosts = { | |
13 | + 5000: 5500, | |
14 | + 10000: 10500, | |
15 | + 25000: 25000, | |
16 | + 50000: 50000, | |
17 | + 100000: 100000, | |
18 | + 200000: 200000 | |
19 | +} | |
20 | + | |
21 | +valid_senders = ['120',] | |
22 | +message_codes = { | |
23 | + 'telah berhasil diisi pulsa sebesar': '00', | |
24 | + 'Maaf, transaksi Anda masih dalam proses': '40' | |
25 | +} | |
26 | + | |
27 | +ussd_response_codes = { | |
28 | + 'nomor yang Anda masukkan tidak valid': '14', | |
29 | + 'Maaf, transaksi Anda masih dalam proses': '40', | |
30 | + 'Mohon maaf, transaksi Anda melebihi limit': '40', | |
31 | + 'http url not found': '40' | |
32 | +} | |
33 | + | |
34 | +def getOperatorFromProduct(product): | |
35 | + return re.sub(r'\d+.*', '', product) | |
36 | + | |
37 | +def getNominalFromProduct(product): | |
38 | + return int("".join(re.findall(r'\d+', product))) * 1000 | |
39 | + | |
40 | +def getNominalFromMessage(message): | |
41 | + nominal = "".join(re.findall(r'pulsa sebesar Rp\.\d+\.', message)) | |
42 | + nominal = "".join(re.findall(r'\d+', nominal)) | |
43 | + return nominal | |
44 | + | |
45 | +def getDenomCodeFromProduct(product): | |
46 | + nominal = getNominalFromProduct(product) | |
47 | + try: | |
48 | + result = nominalCodes[nominal] | |
49 | + except: | |
50 | + result = None | |
51 | + | |
52 | + return result | |
53 | + | |
54 | +def getCostFromProduct(product): | |
55 | + nominal = getNominalFromProduct(product) | |
56 | + try: | |
57 | + result = nominalCosts[nominal] | |
58 | + except: | |
59 | + result = 0 | |
60 | + | |
61 | + return int(result) | |
62 | + | |
63 | +def getDestinationFromMessage(message): | |
64 | + result = "".join(re.findall(r'^Nomor \d+ telah berhasil', message)) | |
65 | + result = "".join(re.findall(r'\d+', result)) | |
66 | + return result | |
67 | + | |
68 | +def getSNFromMessage(message): | |
69 | + try: | |
70 | + sn = "".join(re.findall(r'Ref ID :\d+', message)) | |
71 | + sn = "".join(re.findall(r'\d+', sn)) | |
72 | + except: | |
73 | + sn = "" | |
74 | + | |
75 | + return sn | |
76 | + | |
77 | +def toInternationalNumber(number): | |
78 | + return re.sub(r'^0', '62', number) | |
79 | + | |
80 | +def getSIMCardInfoUSSDCommand(): | |
81 | + return '*123*7*3*1*1#' | |
82 | + | |
83 | +def getMSISDNFromSIMCardInfo(message): | |
84 | + msisdn = "".join(re.findall(r'Nomor \d+ adalah nomor', message)) | |
85 | + msisdn = "".join(re.findall(r'\d+', msisdn)) | |
86 | + return msisdn | |
87 | + | |
88 | +def getIMSIFromSIMCardInfo(message): | |
89 | + imsi = "".join(re.findall(r'dengan ICCID \d+', message)) | |
90 | + imsi = "".join(re.findall(r'\d+', imsi)) | |
91 | + return imsi | |
92 | + | |
93 | +def getBalanceFromUSSDResponse(message): | |
94 | + balance = "".join(re.findall(r'Saldo Rp\d+', message)) | |
95 | + balance = re.sub('Saldo Rp', '', balance) | |
96 | + try: | |
97 | + return int(balance) | |
98 | + except: | |
99 | + return 0 | |
100 | + | |
101 | +def getTopupUSSDCommand(destination, product, pin_trx): | |
102 | + denom_code = getDenomCodeFromProduct(product) | |
103 | + if not denom_code: | |
104 | + return None | |
105 | + | |
106 | + ussdCommand = u'*123*120*2*2*{0}*{1}*{2}#'.format(destination, denom_code, pin_trx); | |
107 | + return ussdCommand | |
108 | + | |
109 | +def isValidSender(sender): | |
110 | + return str(sender) in valid_senders | |
111 | + | |
112 | +def getResponseCodeByMessage(message): | |
113 | + for pattern in message_codes: | |
114 | + if message.find(pattern) >= 0: | |
115 | + return message_codes[pattern] | |
116 | + | |
117 | + return '68' | |
118 | + | |
119 | +def getResponseCodeByUSSDResponse(response_message): | |
120 | + for pattern in ussd_response_codes: | |
121 | + if response_message.find(pattern) >= 0: | |
122 | + return ussd_response_codes[pattern] | |
123 | + | |
124 | + return '68' |