Commit 329b73b1484f36df44ec910dde3ea5fdcb0514bd

Authored by Adhidarma Hadiwinoto
0 parents
Exists in master

initial commit

Showing 14 changed files with 3293 additions and 0 deletions Side-by-side Diff

... ... @@ -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
... ... @@ -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 """
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')
... ... @@ -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)
... ... @@ -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
... ... @@ -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()
... ... @@ -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()
... ... @@ -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'
... ... @@ -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') == ''
... ... @@ -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'