diff --git a/.gitignore b/.gitignore index b06378c..cce4692 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ htmlcov # Working copy files *.swp + +.DS_Store diff --git a/.travis.yml b/.travis.yml index 44d47c8..4b9d14c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,15 @@ language: python python: + - "3.6" + - "3.5" + - "3.4" - "3.3" - - "3.2" - "2.7" - - "2.6" install: # Install unittest2 on Python 2.6 - - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install --use-mirrors unittest2; fi + - if [[ $TRAVIS_PYTHON_VERSION == '2.6' ]]; then pip install unittest2; fi # Install coveralls (for coveralls.io integration) - pip install coveralls - - pip install -r requirements.txt --use-mirrors + - pip install -r requirements.txt script: python setup.py coverage after_success: coveralls diff --git a/AUTHORS b/AUTHORS index ec8a6f3..09a3e3f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,4 +8,16 @@ the01 Frederico Rosmaninho David Beitey BOOMER74 +Cyril-Roques +PeteLawler +alex-eri +tomchy +bennyslbs +epol +rags22489664 +fataevalex +paolo-losi +yuriykashin +foXes68 +babca diff --git a/ChangeLog b/ChangeLog index 56cd87c..2703c83 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,30 @@ +* Wed Mar 15 2017 babca - 0.12 +– stable release +- unit tests fixed after rapid merging – credits to: tomchy +- python3.6 support added +– message concatenation fixes and more + +* Thu Nov 10 2016 babca - 0.11 +- added getter for SIM own number +- added option for blocking incoming calls (GSMBUSY) +- various python3 fixes + +* Thu Aug 18 2016 babca - 0.10 +– Probably a new code maintainer for 2016 +- All commits published for the last 3 years merged into a one branch +– Compatibilty for python3 added, needs further testing! +– experimental GPRS support +– more: + – change AT_CNMI command if needed + – waitingForModemToStartInSeconds + – timeouts increased + – ability to check SMS encodings supported by modem - smsSupportedEncoding() + – better modem specific support (incl. simcom) + – TE SMS status reports handling support + – option to disable requesting delivery reports + – incoming DTMF support +– todo: check AT+CMGD support for 1 or 2 params and use appropriate command format + * Thu Jul 18 2013 Francois Aucamp - 0.9 - Added UDH support for SMS PDUs - Stored messages APIs made public diff --git a/README.rst b/README.rst index 054ef08..66aff24 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -python-gsmmodem -=============== +python-gsmmodem-new v0.12 +========================= *GSM modem module for Python* python-gsmmodem is a module that allows easy control of a GSM modem attached @@ -28,56 +28,71 @@ Bundled utilities: - **identify-modem.py**: simple utility to identify attached modem. Can also be used to provide debug information used for development of python-gsmmodem. +How to use this package +----------------------- + +Go to `examples/` directory in this repo. + + Requirements ------------ -- Python 2.6 or later +- Python 2.7 or later +- Python 3.3 or later - pyserial How to install this package --------------------------- -There are two ways to install ``python-gsmmodem``: +There are multiple ways to install ``python-gsmmodem-new`` package: -Automatic installation -~~~~~~~~~~~~~~~~~~~~~~ +Automatic installation of the latest "stable" release from PyPI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :: - pip install python-gsmmodem + pip install python-gsmmodem-new `pip `_ will automatically download and install all dependencies, as required. You can also utilise ``easy_install`` in the same manner as using ``pip`` above. -If you are utilising ``python-gsmmodem`` as part of another project, +If you are utilising ``python-gsmmodem-new`` as part of another project, add it to your ``install_requires`` section of your ``setup.py`` file and upon your project's installation, it will be pulled in automatically. -Manual installation -~~~~~~~~~~~~~~~~~~~ +Manual installation of the latest "stable" release from PyPI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Download and extract the ``python-gsmmodem`` archive from `PyPI -`_ for the current release -version, or clone from `GitHub `_. -Next, do this:: +Download a ``python-gsmmodem-new`` archive from `PyPI +`_, extract it and install the package with command:: python setup.py install -Note that ``python-gsmmodem`` relies on ``pyserial`` for serial communications: -http://pyserial.sourceforge.net +Note that ``python-gsmmodem-new`` package relies on ``pyserial`` for serial communications: +https://github.com/pyserial/pyserial + +Installation of the latest commit from GitHub +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Clone from GitHub:: + + git clone https://github.com/babca/python-gsmmodem.git + cd python-gsmmodem/ + python setup.py install + +Note that ``python-gsmmodem-new`` package relies on ``pyserial`` for serial communications: +https://github.com/pyserial/pyserial Testing the package ------------------- -.. |Build Status| image:: https://travis-ci.org/faucamp/python-gsmmodem.png?branch=master -.. _Build Status: https://travis-ci.org/faucamp/python-gsmmodem - +.. |Build Status| image:: https://travis-ci.org/babca/python-gsmmodem.svg?branch=master +.. _Build Status: https://travis-ci.org/babca/python-gsmmodem -.. |Coverage Status| image:: https://coveralls.io/repos/faucamp/python-gsmmodem/badge.png?branch=master -.. _Coverage Status: https://coveralls.io/r/faucamp/python-gsmmodem +.. |Coverage Status| image:: https://coveralls.io/repos/github/babca/python-gsmmodem/badge.svg?branch=master +.. _Coverage Status: https://coveralls.io/github/babca/python-gsmmodem?branch=master |Build Status|_ |Coverage Status|_ @@ -100,7 +115,7 @@ Building documentation This package contains `Sphinx `_-based documentation. To manually build or test the documentation locally, do the following:: - git clone https://github.com/faucamp/python-gsmmodem.git + git clone https://github.com/babca/python-gsmmodem.git cd python-gsmmodem pip install .[doc] cd doc @@ -110,6 +125,7 @@ For true isolation, you may wish to run the above commands within a `virtualenv `_, which will help you manage this development installation. + License information ------------------- @@ -118,3 +134,19 @@ See AUTHORS for all authors and contact information. License: GNU Lesser General Public License, version 3 or later; see COPYING included in this archive for details. + +FAQ +--- + +List all modem ports +~~~~~~~~~~~~~~~~~~~~ + +You can simply list all ttyUSB devices before and after pluging the modem in. + + ls /dev/ttyUSB* + + +Device or resource busy error +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Check running processes. The device could be occupied by another program or another instance of gsmmodem which is still running in the background. Run ``sudo lsof | grep tty``, try to locate the problematic process and ``sudo kill ``. diff --git a/examples/incoming_call_demo.py b/examples/incoming_call_demo.py index 3ef9a22..6e8f4bc 100755 --- a/examples/incoming_call_demo.py +++ b/examples/incoming_call_demo.py @@ -4,7 +4,7 @@ Demo: handle incoming calls Simple demo app that listens for incoming calls, displays the caller ID, -optionally answers the call and plays sone DTMF tones (if supported by modem), +optionally answers the call and plays sone DTMF tones (if supported by modem), and hangs up the call. """ @@ -27,29 +27,29 @@ def handleIncomingCall(call): print('Answering call and playing some DTMF tones...') call.answer() # Wait for a bit - some older modems struggle to send DTMF tone immediately after answering a call - time.sleep(2.0) + time.sleep(2.0) try: call.sendDtmfTone('9515999955951') except InterruptedException as e: # Call was ended during playback - print('DTMF playback interrupted: {0} ({1} Error {2})'.format(e, e.cause.type, e.cause.code)) + print('DTMF playback interrupted: {0} ({1} Error {2})'.format(e, e.cause.type, e.cause.code)) finally: if call.answered: print('Hanging up call.') call.hangup() - else: + else: print('Modem has no DTMF support - hanging up call.') call.hangup() else: print(' Call from {0} is still ringing...'.format(call.number)) - + def main(): print('Initializing modem...') #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) modem = GsmModem(PORT, BAUDRATE, incomingCallCallbackFunc=handleIncomingCall) modem.connect(PIN) print('Waiting for incoming calls...') - try: + try: modem.rxThread.join(2**31) # Specify a (huge) timeout so that it essentially blocks indefinitely, but still receives CTRL+C interrupt signal finally: modem.close() diff --git a/examples/own_number_demo.py b/examples/own_number_demo.py new file mode 100755 index 0000000..e8faebe --- /dev/null +++ b/examples/own_number_demo.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +"""\ +Demo: read own phone number +""" + +from __future__ import print_function + +import logging + +PORT = '/dev/vmodem0' +BAUDRATE = 115200 +PIN = None # SIM card PIN (if any) + +from gsmmodem.modem import GsmModem + +def main(): + print('Initializing modem...') + # Uncomment the following line to see what the modem is doing: + logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + modem = GsmModem(PORT, BAUDRATE) + modem.connect(PIN) + + number = modem.ownNumber + print("The SIM card phone number is:") + print(number) + + # Uncomment the following block to change your own number. + # modem.ownNumber = "+000123456789" # lease empty for removing the phone entry altogether + + # number = modem.ownNumber + # print("A new phone number is:") + # print(number) + + # modem.close(); + +if __name__ == '__main__': + main() diff --git a/examples/sms_handler_demo.py b/examples/sms_handler_demo.py index ef1cb69..c7901a6 100755 --- a/examples/sms_handler_demo.py +++ b/examples/sms_handler_demo.py @@ -18,23 +18,25 @@ from gsmmodem.modem import GsmModem def handleSms(sms): - print(u'== SMS message received ==\nFrom: {0}\nTime: {1}\nMessage:\n{2}\n'.format(sms.number, sms.time, sms.text)) + # long sms Concatenation support: reference, parts, number + concat = sms.concat.__dict__ if sms.concat else {} + print(u'== SMS message received ==\nFrom: {0}\nTime: {1}\nconcat: {2}\nMessage:\n{3}\n'.format(sms.number, sms.time, concat, sms.text)) print('Replying to SMS...') sms.reply(u'SMS received: "{0}{1}"'.format(sms.text[:20], '...' if len(sms.text) > 20 else '')) print('SMS sent.\n') - + def main(): print('Initializing modem...') # Uncomment the following line to see what the modem is doing: logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) modem = GsmModem(PORT, BAUDRATE, smsReceivedCallbackFunc=handleSms) - modem.smsTextMode = False + modem.smsTextMode = False modem.connect(PIN) - print('Waiting for SMS message...') - try: + print('Waiting for SMS message...') + try: modem.rxThread.join(2**31) # Specify a (huge) timeout so that it essentially blocks indefinitely, but still receives CTRL+C interrupt signal finally: - modem.close(); + modem.close() if __name__ == '__main__': main() diff --git a/gsmmodem/gprs.py b/gsmmodem/gprs.py new file mode 100644 index 0000000..af164f4 --- /dev/null +++ b/gsmmodem/gprs.py @@ -0,0 +1,96 @@ +# -*- coding: utf8 -*- + +""" GPRS/Data-specific classes + +BRANCH: mms + +PLEASE NOTE: *Everything* in this file (PdpContext, GprsModem class, etc) is experimental. +This is NOT meant to be used in production in any way; the API is completely unstable, +no unit tests will be written for this in the forseeable future, and stuff may generally +break and cause riots. Please do not file bug reports against this branch unless you +have a patch to go along with it, but even then: remember that this entire "mms" branch +is exploratory; I simply want to see what the possibilities are with it. + +Use the "main" branch, and the GsmModem class if you want to build normal applications. +""" + +import re + +from .util import allLinesMatchingPattern +from .modem import GsmModem + +class PdpContext(object): + """ Packet Data Protocol (PDP) context parameter values """ + def __init__(self, cid, pdpType, apn, pdpAddress=None, dataCompression=0, headerCompression=0): + """ Construct a new Packet Data Protocol context + + @param cid: PDP Context Identifier - specifies a particular PDP context definition + @type cid: int + @param pdpType: the type of packet data protocol (IP, PPP, IPV6, etc) + @type pdpType: str + @param apn: Access Point Name; logical name used to select the GGSN or external packet data network + @type apn: str + @param pdpAddress: identifies the MT in the address space applicable to the PDP. If None, a dynamic address may be requested. + @type pdpAddress: str + @param dataCompression: PDP data compression; 0 == off, 1 == on + @type dataCompression: int + @param headerCompression: PDP header compression; 0 == off, 1 == on + @type headerCompression: int + """ + self.cid = cid + self.pdpType = pdpType + self.apn = apn + self.pdpAddress = pdpAddress + self.dataCompression = dataCompression + self.headerCompression = headerCompression + + +class GprsModem(GsmModem): + """ EXPERIMENTAL: Specialized version of GsmModem that includes GPRS/data-specific commands """ + + @property + def pdpContexts(self): + """ Currently-defined Packet Data Protocol (PDP) context list + + PDP paramter values returned include PDP type (IP, IPV6, PPP, X.25 etc), APN, + data compression, header compression, etc. + + @return: a list of currently-defined PDP contexts + """ + result = [] + cgdContResult = self.write('AT+CGDCONT?') + matches = allLinesMatchingPattern(re.compile(r'^\+CGDCONT:\s*(\d+),"([^"]+)","([^"]+)","([^"]+)",(\d+),(\d+)'), cgdContResult) + for cgdContMatch in matches: + cid, pdpType, apn, pdpAddress, dataCompression, headerCompression = cgdContMatch.groups() + pdpContext = PdpContext(cid, pdpType, apn, pdpAddress, dataCompression, headerCompression) + result.append(pdpContext) + return result + + @property + def defaultPdpContext(self): + """ @return: the default PDP context, or None if not defined """ + pdpContexts = self.pdpContexts + return pdpContexts[0] if len(pdpContexts) > 0 else None + @defaultPdpContext.setter + def defaultPdpContext(self, pdpContext): + """ Set the default PDP context (or clear it by setting it to None) """ + self.write('AT+CGDCONT=,"{0}","{1}","{2}",{3},{4}'.format(pdpContext.pdpType, pdpContext.apn, pdpContext.pdpAddress or '', pdpContext.dataCompression, pdpContext.headerCompression)) + + def definePdpContext(self, pdpContext): + """ Define a new Packet Data Protocol context, or overwrite an existing one + + @param pdpContext: The PDP context to define + @type pdpContext: gsmmodem.gprs.PdpContext + """ + self.write('AT+CGDCONT={0},"{1}","{2}","{3}",{4},{5}'.format(pdpContext.cid or '', pdpContext.pdpType, pdpContext.apn, pdpContext.pdpAddress or '', pdpContext.dataCompression, pdpContext.headerCompression)) + + def initDataConnection(self, pdpCid=1): + """ Initializes a packet data (GPRS) connection using the specified PDP Context ID """ + # From this point on, we don't want the read thread interfering + #self.log.debug('Stopping read thread') + #self.alive = False + #self.rxThread.join() + self.log.debug('Init data connection') + self.write('ATD*99#', expectedResponseTermSeq="CONNECT\r") + self.log.debug('Data connection open; ready for PPP comms') + # From here on we use PPP to communicate with the network diff --git a/gsmmodem/modem.py b/gsmmodem/modem.py index e468f34..4b47c93 100644 --- a/gsmmodem/modem.py +++ b/gsmmodem/modem.py @@ -4,16 +4,21 @@ import sys, re, logging, weakref, time, threading, abc, codecs from datetime import datetime +from time import sleep from .serial_comms import SerialComms from .exceptions import CommandError, InvalidStateException, CmeError, CmsError, InterruptedException, TimeoutException, PinRequiredError, IncorrectPinError, SmscNumberUnknownError -from .pdu import encodeSmsSubmitPdu, decodeSmsPdu -from .util import SimpleOffsetTzInfo, lineStartingWith, allLinesMatchingPattern, parseTextModeTimeStr +from .pdu import encodeSmsSubmitPdu, decodeSmsPdu, encodeGsm7, encodeTextMode, Concatenation +from .util import SimpleOffsetTzInfo, lineStartingWith, allLinesMatchingPattern, parseTextModeTimeStr, removeAtPrefix -from . import compat # For Python 2.6 compatibility +#from . import compat # For Python 2.6 compatibility from gsmmodem.util import lineMatching from gsmmodem.exceptions import EncodingError PYTHON_VERSION = sys.version_info[0] + +CTRLZ = '\x1a' +TERMINATOR = '\r' + if PYTHON_VERSION >= 3: xrange = range dictValuesIter = dict.values @@ -48,21 +53,31 @@ def __init__(self, number, text, smsc=None): class ReceivedSms(Sms): """ An SMS message that has been received (MT) """ - - def __init__(self, gsmModem, status, number, time, text, smsc=None): + + def __init__(self, gsmModem, status, number, time, text, smsc=None, udh=[], index=None, concat=None): super(ReceivedSms, self).__init__(number, text, smsc) self._gsmModem = weakref.proxy(gsmModem) self.status = status self.time = time - + self.udh = udh + self.index = index + self.concat = concat + def reply(self, message): """ Convenience method that sends a reply SMS to the sender of this message """ return self._gsmModem.sendSms(self.number, message) + def sendSms(self, dnumber, message): + """ Convenience method that sends a SMS to someone else """ + return self._gsmModem.sendSms(dnumber, message) + + def getModem(self): + """ Convenience method that returns the gsm modem instance """ + return self._gsmModem class SentSms(Sms): """ An SMS message that has been sent (MO) """ - + ENROUTE = 0 # Status indicating message is still enroute to destination DELIVERED = 1 # Status indicating message has been received by destination handset FAILED = 2 # Status indicating message delivery has failed @@ -71,11 +86,11 @@ def __init__(self, number, text, reference, smsc=None): super(SentSms, self).__init__(number, text, smsc) self.report = None # Status report for this SMS (StatusReport object) self.reference = reference - + @property def status(self): """ Status of this SMS. Can be ENROUTE, DELIVERED or FAILED - + The actual status report object may be accessed via the 'report' attribute if status is 'DELIVERED' or 'FAILED' """ @@ -87,15 +102,15 @@ def status(self): class StatusReport(Sms): """ An SMS status/delivery report - + Note: the 'status' attribute of this class refers to this status report SM's status (whether it has been read, etc). To find the status of the message that caused this status report, use the 'deliveryStatus' attribute. """ - + DELIVERED = 0 # SMS delivery status: delivery successful FAILED = 68 # SMS delivery status: delivery failed - + def __init__(self, gsmModem, status, reference, number, timeSent, timeFinalized, deliveryStatus, smsc=None): super(StatusReport, self).__init__(number, None, smsc) self._gsmModem = weakref.proxy(gsmModem) @@ -108,17 +123,19 @@ def __init__(self, gsmModem, status, reference, number, timeSent, timeFinalized, class GsmModem(SerialComms): """ Main class for interacting with an attached GSM modem """ - + log = logging.getLogger('gsmmodem.modem.GsmModem') # Used for parsing AT command errors - CM_ERROR_REGEX = re.compile(r'^\+(CM[ES]) ERROR: (\d+)$') + CM_ERROR_REGEX = re.compile('^\+(CM[ES]) ERROR: (\d+)$') # Used for parsing signal strength query responses - CSQ_REGEX = re.compile(r'^\+CSQ:\s*(\d+),') + CSQ_REGEX = re.compile('^\+CSQ:\s*(\d+),') # Used for parsing caller ID announcements for incoming calls. Group 1 is the number - CLIP_REGEX = re.compile(r'^\+CLIP:\s*"(\+{0,1}\d+)",(\d+).*$') + CLIP_REGEX = re.compile('^\+CLIP:\s*"\+{0,1}(\d+)",(\d+).*$') + # Used for parsing own number. Group 1 is the number + CNUM_REGEX = re.compile('^\+CNUM:\s*".*?","(\+{0,1}\d+)",(\d+).*$') # Used for parsing new SMS message indications - CMTI_REGEX = re.compile(r'^\+CMTI:\s*"([^"]+)",(\d+)$') + CMTI_REGEX = re.compile('^\+CMTI:\s*"([^"]+)",\s*(\d+)$') # Used for parsing SMS message reads (text mode) CMGR_SM_DELIVER_REGEX_TEXT = None # Used for parsing SMS status report message reads (text mode) @@ -126,15 +143,18 @@ class GsmModem(SerialComms): # Used for parsing SMS message reads (PDU mode) CMGR_REGEX_PDU = None # Used for parsing USSD event notifications - CUSD_REGEX = re.compile(r'\+CUSD:\s*(\d),"(.*?)",(\d+)', re.DOTALL) + CUSD_REGEX = re.compile('\+CUSD:\s*(\d),\s*"(.*?)",\s*(\d+)', re.DOTALL) # Used for parsing SMS status reports - CDSI_REGEX = re.compile(r'\+CDSI:\s*"([^"]+)",(\d+)$') - - def __init__(self, port, baudrate=115200, incomingCallCallbackFunc=None, smsReceivedCallbackFunc=None, smsStatusReportCallback=None): - super(GsmModem, self).__init__(port, baudrate, notifyCallbackFunc=self._handleModemNotification) + CDSI_REGEX = re.compile('\+CDSI:\s*"([^"]+)",(\d+)$') + CDS_REGEX = re.compile('\+CDS:\s*([0-9]+)"$') + + def __init__(self, port, baudrate=115200, incomingCallCallbackFunc=None, smsReceivedCallbackFunc=None, smsStatusReportCallback=None, requestDelivery=True, AT_CNMI="", *a, **kw): + super(GsmModem, self).__init__(port, baudrate, notifyCallbackFunc=self._handleModemNotification, *a, **kw) self.incomingCallCallback = incomingCallCallbackFunc or self._placeholderCallback self.smsReceivedCallback = smsReceivedCallbackFunc or self._placeholderCallback self.smsStatusReportCallback = smsStatusReportCallback or self._placeholderCallback + self.requestDelivery = requestDelivery + self.AT_CNMI = AT_CNMI or "2,1,0,2" # Flag indicating whether caller ID for incoming call notification has been set up self._callingLineIdentification = False # Flag indicating whether incoming call notifications have extended information @@ -155,34 +175,49 @@ def __init__(self, port, baudrate=115200, incomingCallCallbackFunc=None, smsRece self._pollCallStatusRegex = None # Regular expression used when polling outgoing call status self._writeWait = 0 # Time (in seconds to wait after writing a command (adjusted when 515 errors are detected) self._smsTextMode = False # Storage variable for the smsTextMode property + self._gsmBusy = 0 # Storage variable for the GSMBUSY property self._smscNumber = None # Default SMSC number self._smsRef = 0 # Sent SMS reference counter self._smsMemReadDelete = None # Preferred message storage memory for reads/deletes ( parameter used for +CPMS) self._smsMemWrite = None # Preferred message storage memory for writes ( parameter used for +CPMS) self._smsReadSupported = True # Whether or not reading SMS messages is supported via AT commands + self._smsEncoding = 'GSM' # Default SMS encoding + self._smsSupportedEncodingNames = None # List of available encoding names + self._commands = None # List of supported AT commands + #Pool of detected DTMF + self.dtmfpool = [] - def connect(self, pin=None): + def connect(self, pin=None, waitingForModemToStartInSeconds=0): """ Opens the port and initializes the modem and SIM card - + :param pin: The SIM card PIN code, if any :type pin: str - + :raise PinRequiredError: if the SIM card requires a PIN but none was provided :raise IncorrectPinError: if the specified PIN is incorrect """ - self.log.info('Connecting to modem on port %s at %dbps', self.port, self.baudrate) + self.log.info('Connecting to modem on port %s at %dbps', self.port, self.baudrate) super(GsmModem, self).connect() + + if waitingForModemToStartInSeconds > 0: + while waitingForModemToStartInSeconds > 0: + try: + self.write('AT', waitForResponse=True, timeout=0.5) + break + except TimeoutException: + waitingForModemToStartInSeconds -= 0.5 + # Send some initialization commands to the modem - try: + try: self.write('ATZ') # reset configuration except CommandError: # Some modems require a SIM PIN at this stage already; unlock it now # Attempt to enable detailed error messages (to catch incorrect PIN error) # but ignore if it fails - self.write('AT+CMEE=1', parseError=False) + self.write('AT+CMEE=1', parseError=False) self._unlockSim(pin) pinCheckComplete = True - self.write('ATZ') # reset configuration + self.write('ATZ') # reset configuration else: pinCheckComplete = False self.write('ATE0') # echo off @@ -192,13 +227,14 @@ def connect(self, pin=None): self.write('AT+CFUN=1') except CommandError: pass # just ignore if the +CFUN command isn't supported - + self.write('AT+CMEE=1') # enable detailed error messages (even if it has already been set - ATZ may reset this) if not pinCheckComplete: self._unlockSim(pin) # Get list of supported commands from modem commands = self.supportedCommands + self._commands = commands # Device-specific settings callUpdateTableHint = 0 # unknown modem @@ -237,6 +273,14 @@ def connect(self, pin=None): # Attempt to identify modem type directly (if not already) - for outgoing call status updates if callUpdateTableHint == 0: + if 'simcom' in self.manufacturer.lower() : #simcom modems support DTMF and don't support AT+CLAC + Call.dtmfSupport = True + try: + self.write('AT+DDET=1') # enable detect incoming DTMF + except CommandError: + # simcom 7000E for example doesn't support the DDET command + Call.dtmfSupport = False + if self.manufacturer.lower() == 'huawei': callUpdateTableHint = 1 # huawei else: @@ -251,9 +295,9 @@ def connect(self, pin=None): if callUpdateTableHint == 1: # Use Hauwei's ^NOTIFICATIONs self.log.info('Loading Huawei call state update table') - self._callStatusUpdates = ((re.compile(r'^\^ORIG:(\d),(\d)$'), self._handleCallInitiated), - (re.compile(r'^\^CONN:(\d),(\d)$'), self._handleCallAnswered), - (re.compile(r'^\^CEND:(\d),(\d),(\d)+,(\d)+$'), self._handleCallEnded)) + self._callStatusUpdates = ((re.compile('^\^ORIG:(\d),(\d)$'), self._handleCallInitiated), + (re.compile('^\^CONN:(\d),(\d)$'), self._handleCallAnswered), + (re.compile('^\^CEND:(\d),(\d+),(\d)+,(\d)+$'), self._handleCallEnded)) self._mustPollCallStatus = False # Huawei modems use ^DTMF to send DTMF tones; use that instead Call.DTMF_COMMAND_BASE = '^DTMF={cid},' @@ -261,9 +305,9 @@ def connect(self, pin=None): elif callUpdateTableHint == 2: # Wavecom modem: +WIND notifications supported self.log.info('Loading Wavecom call state update table') - self._callStatusUpdates = ((re.compile(r'^\+WIND: 5,(\d)$'), self._handleCallInitiated), - (re.compile(r'^OK$'), self._handleCallAnswered), - (re.compile(r'^\+WIND: 6,(\d)$'), self._handleCallEnded)) + self._callStatusUpdates = ((re.compile('^\+WIND: 5,(\d)$'), self._handleCallInitiated), + (re.compile('^OK$'), self._handleCallAnswered), + (re.compile('^\+WIND: 6,(\d)$'), self._handleCallEnded)) self._waitForAtdResponse = False # Wavecom modems return OK only when the call is answered self._mustPollCallStatus = False if commands == None: # older modem, assume it has standard DTMF support @@ -271,9 +315,9 @@ def connect(self, pin=None): elif callUpdateTableHint == 3: # ZTE # Use ZTE notifications ("CONNECT"/"HANGUP", but no "call initiated" notification) self.log.info('Loading ZTE call state update table') - self._callStatusUpdates = ((re.compile(r'^CONNECT$'), self._handleCallAnswered), - (re.compile(r'^HANGUP:\s*(\d+)$'), self._handleCallEnded), - (re.compile(r'^OK$'), self._handleCallRejected)) + self._callStatusUpdates = ((re.compile('^CONNECT$'), self._handleCallAnswered), + (re.compile('^HANGUP:\s*(\d+)$'), self._handleCallEnded), + (re.compile('^OK$'), self._handleCallRejected)) self._waitForAtdResponse = False # ZTE modems do not return an immediate OK only when the call is answered self._mustPollCallStatus = False self._waitForCallInitUpdate = False # ZTE modems do not provide "call initiated" updates @@ -288,9 +332,9 @@ def connect(self, pin=None): # General meta-information setup self.write('AT+COPS=3,0', parseError=False) # Use long alphanumeric name format - + # SMS setup - self.write('AT+CMGF={0}'.format(1 if self._smsTextMode else 0)) # Switch to text or PDU mode for SMS messages + self.write('AT+CMGF={0}'.format(1 if self.smsTextMode else 0)) # Switch to text or PDU mode for SMS messages self._compileSmsRegexes() if self._smscNumber != None: self.write('AT+CSCA="{0}"'.format(self._smscNumber)) # Set default SMSC number @@ -300,7 +344,10 @@ def connect(self, pin=None): # Some modems delete the SMSC number when setting text-mode SMS parameters; preserve it if needed if currentSmscNumber != None: self._smscNumber = None # clear cache - self.write('AT+CSMP=49,167,0,0', parseError=False) # Enable delivery reports + if self.requestDelivery: + self.write('AT+CSMP=49,167,0,0', parseError=False) # Enable delivery reports + else: + self.write('AT+CSMP=17,167,0,0', parseError=False) # Not enable delivery reports # ...check SMSC again to ensure it did not change if currentSmscNumber != None and self.smsc != currentSmscNumber: self.smsc = currentSmscNumber @@ -335,15 +382,18 @@ def connect(self, pin=None): self.write('AT+CPMS={0}'.format(','.join(cpmsItems))) # Set message storage del cpmsSupport del cpmsLine - - if self._smsReadSupported: + + if self._smsReadSupported and (self.smsReceivedCallback or self.smsStatusReportCallback): try: - self.write('AT+CNMI=2,1,0,2') # Set message notifications + self.write('AT+CNMI=' + self.AT_CNMI) # Set message notifications except CommandError: - # Message notifications not supported - self._smsReadSupported = False - self.log.warning('Incoming SMS notifications not supported by modem. SMS receiving unavailable.') - + try: + self.write('AT+CNMI=2,1,0,1,0') # Set message notifications, using TE for delivery reports + except CommandError: + # Message notifications not supported + self._smsReadSupported = False + self.log.warning('Incoming SMS notifications not supported by modem. SMS receiving unavailable.') + # Incoming call notification setup try: self.write('AT+CLIP=1') # Enable calling line identification presentation @@ -358,16 +408,16 @@ def connect(self, pin=None): self._extendedIncomingCallIndication = False self.log.warning('Extended format incoming call indication not supported by modem. Error: {0}'.format(crcError)) else: - self._extendedIncomingCallIndication = True + self._extendedIncomingCallIndication = True # Call control setup self.write('AT+CVHU=0', parseError=False) # Enable call hang-up with ATH command (ignore if command not supported) - + def _unlockSim(self, pin): """ Unlocks the SIM card using the specified PIN (if necessary, else does nothing) """ # Unlock the SIM card if needed try: - cpinResponse = lineStartingWith('+CPIN', self.write('AT+CPIN?', timeout=0.25)) + cpinResponse = lineStartingWith('+CPIN', self.write('AT+CPIN?', timeout=15)) except TimeoutException as timeout: # Wavecom modems do not end +CPIN responses with "OK" (github issue #19) - see if just the +CPIN response was returned if timeout.data != None: @@ -383,8 +433,8 @@ def _unlockSim(self, pin): self.write('AT+CPIN="{0}"'.format(pin)) else: raise PinRequiredError('AT+CPIN') - - def write(self, data, waitForResponse=True, timeout=5, parseError=True, writeTerm='\r', expectedResponseTermSeq=None): + + def write(self, data, waitForResponse=True, timeout=10, parseError=True, writeTerm=TERMINATOR, expectedResponseTermSeq=None): """ Write data to the modem. This method adds the ``\\r\\n`` end-of-line sequence to the data parameter, and @@ -409,9 +459,10 @@ def write(self, data, waitForResponse=True, timeout=5, parseError=True, writeTer :return: A list containing the response lines from the modem, or None if waitForResponse is False :rtype: list """ + self.log.debug('write: %s', data) responseLines = super(GsmModem, self).write(data + writeTerm, waitForResponse=waitForResponse, timeout=timeout, expectedResponseTermSeq=expectedResponseTermSeq) - if self._writeWait > 0: # Sleep a bit if required (some older modems suffer under load) + if self._writeWait > 0: # Sleep a bit if required (some older modems suffer under load) time.sleep(self._writeWait) if waitForResponse: cmdStatusLine = responseLines[-1] @@ -442,15 +493,15 @@ def write(self, data, waitForResponse=True, timeout=5, parseError=True, writeTer else: raise CommandError(data) elif cmdStatusLine == 'COMMAND NOT SUPPORT': # Some Huawei modems respond with this for unknown commands - raise CommandError(data + '({0})'.format(cmdStatusLine)) + raise CommandError('{} ({})'.format(data,cmdStatusLine)) return responseLines @property def signalStrength(self): """ Checks the modem's cellular network signal strength - + :raise CommandError: if an error occurs - + :return: The network signal strength as an integer between 0 and 99, or -1 if it is unknown :rtype: int """ @@ -465,12 +516,12 @@ def signalStrength(self): def manufacturer(self): """ :return: The modem's manufacturer's name """ return self.write('AT+CGMI')[0] - + @property def model(self): """ :return: The modem's model name """ return self.write('AT+CGMM')[0] - + @property def revision(self): """ :return: The modem's software revision, or None if not known/supported """ @@ -478,21 +529,21 @@ def revision(self): return self.write('AT+CGMR')[0] except CommandError: return None - + @property def imei(self): """ :return: The modem's serial number (IMEI number) """ return self.write('AT+CGSN')[0] - + @property def imsi(self): """ :return: The IMSI (International Mobile Subscriber Identity) of the SIM card. The PIN may need to be entered before reading the IMSI """ return self.write('AT+CIMI')[0] - + @property def networkName(self): """ :return: the name of the GSM Network Operator to which the modem is connected """ - copsMatch = lineMatching(r'^\+COPS: (\d),(\d),"(.+)",{0,1}\d*$', self.write('AT+COPS?')) # response format: +COPS: mode,format,"operator_name",x + copsMatch = lineMatching('^\+COPS: (\d),(\d),"(.+)",{0,1}\d*$', self.write('AT+COPS?')) # response format: +COPS: mode,format,"operator_name",x if copsMatch: return copsMatch.group(3) @@ -502,19 +553,44 @@ def supportedCommands(self): try: # AT+CLAC responses differ between modems. Most respond with +CLAC: and then a comma-separated list of commands # while others simply return each command on a new line, with no +CLAC: prefix - response = self.write('AT+CLAC') + response = self.write('AT+CLAC', timeout=10) if len(response) == 2: # Single-line response, comma separated commands = response[0] if commands.startswith('+CLAC'): commands = commands[6:] # remove the +CLAC: prefix before splitting return commands.split(',') elif len(response) > 2: # Multi-line response - return [cmd.strip() for cmd in response[:-1]] + return [removeAtPrefix(cmd.strip()) for cmd in response[:-1]] else: self.log.debug('Unhandled +CLAC response: {0}'.format(response)) return None - except CommandError: - return None + except (TimeoutException, CommandError): + # Try interactive command recognition + commands = [] + checkable_commands = ['^CVOICE', '+VTS', '^DTMF', '^USSDMODE', '+WIND', '+ZPAS', '+CSCS', '+CNUM'] + + # Check if modem is still alive + try: + response = self.write('AT') + except: + raise TimeoutException + + # Check all commands that will by considered + for command in checkable_commands: + try: + # Compose AT command that will read values under specified function + at_command='AT'+command+'=?' + response = self.write(at_command) + # If there are values inside response - add command to the list + commands.append(command) + except: + continue + + # Return found commands + if len(commands) == 0: + return None + else: + return commands @property def smsTextMode(self): @@ -528,12 +604,120 @@ def smsTextMode(self, textMode): self.write('AT+CMGF={0}'.format(1 if textMode else 0)) self._smsTextMode = textMode self._compileSmsRegexes() - + + @property + def smsSupportedEncoding(self): + """ + :raise NotImplementedError: If an error occures during AT command response parsing. + :return: List of supported encoding names. """ + + # Check if command is available + if self._commands == None: + self._commands = self.supportedCommands + + if self._commands == None: + self._smsSupportedEncodingNames = [] + return self._smsSupportedEncodingNames + + if not '+CSCS' in self._commands: + self._smsSupportedEncodingNames = [] + return self._smsSupportedEncodingNames + + # Get available encoding names + response = self.write('AT+CSCS=?') + + # Check response length (should be 2 - list of options and command status) + if len(response) != 2: + self.log.debug('Unhandled +CSCS response: {0}'.format(response)) + self._smsSupportedEncodingNames = [] + raise NotImplementedError + + # Extract encoding names list + try: + enc_list = response[0] # Get the first line + enc_list = enc_list[6:] # Remove '+CSCS: ' prefix + # Extract AT list in format ("str", "str2", "str3") + enc_list = enc_list.split('(')[1] + enc_list = enc_list.split(')')[0] + enc_list = enc_list.split(',') + enc_list = [x.split('"')[1] for x in enc_list] + except: + self.log.debug('Unhandled +CSCS response: {0}'.format(response)) + self._smsSupportedEncodingNames = [] + raise NotImplementedError + + self._smsSupportedEncodingNames = enc_list + return self._smsSupportedEncodingNames + + @property + def smsEncoding(self): + """ :return: Encoding name if encoding command is available, else GSM. """ + if self._commands == None: + self._commands = self.supportedCommands + + if self._commands == None: + return self._smsEncoding + + if '+CSCS' in self._commands: + response = self.write('AT+CSCS?') + + if len(response) == 2: + encoding = response[0] + if encoding.startswith('+CSCS'): + encoding = encoding[6:].split('"') # remove the +CSCS: prefix before splitting + if len(encoding) == 3: + self._smsEncoding = encoding[1] + else: + self.log.debug('Unhandled +CSCS response: {0}'.format(response)) + else: + self.log.debug('Unhandled +CSCS response: {0}'.format(response)) + + return self._smsEncoding + @smsEncoding.setter + def smsEncoding(self, encoding): + """ Set encoding for SMS inside PDU mode. + + :raise CommandError: if unable to set encoding + :raise ValueError: if encoding is not supported by modem + """ + # Check if command is available + if self._commands == None: + self._commands = self.supportedCommands + + if self._commands == None: + if encoding != self._smsEncoding: + raise CommandError('Unable to set SMS encoding (no supported commands)') + else: + return + + if not '+CSCS' in self._commands: + if encoding != self._smsEncoding: + raise CommandError('Unable to set SMS encoding (+CSCS command not supported)') + else: + return + + # Check if command is available + if self._smsSupportedEncodingNames == None: + self.smsSupportedEncoding + + # Check if desired encoding is available + if encoding in self._smsSupportedEncodingNames: + # Set encoding + response = self.write('AT+CSCS="{0}"'.format(encoding)) + if len(response) == 1: + if response[0].lower() == 'ok': + self._smsEncoding = encoding + return + + if encoding != self._smsEncoding: + raise ValueError('Unable to set SMS encoding (enocoding {0} not supported)'.format(encoding)) + else: + return + def _setSmsMemory(self, readDelete=None, write=None): """ Set the current SMS memory to use for read/delete/write operations """ # Switch to the correct memory type if required if write != None and write != self._smsMemWrite: - self.write() readDel = readDelete or self._smsMemReadDelete self.write('AT+CPMS="{0}","{1}"'.format(readDel, write)) self._smsMemReadDelete = readDel @@ -544,13 +728,32 @@ def _setSmsMemory(self, readDelete=None, write=None): def _compileSmsRegexes(self): """ Compiles regular expression used for parsing SMS messages based on current mode """ - if self._smsTextMode: + if self.smsTextMode: if self.CMGR_SM_DELIVER_REGEX_TEXT == None: - self.CMGR_SM_DELIVER_REGEX_TEXT = re.compile(r'^\+CMGR: "([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') - self.CMGR_SM_REPORT_REGEXT_TEXT = re.compile(r'^\+CMGR: ([^,]*),\d+,(\d+),"{0,1}([^"]*)"{0,1},\d*,"([^"]+)","([^"]+)",(\d+)$') + self.CMGR_SM_DELIVER_REGEX_TEXT = re.compile('^\+CMGR: "([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') + self.CMGR_SM_REPORT_REGEXT_TEXT = re.compile('^\+CMGR: ([^,]*),\d+,(\d+),"{0,1}([^"]*)"{0,1},\d*,"([^"]+)","([^"]+)",(\d+)$') elif self.CMGR_REGEX_PDU == None: - self.CMGR_REGEX_PDU = re.compile(r'^\+CMGR: (\d*),"{0,1}([^"]*)"{0,1},(\d+)$') - + self.CMGR_REGEX_PDU = re.compile('^\+CMGR:\s*(\d*),\s*"{0,1}([^"]*)"{0,1},\s*(\d+)$') + + @property + def gsmBusy(self): + """ :return: Current GSMBUSY state """ + try: + response = self.write('AT+GSMBUSY?') + response = response[0] # Get the first line + response = response[10] # Remove '+GSMBUSY: ' prefix + self._gsmBusy = response + except: + pass # If error is related to ME funtionality: +CME ERROR: + return self._gsmBusy + @gsmBusy.setter + def gsmBusy(self, gsmBusy): + """ Sete GSMBUSY state """ + if gsmBusy != self._gsmBusy: + if self.alive: + self.write('AT+GSMBUSY="{0}"'.format(gsmBusy)) + self._gsmBusy = gsmBusy + @property def smsc(self): """ :return: The default SMSC number stored on the SIM card """ @@ -560,7 +763,7 @@ def smsc(self): except SmscNumberUnknownError: pass # Some modems return a CMS 330 error if the value isn't set else: - cscaMatch = lineMatching(r'\+CSCA:\s*"([^,]+)",(\d+)$', readSmsc) + cscaMatch = lineMatching('\+CSCA:\s*"([^,]+)",(\d+)$', readSmsc) if cscaMatch: self._smscNumber = cscaMatch.group(1) return self._smscNumber @@ -572,13 +775,66 @@ def smsc(self, smscNumber): self.write('AT+CSCA="{0}"'.format(smscNumber)) self._smscNumber = smscNumber + @property + def ownNumber(self): + """ Query subscriber phone number. + + It must be stored on SIM by operator. + If is it not stored already, it usually is possible to store the number by user. + + :raise TimeoutException: if a timeout was specified and reached + + + :return: Subscriber SIM phone number. Returns None if not known + :rtype: int + """ + + try: + if "+CNUM" in self._commands: + response = self.write('AT+CNUM') + else: + # temporarily switch to "own numbers" phonebook, read position 1 and than switch back + response = self.write('AT+CPBS?') + selected_phonebook = response[0][6:].split('"')[1] # first line, remove the +CSCS: prefix, split, first parameter + + if selected_phonebook is not "ON": + self.write('AT+CPBS="ON"') + + response = self.write("AT+CPBR=1") + self.write('AT+CPBS="{0}"'.format(selected_phonebook)) + + if response is "OK": # command is supported, but no number is set + return None + elif len(response) == 2: # OK and phone number. Actual number is in the first line, second parameter, and is placed inside quotation marks + cnumLine = response[0] + cnumMatch = self.CNUM_REGEX.match(cnumLine) + if cnumMatch: + return cnumMatch.group(1) + else: + self.log.debug('Error parse +CNUM response: {0}'.format(response)) + return None + elif len(response) > 2: # Multi-line response + self.log.debug('Unhandled +CNUM/+CPBS response: {0}'.format(response)) + return None + + except (TimeoutException, CommandError): + raise + + @ownNumber.setter + def ownNumber(self, phone_number): + actual_phonebook = self.write('AT+CPBS?') + if actual_phonebook is not "ON": + self.write('AT+CPBS="ON"') + self.write('AT+CPBW=1,"' + phone_number + '"') + + def waitForNetworkCoverage(self, timeout=None): """ Block until the modem has GSM network coverage. - - This method blocks until the modem is registered with the network + + This method blocks until the modem is registered with the network and the signal strength is greater than 0, optionally timing out if a timeout was specified - + :param timeout: Maximum time to wait for network coverage, in seconds :type timeout: int or float @@ -586,20 +842,19 @@ def waitForNetworkCoverage(self, timeout=None): :raise InvalidStateException: if the modem is not going to receive network coverage (SIM blocked, etc) :return: the current signal strength - :rtype: int """ block = [True] if timeout != None: # Set up a timeout mechanism - def _cancelBlock(): - block[0] = False + def _cancelBlock(): + block[0] = False t = threading.Timer(timeout, _cancelBlock) t.start() ss = -1 checkCreg = True while block[0]: if checkCreg: - cregResult = lineMatching(r'^\+CREG:\s*(\d),(\d)$', self.write('AT+CREG?', parseError=False)) # example result: +CREG: 0,1 + cregResult = lineMatching('^\+CREG:\s*(\d),(\d)(,[^,]*,[^,]*)?$', self.write('AT+CREG?', parseError=False)) # example result: +CREG: 0,1 if cregResult: status = int(cregResult.group(2)) if status in (1, 5): @@ -623,37 +878,69 @@ def _cancelBlock(): else: # If this is reached, the timer task has triggered raise TimeoutException() - + def sendSms(self, destination, text, waitForDeliveryReport=False, deliveryTimeout=15, sendFlash=False): """ Send an SMS text message - + :param destination: the recipient's phone number :type destination: str :param text: the message text :type text: str :param waitForDeliveryReport: if True, this method blocks until a delivery report is received for the sent message :type waitForDeliveryReport: boolean - :param deliveryReport: the maximum time in seconds to wait for a delivery report (if "waitForDeliveryReport" is True) - :type deliveryTimeout: int or float - + :param deliveryTimeout: the maximum time in seconds to wait for a delivery report (if "waitForDeliveryReport" is True) + :type deliveryTimeout: int or float + :raise CommandError: if an error occurs while attempting to send the message :raise TimeoutException: if the operation times out """ - if self._smsTextMode: - self.write('AT+CMGS="{0}"'.format(destination), timeout=3, expectedResponseTermSeq='> ') - result = lineStartingWith('+CMGS:', self.write(text, timeout=15, writeTerm=chr(26))) + + # Check input text to select appropriate mode (text or PDU) + if self.smsTextMode: + try: + encodedText = encodeTextMode(text) + except ValueError: + self.smsTextMode = False + + if self.smsTextMode: + # Send SMS via AT commands + self.write('AT+CMGS="{0}"'.format(destination), timeout=5, expectedResponseTermSeq='> ') + result = lineStartingWith('+CMGS:', self.write(text, timeout=35, writeTerm=CTRLZ)) else: + # Check encoding + try: + encodedText = encodeGsm7(text) + except ValueError: + encodedText = None + + # Set GSM modem SMS encoding format + # Encode message text and set data coding scheme based on text contents + if encodedText == None: + # Cannot encode text using GSM-7; use UCS2 instead + self.smsEncoding = 'UCS2' + else: + self.smsEncoding = 'GSM' + + # Encode text into PDUs pdus = encodeSmsSubmitPdu(destination, text, reference=self._smsRef, sendFlash=sendFlash) + + # Send SMS PDUs via AT commands for pdu in pdus: - self.write('AT+CMGS={0}'.format(pdu.tpduLength), timeout=3, expectedResponseTermSeq='> ') - result = lineStartingWith('+CMGS:', self.write(str(pdu), timeout=15, writeTerm=chr(26))) # example: +CMGS: xx + self.write('AT+CMGS={0}'.format(pdu.tpduLength), timeout=5, expectedResponseTermSeq='> ') + result = lineStartingWith('+CMGS:', self.write(str(pdu), timeout=35, writeTerm=CTRLZ)) # example: +CMGS: xx + if result == None: raise CommandError('Modem did not respond with +CMGS response') + + # Keep SMS reference number in order to pair delivery reports with sent message reference = int(result[7:]) self._smsRef = reference + 1 if self._smsRef > 255: self._smsRef = 0 + + # Create sent SMS object for future delivery checks sms = SentSms(destination, text, reference) + # Add a weak-referenced entry for this SMS (allows us to update the SMS state if a status report is received) self.sentSms[reference] = sms if waitForDeliveryReport: @@ -668,12 +955,12 @@ def sendSms(self, destination, text, waitForDeliveryReport=False, deliveryTimeou def sendUssd(self, ussdString, responseTimeout=15): """ Starts a USSD session by dialing the the specified USSD string, or \ sends the specified string in the existing USSD session (if any) - + :param ussdString: The USSD access number to dial :param responseTimeout: Maximum time to wait a response, in seconds - + :raise TimeoutException: if no response is received in time - + :return: The USSD response message/session (as a Ussd object) :rtype: gsmmodem.modem.Ussd """ @@ -695,9 +982,42 @@ def sendUssd(self, ussdString, responseTimeout=15): self._ussdSessionEvent = None return self._ussdResponse else: # Response timed out - self._ussdSessionEvent = None + self._ussdSessionEvent = None raise TimeoutException() - + + + def checkForwarding(self, querytype, responseTimeout=15): + """ Check forwarding status: 0=Unconditional, 1=Busy, 2=NoReply, 3=NotReach, 4=AllFwd, 5=AllCondFwd + :param querytype: The type of forwarding to check + + :return: Status + :rtype: Boolean + """ + try: + queryResponse = self.write('AT+CCFC={0},2'.format(querytype), timeout=responseTimeout) # Should respond with "OK" + except Exception: + raise + print(queryResponse) + return True + + + def setForwarding(self, fwdType, fwdEnable, fwdNumber, responseTimeout=15): + """ Check forwarding status: 0=Unconditional, 1=Busy, 2=NoReply, 3=NotReach, 4=AllFwd, 5=AllCondFwd + :param fwdType: The type of forwarding to set + :param fwdEnable: 1 to enable, 0 to disable, 2 to query, 3 to register, 4 to erase + :param fwdNumber: Number to forward to + + :return: Success or not + :rtype: Boolean + """ + try: + queryResponse = self.write('AT+CCFC={0},{1},"{2}"'.format(fwdType, fwdEnable, fwdNumber), timeout=responseTimeout) # Should respond with "OK" + except Exception: + raise + return False + print(queryResponse) + return queryResponse + def dial(self, number, timeout=5, callStatusUpdateCallbackFunc=None): """ Calls the specified phone number using a voice phone call @@ -743,44 +1063,56 @@ def dial(self, number, timeout=5, callStatusUpdateCallbackFunc=None): def processStoredSms(self, unreadOnly=False): """ Process all SMS messages currently stored on the device/SIM card. - - Reads all (or just unread) received SMS messages currently stored on the - device/SIM card, initiates "SMS received" events for them, and removes + + Reads all (or just unread) received SMS messages currently stored on the + device/SIM card, initiates "SMS received" events for them, and removes them from the SIM card. This is useful if SMS messages were received during a period that python-gsmmodem was not running but the modem was powered on. - + :param unreadOnly: If True, only process unread SMS messages :type unreadOnly: boolean """ - states = [Sms.STATUS_RECEIVED_UNREAD] - if not unreadOnly: - states.insert(0, Sms.STATUS_RECEIVED_READ) - for msgStatus in states: - messages = self.listStoredSms(status=msgStatus, delete=True) - for sms in messages: - self.smsReceivedCallback(sms) + if self.smsReceivedCallback: + states = [Sms.STATUS_RECEIVED_UNREAD] + if not unreadOnly: + states.insert(0, Sms.STATUS_RECEIVED_READ) + for msgStatus in states: + messages = self.listStoredSms(status=msgStatus, delete=True) + for sms in messages: + self.smsReceivedCallback(sms) + else: + raise ValueError('GsmModem.smsReceivedCallback not set') + + def _getConcat(self, smsDict): + concat = None + if smsDict.has_key('udh'): + for i in smsDict['udh']: + if isinstance(i, Concatenation): + concat = i + break + return concat def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): """ Returns SMS messages currently stored on the device/SIM card. - + The messages are read from the memory set by the "memory" parameter. - + :param status: Filter messages based on this read status; must be 0-4 (see Sms class) :type status: int :param memory: The memory type to read from. If None, use the current default SMS read memory :type memory: str or None :param delete: If True, delete returned messages from the device/SIM card :type delete: bool - + :return: A list of Sms objects containing the messages read :rtype: list """ self._setSmsMemory(readDelete=memory) messages = [] delMessages = set() - if self._smsTextMode: - cmglRegex= re.compile(r'^\+CMGL: (\d+),"([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') + if self.smsTextMode: + cmglRegex= re.compile('^\+CMGL: (\d+),"([^"]+)","([^"]+)",[^,]*,"([^"]+)"$') for key, val in dictItemsIter(Sms.TEXT_MODE_STATUS_MAP): if status == val: statusStr = key @@ -791,13 +1123,13 @@ def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): msgLines = [] msgIndex = msgStatus = number = msgTime = None for line in result: - cmglMatch = cmglRegex.match(line) + cmglMatch = cmglRegex.match(line) if cmglMatch: # New message; save old one if applicable if msgIndex != None and len(msgLines) > 0: msgText = '\n'.join(msgLines) msgLines = [] - messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText)) + messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText, None, [], msgIndex)) delMessages.add(int(msgIndex)) msgIndex, msgStatus, number, msgTime = cmglMatch.groups() msgLines = [] @@ -807,10 +1139,10 @@ def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): if msgIndex != None and len(msgLines) > 0: msgText = '\n'.join(msgLines) msgLines = [] - messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText)) + messages.append(ReceivedSms(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], number, parseTextModeTimeStr(msgTime), msgText, None, [], msgIndex)) delMessages.add(int(msgIndex)) else: - cmglRegex = re.compile(r'^\+CMGL:\s*(\d+),\s*(\d+),.*$') + cmglRegex = re.compile('^\+CMGL:\s*(\d+),\s*(\d+),.*$') readPdu = False result = self.write('AT+CMGL={0}'.format(status)) for line in result: @@ -825,9 +1157,14 @@ def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): smsDict = decodeSmsPdu(line) except EncodingError: self.log.debug('Discarding line from +CMGL response: %s', line) + except: + pass + # dirty fix warning: https://github.com/yuriykashin/python-gsmmodem/issues/1 + # todo: make better fix else: if smsDict['type'] == 'SMS-DELIVER': - sms = ReceivedSms(self, int(msgStat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc']) + concat = self._getConcat(smsDict) + sms = ReceivedSms(self, int(msgStat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc'], smsDict.get('udh', []), msgIndex, concat) elif smsDict['type'] == 'SMS-STATUS-REPORT': sms = StatusReport(self, int(msgStat), smsDict['reference'], smsDict['number'], smsDict['time'], smsDict['discharge'], smsDict['status']) else: @@ -846,19 +1183,20 @@ def listStoredSms(self, status=Sms.STATUS_ALL, memory=None, delete=False): def _handleModemNotification(self, lines): """ Handler for unsolicited notifications from the modem - + This method simply spawns a separate thread to handle the actual notification (in order to release the read thread so that the handlers are able to write back to the modem, etc) - + :param lines The lines that were read """ threading.Thread(target=self.__threadedHandleModemNotification, kwargs={'lines': lines}).start() - + def __threadedHandleModemNotification(self, lines): """ Implementation of _handleModemNotification() to be run in a separate thread - + :param lines The lines that were read """ + next_line_is_te_statusreport = False for line in lines: if 'RING' in line: # Incoming call (or existing call is ringing) @@ -876,8 +1214,23 @@ def __threadedHandleModemNotification(self, lines): # SMS status report self._handleSmsStatusReport(line) return + elif line.startswith('+CDS'): + # SMS status report at next line + next_line_is_te_statusreport = True + cdsMatch = self.CDS_REGEX.match(line) + if cdsMatch: + next_line_is_te_statusreport_length = int(cdsMatch.group(1)) + else: + next_line_is_te_statusreport_length = -1 + elif next_line_is_te_statusreport: + self._handleSmsStatusReportTe(next_line_is_te_statusreport_length, line) + return + elif line.startswith('+DTMF'): + # New incoming DTMF + self._handleIncomingDTMF(line) + return else: - # Check for call status updates + # Check for call status updates for updateRegex, handlerFunc in self._callStatusUpdates: match = updateRegex.match(line) if match: @@ -885,8 +1238,24 @@ def __threadedHandleModemNotification(self, lines): handlerFunc(match) return # If this is reached, the notification wasn't handled - self.log.debug('Unhandled unsolicited modem notification: %s', lines) - + self.log.debug('Unhandled unsolicited modem notification: %s', lines) + + #Simcom modem able detect incoming DTMF + def _handleIncomingDTMF(self,line): + self.log.debug('Handling incoming DTMF') + + try: + dtmf_num=line.split(':')[1].replace(" ","") + self.dtmfpool.append(dtmf_num) + self.log.debug('DTMF number is {0}'.format(dtmf_num)) + except: + self.log.debug('Error parse DTMF number on line {0}'.format(line)) + def GetIncomingDTMF(self): + if (len(self.dtmfpool)==0): + return None + else: + return self.dtmfpool.pop(0) + def _handleIncomingCall(self, lines): self.log.debug('Handling incoming call') ringLine = lines.pop(0) @@ -899,9 +1268,9 @@ def _handleIncomingCall(self, lines): callType = None try: # Re-enable extended format of incoming indication (optional) - self.write('AT+CRC=1') + self.write('AT+CRC=1') except CommandError: - self.log.warn('Extended incoming call indication format changed externally; unable to re-enable') + self.log.warning('Extended incoming call indication format changed externally; unable to re-enable') self._extendedIncomingCallIndication = False else: callType = None @@ -909,7 +1278,7 @@ def _handleIncomingCall(self, lines): clipLine = lines.pop(0) clipMatch = self.CLIP_REGEX.match(clipLine) if clipMatch: - callerNumber = clipMatch.group(1) + callerNumber = '+' + clipMatch.group(1) ton = clipMatch.group(2) #TODO: re-add support for this callerName = None @@ -920,7 +1289,7 @@ def _handleIncomingCall(self, lines): callerNumber = ton = callerName = None else: callerNumber = ton = callerName = None - + call = None for activeCall in dictValuesIter(self.activeCalls): if activeCall.number == callerNumber: @@ -930,8 +1299,8 @@ def _handleIncomingCall(self, lines): callId = len(self.activeCalls) + 1; call = IncomingCall(self, callerNumber, ton, callerName, callId, callType) self.activeCalls[callId] = call - self.incomingCallCallback(call) - + self.incomingCallCallback(call) + def _handleCallInitiated(self, regexMatch, callId=None, callType=1): """ Handler for "outgoing call initiated" event notification line """ if self._dialEvent: @@ -945,7 +1314,7 @@ def _handleCallInitiated(self, regexMatch, callId=None, callType=1): else: self._dialResponse = callId, callType self._dialEvent.set() - + def _handleCallAnswered(self, regexMatch, callId=None): """ Handler for "outgoing call answered" event notification line """ if regexMatch: @@ -991,14 +1360,19 @@ def _handleCallRejected(self, regexMatch, callId=None): def _handleSmsReceived(self, notificationLine): """ Handler for "new SMS" unsolicited notification line """ self.log.debug('SMS message received') - cmtiMatch = self.CMTI_REGEX.match(notificationLine) - if cmtiMatch: - msgMemory = cmtiMatch.group(1) - msgIndex = cmtiMatch.group(2) - sms = self.readStoredSms(msgIndex, msgMemory) - self.deleteStoredSms(msgIndex) - self.smsReceivedCallback(sms) - + if self.smsReceivedCallback is not None: + cmtiMatch = self.CMTI_REGEX.match(notificationLine) + if cmtiMatch: + msgMemory = cmtiMatch.group(1) + msgIndex = cmtiMatch.group(2) + sms = self.readStoredSms(msgIndex, msgMemory) + try: + self.smsReceivedCallback(sms) + except Exception: + self.log.error('error in smsReceivedCallback', exc_info=True) + else: + self.deleteStoredSms(msgIndex) + def _handleSmsStatusReport(self, notificationLine): """ Handler for SMS status reports """ self.log.debug('SMS status report received') @@ -1008,26 +1382,54 @@ def _handleSmsStatusReport(self, notificationLine): msgIndex = cdsiMatch.group(2) report = self.readStoredSms(msgIndex, msgMemory) self.deleteStoredSms(msgIndex) - # Update sent SMS status if possible - if report.reference in self.sentSms: + # Update sent SMS status if possible + if report.reference in self.sentSms: self.sentSms[report.reference].report = report - if self._smsStatusReportEvent: + if self._smsStatusReportEvent: # A sendSms() call is waiting for this response - notify waiting thread self._smsStatusReportEvent.set() - else: + elif self.smsStatusReportCallback: # Nothing is waiting for this report directly - use callback + try: + self.smsStatusReportCallback(report) + except Exception: + self.log.error('error in smsStatusReportCallback', exc_info=True) + + def _handleSmsStatusReportTe(self, length, notificationLine): + """ Handler for TE SMS status reports """ + self.log.debug('TE SMS status report received') + try: + smsDict = decodeSmsPdu(notificationLine) + except EncodingError: + self.log.debug('Discarding notification line from +CDS response: %s', notificationLine) + else: + if smsDict['type'] == 'SMS-STATUS-REPORT': + report = StatusReport(self, int(smsDict['status']), smsDict['reference'], smsDict['number'], smsDict['time'], smsDict['discharge'], smsDict['status']) + else: + raise CommandError('Invalid PDU type for readStoredSms(): {0}'.format(smsDict['type'])) + # Update sent SMS status if possible + if report.reference in self.sentSms: + self.sentSms[report.reference].report = report + if self._smsStatusReportEvent: + # A sendSms() call is waiting for this response - notify waiting thread + self._smsStatusReportEvent.set() + else: + # Nothing is waiting for this report directly - use callback + try: self.smsStatusReportCallback(report) - + except Exception: + self.log.error('error in smsStatusReportCallback', exc_info=True) + def readStoredSms(self, index, memory=None): """ Reads and returns the SMS message at the specified index - + :param index: The index of the SMS message in the specified memory :type index: int :param memory: The memory type to read from. If None, use the current default SMS read memory :type memory: str or None - + :raise CommandError: if unable to read the stored message - + :return: The SMS message :rtype: subclass of gsmmodem.modem.Sms (either ReceivedSms or StatusReport) """ @@ -1035,7 +1437,7 @@ def readStoredSms(self, index, memory=None): self._setSmsMemory(readDelete=memory) msgData = self.write('AT+CMGR={0}'.format(index)) # Parse meta information - if self._smsTextMode: + if self.smsTextMode: cmgrMatch = self.CMGR_SM_DELIVER_REGEX_TEXT.match(msgData[0]) if cmgrMatch: msgStatus, number, msgTime = cmgrMatch.groups() @@ -1047,7 +1449,7 @@ def readStoredSms(self, index, memory=None): if cmgrMatch: msgStatus, reference, number, sentTime, deliverTime, deliverStatus = cmgrMatch.groups() if msgStatus.startswith('"'): - msgStatus = msgStatus[1:-1] + msgStatus = msgStatus[1:-1] if len(msgStatus) == 0: msgStatus = "REC UNREAD" return StatusReport(self, Sms.TEXT_MODE_STATUS_MAP[msgStatus], int(reference), number, parseTextModeTimeStr(sentTime), parseTextModeTimeStr(deliverTime), int(deliverStatus)) @@ -1066,42 +1468,45 @@ def readStoredSms(self, index, memory=None): pdu = msgData[1] smsDict = decodeSmsPdu(pdu) if smsDict['type'] == 'SMS-DELIVER': - return ReceivedSms(self, int(stat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc']) + concat = self._getConcat(smsDict) + return ReceivedSms(self, int(stat), smsDict['number'], smsDict['time'], smsDict['text'], smsDict['smsc'], smsDict.get('udh', []), concat) elif smsDict['type'] == 'SMS-STATUS-REPORT': return StatusReport(self, int(stat), smsDict['reference'], smsDict['number'], smsDict['time'], smsDict['discharge'], smsDict['status']) else: raise CommandError('Invalid PDU type for readStoredSms(): {0}'.format(smsDict['type'])) - + def deleteStoredSms(self, index, memory=None): """ Deletes the SMS message stored at the specified index in modem/SIM card memory - + :param index: The index of the SMS message in the specified memory :type index: int :param memory: The memory type to delete from. If None, use the current default SMS read/delete memory :type memory: str or None - + :raise CommandError: if unable to delete the stored message """ self._setSmsMemory(readDelete=memory) self.write('AT+CMGD={0},0'.format(index)) - + # TODO: make a check how many params are supported by the modem and use the right command. For example, Siemens MC35, TC35 take only one parameter. + #self.write('AT+CMGD={0}'.format(index)) + def deleteMultipleStoredSms(self, delFlag=4, memory=None): """ Deletes all SMS messages that have the specified read status. - + The messages are read from the memory set by the "memory" parameter. The value of the "delFlag" paramater is the same as the "DelFlag" parameter of the +CMGD command: 1: Delete All READ messages 2: Delete All READ and SENT messages 3: Delete All READ, SENT and UNSENT messages 4: Delete All messages (this is the default) - + :param delFlag: Controls what type of messages to delete; see description above. :type delFlag: int :param memory: The memory type to delete from. If None, use the current default SMS read/delete memory :type memory: str or None :param delete: If True, delete returned messages from the device/SIM card :type delete: bool - + :raise ValueErrror: if "delFlag" is not in range [1,4] :raise CommandError: if unable to delete the stored messages """ @@ -1110,7 +1515,7 @@ def deleteMultipleStoredSms(self, delFlag=4, memory=None): self.write('AT+CMGD=1,{0}'.format(delFlag)) else: raise ValueError('"delFlag" must be in range [1,4]') - + def _handleUssd(self, lines): """ Handler for USSD event notification line(s) """ if self._ussdSessionEvent: @@ -1118,7 +1523,7 @@ def _handleUssd(self, lines): self._ussdResponse = self._parseCusdResponse(lines) # Notify waiting thread self._ussdSessionEvent.set() - + def _parseCusdResponse(self, lines): """ Parses one or more +CUSD notification lines (for USSD) :return: USSD response object @@ -1155,14 +1560,14 @@ def _parseCusdResponse(self, lines): def _placeHolderCallback(self, *args): """ Does nothing """ self.log.debug('called with args: {0}'.format(args)) - + def _pollCallStatus(self, expectedState, callId=None, timeout=None): - """ Poll the status of outgoing calls. + """ Poll the status of outgoing calls. This is used for modems that do not have a known set of call status update notifications. - + :param expectedState: The internal state we are waiting for. 0 == initiated, 1 == answered, 2 = hangup :type expectedState: int - + :raise TimeoutException: If a timeout was specified, and has occurred """ callDone = False @@ -1182,7 +1587,7 @@ def _pollCallStatus(self, expectedState, callId=None, timeout=None): # Determine call state stat = int(clcc.group(3)) if expectedState == 0: # waiting for call initiated - if stat == 2 or stat == 3: # Dialing or ringing ("alerting") + if stat == 2 or stat == 3: # Dialing or ringing ("alerting") callId = int(clcc.group(1)) callType = int(clcc.group(4)) self._handleCallInitiated(None, callId, callType) # if self_dialEvent is None, this does nothing @@ -1191,7 +1596,7 @@ def _pollCallStatus(self, expectedState, callId=None, timeout=None): if stat == 0: # Call active callId = int(clcc.group(1)) self._handleCallAnswered(None, callId) - expectedState = 2 # Now wait for call hangup + expectedState = 2 # Now wait for call hangup elif expectedState == 2 : # waiting for remote hangup # Since there was no +CLCC response, the call is no longer active callDone = True @@ -1206,23 +1611,23 @@ def _pollCallStatus(self, expectedState, callId=None, timeout=None): class Call(object): """ A voice call """ - + DTMF_COMMAND_BASE = '+VTS=' dtmfSupport = False # Indicates whether or not DTMF tones can be sent in calls - + def __init__(self, gsmModem, callId, callType, number, callStatusUpdateCallbackFunc=None): """ :param gsmModem: GsmModem instance that created this object - :param number: The number that is being called + :param number: The number that is being called """ self._gsmModem = weakref.proxy(gsmModem) self._callStatusUpdateCallbackFunc = callStatusUpdateCallbackFunc # Unique ID of this call self.id = callId # Call type (VOICE == 0, etc) - self.type = callType + self.type = callType # The remote number of this call (destination or origin) - self.number = number + self.number = number # Flag indicating whether the call has been answered or not (backing field for "answered" property) self._answered = False # Flag indicating whether or not the call is active @@ -1237,27 +1642,25 @@ def answered(self, answered): self._answered = answered if self._callStatusUpdateCallbackFunc: self._callStatusUpdateCallbackFunc(self) - + def sendDtmfTone(self, tones): - """ Send one or more DTMF tones to the remote party (only allowed for an answered call) - + """ Send one or more DTMF tones to the remote party (only allowed for an answered call) + Note: this is highly device-dependent, and might not work - + :param digits: A str containining one or more DTMF tones to play, e.g. "3" or "\*123#" - :raise CommandError: if the command failed/is not supported + :raise CommandError: if the command failed/is not supported :raise InvalidStateException: if the call has not been answered, or is ended while the command is still executing - """ + """ if self.answered: dtmfCommandBase = self.DTMF_COMMAND_BASE.format(cid=self.id) toneLen = len(tones) - if len(tones) > 1: - cmd = ('AT{0}{1};{0}' + ';{0}'.join(tones[1:])).format(dtmfCommandBase, tones[0]) - else: - cmd = 'AT{0}{1}'.format(dtmfCommandBase, tones) - try: - self._gsmModem.write(cmd, timeout=(5 + toneLen)) - except CmeError as e: + for tone in list(tones): + try: + self._gsmModem.write('AT{0}{1}'.format(dtmfCommandBase,tone), timeout=(5 + toneLen)) + + except CmeError as e: if e.code == 30: # No network service - can happen if call is ended during DTMF transmission (but also if DTMF is sent immediately after call is answered) raise InterruptedException('No network service', e) @@ -1268,10 +1671,10 @@ def sendDtmfTone(self, tones): raise e else: raise InvalidStateException('Call is not active (it has not yet been answered, or it has ended).') - + def hangup(self): """ End the phone call. - + Does nothing if the call is already inactive. """ if self.active: @@ -1283,10 +1686,10 @@ def hangup(self): class IncomingCall(Call): - + CALL_TYPE_MAP = {'VOICE': 0} - - """ Represents an incoming call, conveniently allowing access to call meta information and -control """ + + """ Represents an incoming call, conveniently allowing access to call meta information and -control """ def __init__(self, gsmModem, number, ton, callerName, callId, callType): """ :param gsmModem: GsmModem instance that created this object @@ -1294,26 +1697,26 @@ def __init__(self, gsmModem, number, ton, callerName, callId, callType): :param ton: TON (type of number/address) in integer format :param callType: Type of the incoming call (VOICE, FAX, DATA, etc) """ - if type(callType) == str: - callType = self.CALL_TYPE_MAP[callType] - super(IncomingCall, self).__init__(gsmModem, callId, callType, number) + if callType in self.CALL_TYPE_MAP: + callType = self.CALL_TYPE_MAP[callType] + super(IncomingCall, self).__init__(gsmModem, callId, callType, number) # Type attribute of the incoming call self.ton = ton - self.callerName = callerName + self.callerName = callerName # Flag indicating whether the call is ringing or not - self.ringing = True + self.ringing = True # Amount of times this call has rung (before answer/hangup) self.ringCount = 1 - + def answer(self): - """ Answer the phone call. + """ Answer the phone call. :return: self (for chaining method calls) """ if self.ringing: self._gsmModem.write('ATA') self.ringing = False self.answered = True - return self + return self def hangup(self): """ End the phone call. """ @@ -1322,32 +1725,32 @@ def hangup(self): class Ussd(object): """ Unstructured Supplementary Service Data (USSD) message. - + This class contains convenient methods for replying to a USSD prompt and to cancel the USSD session """ - + def __init__(self, gsmModem, sessionActive, message): self._gsmModem = weakref.proxy(gsmModem) # Indicates if the session is active (True) or has been closed (False) self.sessionActive = sessionActive self.message = message - + def reply(self, message): - """ Sends a reply to this USSD message in the same USSD session - + """ Sends a reply to this USSD message in the same USSD session + :raise InvalidStateException: if the USSD session is not active (i.e. it has ended) - + :return: The USSD response message/session (as a Ussd object) """ if self.sessionActive: return self._gsmModem.sendUssd(message) else: raise InvalidStateException('USSD session is inactive') - + def cancel(self): """ Terminates/cancels the USSD session (without sending a reply) - + Does nothing if the USSD session is inactive. """ if self.sessionActive: diff --git a/gsmmodem/pdu.py b/gsmmodem/pdu.py index ee15ebc..a3e28e8 100644 --- a/gsmmodem/pdu.py +++ b/gsmmodem/pdu.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals -import sys, codecs, math +import sys, codecs from datetime import datetime, timedelta, tzinfo from copy import copy from .exceptions import EncodingError @@ -24,6 +24,7 @@ toByteArray = lambda x: bytearray(x.decode('hex')) if type(x) in (str, unicode) else x rawStrToByteArray = bytearray +TEXT_MODE = ('\n\r !\"#%&\'()*+,-./0123456789:;<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') # TODO: Check if all of them are supported inside text mode # 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 GSM7_BASIC = ('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !\"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà') GSM7_EXTENDED = {chr(0xFF): 0x0A, @@ -43,33 +44,50 @@ 0x04: 140, # 8-bit 0x08: 70} # UCS2 +# Maximum message sizes for each data coding for multipart messages +MAX_MULTIPART_MESSAGE_LENGTH = {0x00: 153, # GSM-7 + 0x04: 133, # 8-bit TODO: Check this value! + 0x08: 67} # UCS2 + class SmsPduTzInfo(tzinfo): """ Simple implementation of datetime.tzinfo for handling timestamp GMT offsets specified in SMS PDUs """ - + def __init__(self, pduOffsetStr=None): - """ + """ :param pduOffset: 2 semi-octet timezone offset as specified by PDU (see GSM 03.40 spec) - :type pduOffset: str - + :type pduOffset: str + Note: pduOffsetStr is optional in this constructor due to the special requirement for pickling mentioned in the Python docs. It should, however, be used (or otherwise pduOffsetStr must be manually set) - """ + """ self._offset = None if pduOffsetStr != None: self._setPduOffsetStr(pduOffsetStr) - + def _setPduOffsetStr(self, pduOffsetStr): # See if the timezone difference is positive/negative by checking MSB of first semi-octet tzHexVal = int(pduOffsetStr, 16) + # In order to read time zone 'minute' shift: + # - Remove MSB (sign) + # - Read HEX value as decimal + # - Multiply by 15 + # See: https://en.wikipedia.org/wiki/GSM_03.40#Time_Format + + # Possible fix for #15 - convert invalid character to BCD-value + if (tzHexVal & 0x0F) > 0x9: + tzHexVal +=0x06 + + tzOffsetMinutes = int('{0:0>2X}'.format(tzHexVal & 0x7F)) * 15 + if tzHexVal & 0x80 == 0: # positive - self._offset = timedelta(minutes=(int(pduOffsetStr) * 15)) + self._offset = timedelta(minutes=(tzOffsetMinutes)) else: # negative - self._offset = timedelta(minutes=(int('{0:0>2X}'.format(tzHexVal & 0x7F)) * -15)) - + self._offset = timedelta(minutes=(-tzOffsetMinutes)) + def utcoffset(self, dt): return self._offset - + def dst(self, dt): """ We do not have enough info in the SMS PDU to implement daylight savings time """ return timedelta(0) @@ -77,17 +95,17 @@ def dst(self, dt): class InformationElement(object): """ User Data Header (UDH) Information Element (IE) implementation - + This represents a single field ("information element") in the PDU's User Data Header. The UDH itself contains one or more of these information elements. - + If the IEI (IE identifier) is recognized, the class will automatically - specialize into one of the subclasses of InformationElement, + specialize into one of the subclasses of InformationElement, e.g. Concatenation or PortAddress, allowing the user to easily access the specific (and useful) attributes of these special cases. """ - + def __new__(cls, *args, **kwargs): #iei, ieLen, ieData): """ Causes a new InformationElement class, or subclass thereof, to be created. If the IEI is recognized, a specific @@ -99,17 +117,17 @@ def __new__(cls, *args, **kwargs): #iei, ieLen, ieData): else: return super(InformationElement, cls).__new__(cls) return super(InformationElement, targetClass).__new__(targetClass) - + def __init__(self, iei, ieLen=0, ieData=None): self.id = iei # IEI self.dataLength = ieLen # IE Length self.data = ieData or [] # raw IE data - + @classmethod def decode(cls, byteIter): """ Decodes a single IE at the current position in the specified - byte iterator - + byte iterator + :return: An InformationElement (or subclass) instance for the decoded IE :rtype: InformationElement, or subclass thereof """ @@ -119,7 +137,7 @@ def decode(cls, byteIter): for i in xrange(ieLen): ieData.append(next(byteIter)) return InformationElement(iei, ieLen, ieData) - + def encode(self): """ Encodes this IE and returns the resulting bytes """ result = bytearray() @@ -127,7 +145,7 @@ def encode(self): result.append(self.dataLength) result.extend(self.data) return result - + def __len__(self): """ Exposes the IE's total length (including the IEI and IE length octet) in octets """ return self.dataLength + 2 @@ -176,16 +194,16 @@ def encode(self): class PortAddress(InformationElement): """ IE that indicates an Application Port Addressing Scheme. - + This implementation handles both 8-bit and 16-bit concatenation indication, and exposes the specific useful details of this IE as instance variables. - + Exposes: destination: The destination port number source: The source port number """ - + def __init__(self, iei=0x04, ieLen=0, ieData=None): super(PortAddress, self).__init__(iei, ieLen, ieData) if ieData != None: @@ -194,7 +212,7 @@ def __init__(self, iei=0x04, ieLen=0, ieData=None): else: # 0x05: 16-bit port addressing scheme self.destination = ieData[0] << 8 | ieData[1] self.source = ieData[2] << 8 | ieData[3] - + def encode(self): if self.destination > 0xFF or self.source > 0xFF: self.id = 0x05 # 16-bit @@ -216,7 +234,7 @@ def encode(self): class Pdu(object): """ Encoded SMS PDU. Contains raw PDU data and related meta-information """ - + def __init__(self, data, tpduLength): """ Constructor :param data: the raw PDU data (as bytes) @@ -226,18 +244,18 @@ def __init__(self, data, tpduLength): """ self.data = data self.tpduLength = tpduLength - + def __str__(self): global PYTHON_VERSION if PYTHON_VERSION < 3: return str(self.data).encode('hex').upper() else: #pragma: no cover - return str(codecs.encode(self.data, 'hex_codec'), 'ascii').upper() + return str(codecs.encode(self.data, 'hex_codec'), 'ascii').upper() def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requestStatusReport=True, rejectDuplicates=False, sendFlash=False): """ Creates an SMS-SUBMIT PDU for sending a message with the specified text to the specified number - + :param number: the destination mobile number :type number: str :param text: the message text @@ -250,10 +268,14 @@ def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requ :type smsc: str :param rejectDuplicates: Flag that controls the TP-RD parameter (messages with same destination and reference may be rejected if True) :type rejectDuplicates: bool - + :return: A list of one or more tuples containing the SMS PDU (as a bytearray, and the length of the TPDU part :rtype: list of tuples - """ + """ + if PYTHON_VERSION < 3: + if type(text) == str: + text = text.decode('UTF-8') + tpduFirstOctet = 0x01 # SMS-SUBMIT PDU if validity != None: # Validity period format (TP-VPF) is stored in bits 4,3 of the first TPDU octet @@ -264,70 +286,78 @@ def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requ elif type(validity) == datetime: # Absolute (TP-VP is semi-octet encoded date) tpduFirstOctet |= 0x18 # bit4 == 1, bit3 == 1 - validityPeriod = _encodeTimestamp(validity) + validityPeriod = _encodeTimestamp(validity) else: - raise TypeError('"validity" must be of type datetime.timedelta (for relative value) or datetime.datetime (for absolute value)') + raise TypeError('"validity" must be of type datetime.timedelta (for relative value) or datetime.datetime (for absolute value)') else: validityPeriod = None if rejectDuplicates: tpduFirstOctet |= 0x04 # bit2 == 1 if requestStatusReport: tpduFirstOctet |= 0x20 # bit5 == 1 - + # Encode message text and set data coding scheme based on text contents try: - encodedText = encodeGsm7(text) + encodedTextLength = len(encodeGsm7(text)) except ValueError: # Cannot encode text using GSM-7; use UCS2 instead + encodedTextLength = len(text) alphabet = 0x08 # UCS2 else: - alphabet = 0x00 # GSM-7 - + alphabet = 0x00 # GSM-7 + # Check if message should be concatenated - if len(text) > MAX_MESSAGE_LENGTH[alphabet]: + if encodedTextLength > MAX_MESSAGE_LENGTH[alphabet]: # Text too long for single PDU - add "concatenation" User Data Header concatHeaderPrototype = Concatenation() concatHeaderPrototype.reference = reference - pduCount = int(len(text) / MAX_MESSAGE_LENGTH[alphabet]) + 1 + + # Devide whole text into parts + if alphabet == 0x00: + pduTextParts = divideTextGsm7(text) + elif alphabet == 0x08: + pduTextParts = divideTextUcs2(text) + else: + raise NotImplementedError + + pduCount = len(pduTextParts) concatHeaderPrototype.parts = pduCount tpduFirstOctet |= 0x40 else: concatHeaderPrototype = None pduCount = 1 - + # Construct required PDU(s) - pdus = [] + pdus = [] for i in xrange(pduCount): pdu = bytearray() if smsc: pdu.extend(_encodeAddressField(smsc, smscField=True)) else: - pdu.append(0x00) # Don't supply an SMSC number - use the one configured in the device - + pdu.append(0x00) # Don't supply an SMSC number - use the one configured in the device + udh = bytearray() if concatHeaderPrototype != None: concatHeader = copy(concatHeaderPrototype) concatHeader.number = i + 1 - if alphabet == 0x00: - pduText = text[i*153:(i+1) * 153] - elif alphabet == 0x08: - pduText = text[i * 67 : (i + 1) * 67] + pduText = pduTextParts[i] + pduTextLength = len(pduText) udh.extend(concatHeader.encode()) else: pduText = text - - udhLen = len(udh) - + + udhLen = len(udh) + pdu.append(tpduFirstOctet) pdu.append(reference) # message reference - # Add destination number + # Add destination number pdu.extend(_encodeAddressField(number)) pdu.append(0x00) # Protocol identifier - no higher-level protocol - + pdu.append(alphabet if not sendFlash else (0x10 if alphabet == 0x00 else 0x18)) if validityPeriod: pdu.extend(validityPeriod) - + if alphabet == 0x00: # GSM-7 encodedText = encodeGsm7(pduText) userDataLength = len(encodedText) # Payload size in septets/characters @@ -341,8 +371,8 @@ def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requ elif alphabet == 0x08: # UCS2 userData = encodeUcs2(pduText) userDataLength = len(userData) - - if udhLen > 0: + + if udhLen > 0: userDataLength += udhLen + 1 # +1 for the UDH length indicator byte pdu.append(userDataLength) pdu.append(udhLen) @@ -356,15 +386,15 @@ def encodeSmsSubmitPdu(number, text, reference=0, validity=None, smsc=None, requ def decodeSmsPdu(pdu): """ Decodes SMS pdu data and returns a tuple in format (number, text) - + :param pdu: PDU data as a hex string, or a bytearray containing PDU octects :type pdu: str or bytearray - + :raise EncodingError: If the specified PDU data cannot be decoded - + :return: The decoded SMS data as a dictionary - :rtype: dict - """ + :rtype: dict + """ try: pdu = toByteArray(pdu) except Exception as e: @@ -372,13 +402,13 @@ def decodeSmsPdu(pdu): raise EncodingError(e) result = {} pduIter = iter(pdu) - + smscNumber, smscBytesRead = _decodeAddressField(pduIter, smscField=True) result['smsc'] = smscNumber result['tpdu_length'] = len(pdu) - smscBytesRead - - tpduFirstOctet = next(pduIter) - + + tpduFirstOctet = next(pduIter) + pduType = tpduFirstOctet & 0x03 # bits 1-0 if pduType == 0x00: # SMS-DELIVER or SMS-DELIVER REPORT result['type'] = 'SMS-DELIVER' @@ -399,7 +429,7 @@ def decodeSmsPdu(pdu): validityPeriodFormat = (tpduFirstOctet & 0x18) >> 3 # bits 4,3 if validityPeriodFormat == 0x02: # TP-VP field present and integer represented (relative) result['validity'] = _decodeRelativeValidityPeriod(next(pduIter)) - elif validityPeriodFormat == 0x03: # TP-VP field present and semi-octet represented (absolute) + elif validityPeriodFormat == 0x03: # TP-VP field present and semi-octet represented (absolute) result['validity'] = _decodeTimestamp(pduIter) userDataLen = next(pduIter) udhPresent = (tpduFirstOctet & 0x40) != 0 @@ -411,10 +441,10 @@ def decodeSmsPdu(pdu): result['number'] = _decodeAddressField(pduIter)[0] result['time'] = _decodeTimestamp(pduIter) result['discharge'] = _decodeTimestamp(pduIter) - result['status'] = next(pduIter) + result['status'] = next(pduIter) else: raise EncodingError('Unknown SMS message type: {0}. First TPDU octet was: {1}'.format(pduType, tpduFirstOctet)) - + return result def _decodeUserData(byteIter, userDataLen, dataCoding, udhPresent): @@ -471,7 +501,7 @@ def _decodeRelativeValidityPeriod(tpVp): def _encodeRelativeValidityPeriod(validityPeriod): """ Encodes the specified relative validity period timedelta into an integer for use in an SMS PDU (based on the table in section 9.2.3.12 of GSM 03.40) - + :param validityPeriod: The validity period to encode :type validityPeriod: datetime.timedelta :rtype: int @@ -490,21 +520,21 @@ def _encodeRelativeValidityPeriod(validityPeriod): else: raise ValueError('Validity period too long; tpVp limited to 1 octet (max value: 255)') return tpVp - + def _decodeTimestamp(byteIter): """ Decodes a 7-octet timestamp """ dateStr = decodeSemiOctets(byteIter, 7) - timeZoneStr = dateStr[-2:] + timeZoneStr = dateStr[-2:] return datetime.strptime(dateStr[:-2], '%y%m%d%H%M%S').replace(tzinfo=SmsPduTzInfo(timeZoneStr)) def _encodeTimestamp(timestamp): """ Encodes a 7-octet timestamp from the specified date - + Note: the specified timestamp must have a UTC offset set; you can use gsmmodem.util.SimpleOffsetTzInfo for simple cases - + :param timestamp: The timestamp to encode :type timestamp: datetime.datetime - + :return: The encoded timestamp :rtype: bytearray """ @@ -531,14 +561,17 @@ def _decodeDataCoding(octet): alphabet = (octet & 0x0C) >> 2 return alphabet # 0x00 == GSM-7, 0x01 == 8-bit data, 0x02 == UCS2 # We ignore other coding groups - return 0 + return 0 + +def nibble2octet(addressLen): + return int((addressLen + 1) / 2) def _decodeAddressField(byteIter, smscField=False, log=False): """ Decodes the address field at the current position of the bytearray iterator - + :param byteIter: Iterator over bytearray - :type byteIter: iter(bytearray) - + :type byteIter: iter(bytearray) + :return: Tuple containing the address value and amount of bytes read (value is or None if it is empty (zero-length)) :rtype: tuple """ @@ -546,22 +579,19 @@ def _decodeAddressField(byteIter, smscField=False, log=False): if addressLen > 0: toa = next(byteIter) ton = (toa & 0x70) # bits 6,5,4 of type-of-address == type-of-number - if ton == 0x50: - # Alphanumberic number - addressLen = int(math.ceil(addressLen / 2.0)) + if ton == 0x50: + # Alphanumberic number + addressLen = nibble2octet(addressLen) septets = unpackSeptets(byteIter, addressLen) addressValue = decodeGsm7(septets) return (addressValue, (addressLen + 2)) else: - # ton == 0x00: Unknown (might be international, local, etc) - leave as is + # ton == 0x00: Unknown (might be international, local, etc) - leave as is # ton == 0x20: National number if smscField: addressValue = decodeSemiOctets(byteIter, addressLen-1) else: - if addressLen % 2: - addressLen = int(addressLen / 2) + 1 - else: - addressLen = int(addressLen / 2) + addressLen = nibble2octet(addressLen) addressValue = decodeSemiOctets(byteIter, addressLen) addressLen += 1 # for the return value, add the toa byte if ton == 0x10: # International number @@ -572,16 +602,16 @@ def _decodeAddressField(byteIter, smscField=False, log=False): def _encodeAddressField(address, smscField=False): """ Encodes the address into an address field - + :param address: The address to encode (phone number or alphanumeric) :type byteIter: str - + :return: Encoded SMS PDU address field :rtype: bytearray """ # First, see if this is a number or an alphanumeric string toa = 0x80 | 0x00 | 0x01 # Type-of-address start | Unknown type-of-number | ISDN/tel numbering plan - alphaNumeric = False + alphaNumeric = False if address.isalnum(): # Might just be a local number if address.isdigit(): @@ -605,10 +635,10 @@ def _encodeAddressField(address, smscField=False): alphaNumeric = True if alphaNumeric: addressValue = packSeptets(encodeGsm7(address, False)) - addressLen = len(addressValue) * 2 + addressLen = len(addressValue) * 2 else: addressValue = encodeSemiOctets(address) - if smscField: + if smscField: addressLen = len(addressValue) + 1 else: addressLen = len(address) @@ -620,7 +650,7 @@ def _encodeAddressField(address, smscField=False): def encodeSemiOctets(number): """ Semi-octet encoding algorithm (e.g. for phone numbers) - + :return: bytearray containing the encoded octets :rtype: bytearray """ @@ -631,12 +661,12 @@ def encodeSemiOctets(number): def decodeSemiOctets(encodedNumber, numberOfOctets=None): """ Semi-octet decoding algorithm(e.g. for phone numbers) - + :param encodedNumber: The semi-octet-encoded telephone number (in bytearray format or hex string) :type encodedNumber: bytearray, str or iter(bytearray) :param numberOfOctets: The expected amount of octets after decoding (i.e. when to stop) :type numberOfOctets: int - + :return: decoded telephone number :rtype: string """ @@ -644,8 +674,8 @@ def decodeSemiOctets(encodedNumber, numberOfOctets=None): if type(encodedNumber) in (str, bytes): encodedNumber = bytearray(codecs.decode(encodedNumber, 'hex_codec')) i = 0 - for octet in encodedNumber: - hexVal = hex(octet)[2:].zfill(2) + for octet in encodedNumber: + hexVal = hex(octet)[2:].zfill(2) number.append(hexVal[1]) if hexVal[0] != 'f': number.append(hexVal[0]) @@ -657,23 +687,55 @@ def decodeSemiOctets(encodedNumber, numberOfOctets=None): break return ''.join(number) +def encodeTextMode(plaintext): + """ Text mode checker + + Tests whther SMS could be sent in text mode + + :param text: the text string to encode + + :raise ValueError: if the text string cannot be sent in text mode + + :return: Passed string + :rtype: str + """ + if PYTHON_VERSION >= 3: + plaintext = str(plaintext) + elif type(plaintext) == str: + plaintext = plaintext.decode('UTF-8') + + for char in plaintext: + idx = TEXT_MODE.find(char) + if idx != -1: + continue + else: + raise ValueError('Cannot encode char "{0}" inside text mode'.format(char)) + + if len(plaintext) > MAX_MESSAGE_LENGTH[0x00]: + raise ValueError('Message is too long for text mode (maximum {0} characters)'.format(MAX_MESSAGE_LENGTH[0x00])) + + return plaintext + def encodeGsm7(plaintext, discardInvalid=False): """ GSM-7 text encoding algorithm - + Encodes the specified text string into GSM-7 octets (characters). This method does not pack the characters into septets. - + :param text: the text string to encode - :param discardInvalid: if True, characters that cannot be encoded will be silently discarded - + :param discardInvalid: if True, characters that cannot be encoded will be silently discarded + :raise ValueError: if the text string cannot be encoded using GSM-7 encoding (unless discardInvalid == True) - + :return: A bytearray containing the string encoded in GSM-7 encoding :rtype: bytearray """ result = bytearray() - if PYTHON_VERSION >= 3: + if PYTHON_VERSION >= 3: plaintext = str(plaintext) + elif type(plaintext) == str: + plaintext = plaintext.decode('UTF-8') + for char in plaintext: idx = GSM7_BASIC.find(char) if idx != -1: @@ -687,12 +749,12 @@ def encodeGsm7(plaintext, discardInvalid=False): def decodeGsm7(encodedText): """ GSM-7 text decoding algorithm - + Decodes the specified GSM-7-encoded string into a plaintext string. - + :param encodedText: the text string to encode :type encodedText: bytearray or str - + :return: A string containing the decoded text :rtype: str """ @@ -711,85 +773,133 @@ def decodeGsm7(encodedText): result.append(GSM7_BASIC[b]) return ''.join(result) +def divideTextGsm7(plainText): + """ GSM7 message dividing algorithm + + Divides text into list of chunks that could be stored in a single, GSM7-encoded SMS message. + + :param plainText: the text string to divide + :type plainText: str + + :return: A list of strings + :rtype: list of str + """ + result = [] + + plainStartPtr = 0 + plainStopPtr = 0 + chunkByteSize = 0 + + if PYTHON_VERSION >= 3: + plainText = str(plainText) + while plainStopPtr < len(plainText): + char = plainText[plainStopPtr] + idx = GSM7_BASIC.find(char) + if idx != -1: + chunkByteSize = chunkByteSize + 1; + elif char in GSM7_EXTENDED: + chunkByteSize = chunkByteSize + 2; + else: + raise ValueError('Cannot encode char "{0}" using GSM-7 encoding'.format(char)) + + plainStopPtr = plainStopPtr + 1 + if chunkByteSize > MAX_MULTIPART_MESSAGE_LENGTH[0x00]: + plainStopPtr = plainStopPtr - 1 + + if chunkByteSize >= MAX_MULTIPART_MESSAGE_LENGTH[0x00]: + result.append(plainText[plainStartPtr:plainStopPtr]) + plainStartPtr = plainStopPtr + chunkByteSize = 0 + + if chunkByteSize > 0: + result.append(plainText[plainStartPtr:]) + + return result + def packSeptets(octets, padBits=0): """ Packs the specified octets into septets - + Typically the output of encodeGsm7 would be used as input to this function. The resulting bytearray contains the original GSM-7 characters packed into septets ready for transmission. - + :rtype: bytearray """ - result = bytearray() + result = bytearray() if type(octets) == str: octets = iter(rawStrToByteArray(octets)) elif type(octets) == bytearray: octets = iter(octets) shift = padBits if padBits == 0: - prevSeptet = next(octets) + try: + prevSeptet = next(octets) + except StopIteration: + return result else: prevSeptet = 0x00 for octet in octets: septet = octet & 0x7f; if shift == 7: # prevSeptet has already been fully added to result - shift = 0 + shift = 0 prevSeptet = septet - continue + continue b = ((septet << (7 - shift)) & 0xFF) | (prevSeptet >> shift) prevSeptet = septet shift += 1 - result.append(b) + result.append(b) if shift != 7: # There is a bit "left over" from prevSeptet result.append(prevSeptet >> shift) return result def unpackSeptets(septets, numberOfSeptets=None, prevOctet=None, shift=7): - """ Unpacks the specified septets into octets - + """ Unpacks the specified septets into octets + :param septets: Iterator or iterable containing the septets packed into octets :type septets: iter(bytearray), bytearray or str :param numberOfSeptets: The amount of septets to unpack (or None for all remaining in "septets") :type numberOfSeptets: int or None - + :return: The septets unpacked into octets :rtype: bytearray - """ - result = bytearray() + """ + result = bytearray() if type(septets) == str: septets = iter(rawStrToByteArray(septets)) elif type(septets) == bytearray: - septets = iter(septets) - if numberOfSeptets == None: + septets = iter(septets) + if numberOfSeptets == None: numberOfSeptets = MAX_INT # Loop until StopIteration + if numberOfSeptets == 0: + return result i = 0 for octet in septets: i += 1 if shift == 7: shift = 1 - if prevOctet != None: - result.append(prevOctet >> 1) + if prevOctet != None: + result.append(prevOctet >> 1) if i <= numberOfSeptets: result.append(octet & 0x7F) - prevOctet = octet + prevOctet = octet if i == numberOfSeptets: break else: continue b = ((octet << shift) & 0x7F) | (prevOctet >> (8 - shift)) - - prevOctet = octet + + prevOctet = octet result.append(b) shift += 1 - + if i == numberOfSeptets: break - if shift == 7: + if shift == 7 and prevOctet: b = prevOctet >> (8 - shift) if b: # The final septet value still needs to be unpacked - result.append(b) + result.append(b) return result def decodeUcs2(byteIter, numBytes): @@ -807,16 +917,42 @@ def decodeUcs2(byteIter, numBytes): def encodeUcs2(text): """ UCS2 text encoding algorithm - + Encodes the specified text string into UCS2-encoded bytes. - + :param text: the text string to encode - + :return: A bytearray containing the string encoded in UCS2 encoding :rtype: bytearray """ result = bytearray() + for b in map(ord, text): result.append(b >> 8) result.append(b & 0xFF) return result + +def divideTextUcs2(plainText): + """ UCS-2 message dividing algorithm + + Divides text into list of chunks that could be stored in a single, UCS-2 -encoded SMS message. + + :param plainText: the text string to divide + :type plainText: str + + :return: A list of strings + :rtype: list of str + """ + result = [] + resultLength = 0 + + fullChunksCount = int(len(plainText) / MAX_MULTIPART_MESSAGE_LENGTH[0x08]) + for i in range(fullChunksCount): + result.append(plainText[i * MAX_MULTIPART_MESSAGE_LENGTH[0x08] : (i + 1) * MAX_MULTIPART_MESSAGE_LENGTH[0x08]]) + resultLength = resultLength + MAX_MULTIPART_MESSAGE_LENGTH[0x08] + + # Add last, not fully filled chunk + if resultLength < len(plainText): + result.append(plainText[resultLength:]) + + return result diff --git a/gsmmodem/serial_comms.py b/gsmmodem/serial_comms.py index ac445f5..22e722c 100644 --- a/gsmmodem/serial_comms.py +++ b/gsmmodem/serial_comms.py @@ -12,47 +12,51 @@ class SerialComms(object): """ Wraps all low-level serial communications (actual read/write operations) """ - + log = logging.getLogger('gsmmodem.serial_comms.SerialComms') - + # End-of-line read terminator - RX_EOL_SEQ = '\r\n' + RX_EOL_SEQ = b'\r\n' # End-of-response terminator - RESPONSE_TERM = re.compile(r'^OK|ERROR|(\+CM[ES] ERROR: \d+)|(COMMAND NOT SUPPORT)$') + RESPONSE_TERM = re.compile('^OK|ERROR|(\+CM[ES] ERROR: \d+)|(COMMAND NOT SUPPORT)$') # Default timeout for serial port reads (in seconds) timeout = 1 - + def __init__(self, port, baudrate=115200, notifyCallbackFunc=None, fatalErrorCallbackFunc=None, *args, **kwargs): """ Constructor - + :param fatalErrorCallbackFunc: function to call if a fatal error occurs in the serial device reading thread :type fatalErrorCallbackFunc: func - """ + """ self.alive = False self.port = port self.baudrate = baudrate - + self._responseEvent = None # threading.Event() self._expectResponseTermSeq = None # expected response terminator sequence self._response = None # Buffer containing response to a written command self._notification = [] # Buffer containing lines from an unsolicited notification from the modem # Reentrant lock for managing concurrent write access to the underlying serial port self._txLock = threading.RLock() - - self.notifyCallback = notifyCallbackFunc or self._placeholderCallback + + self.notifyCallback = notifyCallbackFunc or self._placeholderCallback self.fatalErrorCallback = fatalErrorCallbackFunc or self._placeholderCallback - + + self.com_args = args + self.com_kwargs = kwargs + def connect(self): - """ Connects to the device and starts the read thread """ - self.serial = serial.Serial(port=self.port, baudrate=self.baudrate, timeout=self.timeout) + """ Connects to the device and starts the read thread """ + self.serial = serial.Serial(dsrdtr=True, rtscts=True, port=self.port, baudrate=self.baudrate, + timeout=self.timeout,*self.com_args,**self.com_kwargs) # Start read thread - self.alive = True + self.alive = True self.rxThread = threading.Thread(target=self._readLoop) self.rxThread.daemon = True self.rxThread.start() def close(self): - """ Stops the read thread, waits for it to exit cleanly, then closes the underlying serial port """ + """ Stops the read thread, waits for it to exit cleanly, then closes the underlying serial port """ self.alive = False self.rxThread.join() self.serial.close() @@ -67,7 +71,7 @@ def _handleLineRead(self, line, checkForResponseTerm=True): #print 'response:', self._response self.log.debug('response: %s', self._response) self._responseEvent.set() - else: + else: # Nothing was waiting for this - treat it as a notification self._notification.append(line) if self.serial.inWaiting() == 0: @@ -75,37 +79,37 @@ def _handleLineRead(self, line, checkForResponseTerm=True): #print 'notification:', self._notification self.log.debug('notification: %s', self._notification) self.notifyCallback(self._notification) - self._notification = [] + self._notification = [] def _placeholderCallback(self, *args, **kwargs): """ Placeholder callback function (does nothing) """ - + def _readLoop(self): """ Read thread main loop - + Reads lines from the connected device """ try: - readTermSeq = list(self.RX_EOL_SEQ) + readTermSeq = bytearray(self.RX_EOL_SEQ) readTermLen = len(readTermSeq) - rxBuffer = [] + rxBuffer = bytearray() while self.alive: data = self.serial.read(1) - if data != '': # check for timeout + if len(data) != 0: # check for timeout #print >> sys.stderr, ' RX:', data,'({0})'.format(ord(data)) - rxBuffer.append(data) - if rxBuffer[-readTermLen:] == readTermSeq: + rxBuffer.append(ord(data)) + if rxBuffer[-readTermLen:] == readTermSeq: # A line (or other logical segment) has been read - line = ''.join(rxBuffer[:-readTermLen]) - rxBuffer = [] - if len(line) > 0: - #print 'calling handler' + line = rxBuffer[:-readTermLen].decode() + rxBuffer = bytearray() + if len(line) > 0: + #print 'calling handler' self._handleLineRead(line) elif self._expectResponseTermSeq: if rxBuffer[-len(self._expectResponseTermSeq):] == self._expectResponseTermSeq: - line = ''.join(rxBuffer) - rxBuffer = [] - self._handleLineRead(line, checkForResponseTerm=False) + line = rxBuffer.decode() + rxBuffer = bytearray() + self._handleLineRead(line, checkForResponseTerm=False) #else: #' ' except serial.SerialException as e: @@ -116,14 +120,15 @@ def _readLoop(self): pass # Notify the fatal error handler self.fatalErrorCallback(e) - + def write(self, data, waitForResponse=True, timeout=5, expectedResponseTermSeq=None): - with self._txLock: + data = data.encode() + with self._txLock: if waitForResponse: if expectedResponseTermSeq: - self._expectResponseTermSeq = list(expectedResponseTermSeq) + self._expectResponseTermSeq = bytearray(expectedResponseTermSeq.encode()) self._response = [] - self._responseEvent = threading.Event() + self._responseEvent = threading.Event() self.serial.write(data) if self._responseEvent.wait(timeout): self._responseEvent = None @@ -137,5 +142,5 @@ def write(self, data, waitForResponse=True, timeout=5, expectedResponseTermSeq=N raise TimeoutException(self._response) else: raise TimeoutException() - else: + else: self.serial.write(data) diff --git a/gsmmodem/util.py b/gsmmodem/util.py index 1008e5c..6bf7cf3 100644 --- a/gsmmodem/util.py +++ b/gsmmodem/util.py @@ -108,3 +108,17 @@ def allLinesMatchingPattern(pattern, lines): if m: result.append(m) return result + + +def removeAtPrefix(string): + """ Remove AT prefix from a specified string. + + :param string: An original string + :type string: str + + :return: A string with AT prefix removed + :rtype: str + """ + if string.startswith('AT'): + return string[2:] + return string diff --git a/requirements.txt b/requirements.txt index f59baab..8b2635b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pyserial>=2.6 \ No newline at end of file +pyserial>=3.1.1 \ No newline at end of file diff --git a/setup.py b/setup.py index e7ef2ed..abe244a 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ test_command = [sys.executable, '-m', 'unittest', 'discover'] coverage_command = ['coverage', 'run', '-m', 'unittest', 'discover'] -VERSION = 0.9 +VERSION = "0.12" class RunUnitTests(Command): """ run unit tests """ @@ -58,15 +58,15 @@ def run(self): subprocess.call(['coverage', 'report']) raise SystemExit(errno) -setup(name='python-gsmmodem', +setup(name='python-gsmmodem-new', version='{0}'.format(VERSION), description='Control an attached GSM modem: send/receive SMS messages, handle calls, etc', license='LGPLv3+', author='Francois Aucamp', author_email='francois.aucamp@gmail.com', - url='https://github.com/faucamp/python-gsmmodem', - download_url='https://github.com/faucamp/python-gsmmodem/archive/{0}.tar.gz'.format(VERSION), + url='https://github.com/babca/python-gsmmodem', + download_url='https://github.com/babca/python-gsmmodem/archive/{0}.tar.gz'.format(VERSION), long_description="""\ python-gsmmodem is a module that allows easy control of a GSM modem attached diff --git a/test/fakemodems.py b/test/fakemodems.py index efd9575..706c5e0 100644 --- a/test/fakemodems.py +++ b/test/fakemodems.py @@ -6,7 +6,7 @@ class FakeModem(object): """ Abstract base class for fake modem descriptors """ __metaclass__ = abc.ABCMeta - + def __init__(self): self.responses = {} self.commandsNoPinRequired = [] @@ -19,13 +19,16 @@ def __init__(self): self.deviceBusyErrorCounter = 0 # Number of times to issue a "Device busy" error self.cfun = 1 # +CFUN value to report back self.dtmfCommandBase = '+VTS=' - + def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + if self.deviceBusyErrorCounter > 0: self.deviceBusyErrorCounter -= 1 return ['+CME ERROR: 515\r\n'] if self._pinLock and not cmd.startswith('AT+CPIN'): - if cmd not in self.commandsNoPinRequired: + if cmd not in self.commandsNoPinRequired: return copy(self.pinRequiredErrorResponse) if cmd.startswith('AT+CPIN="'): @@ -35,7 +38,7 @@ def getResponse(self, cmd): return ['+CME ERROR: 14\r\n'] if cmd == 'AT+CFUN?\r' and self.cfun != -1: return ['+CFUN: {0}\r\n'.format(self.cfun), 'OK\r\n'] - elif cmd == 'AT+CSCA?\r': + elif cmd == 'AT+CSCA?\r': if self.smscNumber != None: return ['+CSCA: "{0}",145\r\n'.format(self.smscNumber), 'OK\r\n'] else: @@ -52,7 +55,7 @@ def pinLock(self): def pinLock(self, pinLock): self._pinLock = pinLock if self._pinLock == True: - self.responses['AT+CPIN?\r'] = ['+CPIN: SIM PIN\r\n', 'OK\r\n'] + self.responses['AT+CPIN?\r'] = ['+CPIN: SIM PIN\r\n', 'OK\r\n'] else: self.responses['AT+CPIN?\r'] = ['+CPIN: READY\r\n', 'OK\r\n'] @@ -63,15 +66,15 @@ def getAtdResponse(self, number): @abc.abstractmethod def getPreCallInitWaitSequence(self): return [0.1] - + @abc.abstractmethod def getCallInitNotification(self, callId, callType): return ['+WIND: 5,1\r\n', '+WIND: 2\r\n'] - + @abc.abstractmethod def getRemoteAnsweredNotification(self, callId, callType): return ['OK\r\n'] - + @abc.abstractmethod def getRemoteHangupNotification(self, callId, callType): return ['NO CARRIER\r\n', '+WIND: 6,1\r\n'] @@ -79,7 +82,7 @@ def getRemoteHangupNotification(self, callId, callType): def getRemoteRejectCallNotification(self, callId, callType): # For a lot of modems, this is the same as a hangup notification - override this if necessary! return self.getRemoteHangupNotification(callId, callType) - + @abc.abstractmethod def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): return ['RING\r\n'] @@ -87,7 +90,7 @@ def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): class GenericTestModem(FakeModem): """ Not based on a real modem - simply used for general tests. Uses polling for call status updates """ - + def __init__(self): super(GenericTestModem, self).__init__() self._callState = 2 @@ -95,13 +98,21 @@ def __init__(self): self._callId = None self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] self.responses = {'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], + 'AT+CLAC=?\r': ['ERROR\r\n'], 'AT+CLAC\r': ['ERROR\r\n'], + 'AT+WIND=?\r': ['ERROR\r\n'], 'AT+WIND?\r': ['ERROR\r\n'], 'AT+WIND=50\r': ['ERROR\r\n'], + 'AT+ZPAS=?\r': ['ERROR\r\n'], 'AT+ZPAS?\r': ['ERROR\r\n'], - 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} + 'AT+CSCS=?\r': ['+CSCS: ("GSM","UCS2")\r\n', 'OK\r\n'], + 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n'], + 'AT\r': ['OK\r\n']} def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + if not self._pinLock and cmd == 'AT+CLCC\r': if self._callNumber: if self._callState == 0: @@ -141,7 +152,7 @@ def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): class WavecomMultiband900E1800(FakeModem): """ Family of old Wavecom serial modems - + User franciumlin also submitted the following improvements to this profile: +CPIN replies are not ended with "OK" """ @@ -152,25 +163,28 @@ def __init__(self): 'AT+CGMM\r': [' MULTIBAND 900E 1800\r\n', 'OK\r\n'], 'AT+CGMR\r': ['ERROR\r\n'], 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], - 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], + 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 'AT+CLAC\r': ['ERROR\r\n'], 'AT+WIND?\r': ['+WIND: 0\r\n', 'OK\r\n'], 'AT+WIND=50\r': ['OK\r\n'], 'AT+ZPAS?\r': ['ERROR\r\n'], - 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], + 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], 'AT+CPMS=?\r': ['+CPMS: (("SM","BM","SR"),("SM"))\r\n', 'OK\r\n'], 'AT+CPMS="SM","SM"\r': ['+CPMS: 14,50,14,50\r\n', 'OK\r\n'], 'AT+CNMI=2,1,0,2\r': ['OK\r\n'], 'AT+CVHU=0\r': ['ERROR\r\n'], 'AT+CPIN?\r': ['+CPIN: READY\r\n']} # <---- note: missing 'OK\r\n' self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] - + def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + if cmd == 'AT+CFUN=1\r': self.deviceBusyErrorCounter = 2 # This modem takes quite a while to recover from this return ['OK\r\n'] return super(WavecomMultiband900E1800, self).getResponse(cmd) - + @property def pinLock(self): return self._pinLock @@ -181,29 +195,29 @@ def pinLock(self, pinLock): self.responses['AT+CPIN?\r'] = ['+CPIN: SIM PIN\r\n'] # missing OK else: self.responses['AT+CPIN?\r'] = ['+CPIN: READY\r\n'] # missing OK - + def getAtdResponse(self, number): return [] - + def getPreCallInitWaitSequence(self): return [0.1] - + def getCallInitNotification(self, callId, callType): # +WIND: 5 == indication of call # +WIND: 2 == remote party is ringing return ['+WIND: 5,1\r\n', '+WIND: 2\r\n'] - + def getRemoteAnsweredNotification(self, callId, callType): return ['OK\r\n'] - + def getRemoteHangupNotification(self, callId, callType): return ['NO CARRIER\r\n', '+WIND: 6,1\r\n'] - + def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2}\r\n'.format(callType, callerNumber, ton)] - + def __str__(self): - return 'WAVECOM MODEM MULTIBAND 900E 1800' + return 'WAVECOM MODEM MULTIBAND 900E 1800' class HuaweiK3715(FakeModem): @@ -215,7 +229,7 @@ def __init__(self): 'AT+CGMM\r': ['K3715\r\n', 'OK\r\n'], 'AT+CGMR\r': ['11.104.05.00.00\r\n', 'OK\r\n'], 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], - 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], + 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], 'AT+CPMS=?\r': ['+CPMS: ("ME","MT","SM","SR"),("ME","MT","SM","SR"),("ME","MT","SM","SR")\r\n', 'OK\r\n'], 'AT+WIND?\r': ['ERROR\r\n'], 'AT+WIND=50\r': ['ERROR\r\n'], @@ -238,25 +252,25 @@ def __init__(self): 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] self.dtmfCommandBase = '^DTMF={cid},' - + def getAtdResponse(self, number): return ['OK\r\n'] - + def getPreCallInitWaitSequence(self): return [0.1] - + def getCallInitNotification(self, callId, callType): return ['^ORIG:{0},{1}\r\n'.format(callId, callType), 0.2, '^CONF:{0}\r\n'.format(callId)] - + def getRemoteAnsweredNotification(self, callId, callType): return ['^CONN:{0},{1}\r\n'.format(callId, callType)] - + def getRemoteHangupNotification(self, callId, callType): return ['^CEND:{0},5,29,16\r\n'.format(callId)] - + def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] - + def __str__(self): return 'Huawei K3715' @@ -331,10 +345,13 @@ def __init__(self): 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] self.dtmfCommandBase = '^DTMF={cid},' - + def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + # Device defaults to ^USSDMODE == 1 - if cmd.startswith('AT+CUSD=1') and self._ussdMode == 1: + if cmd.startswith('AT+CUSD=1') and self._ussdMode == 1: return ['ERROR\r\n'] elif cmd.startswith('AT^USSDMODE='): self._ussdMode = int(cmd[12]) @@ -379,7 +396,9 @@ def __init__(self): 'AT+CGMR\r': ['M6280_V1.0.0 M6280_V1.0.0 1 [Sep 4 2008 12:00:00]\r\n', 'OK\r\n'], 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], + 'AT+CLAC=?\r': ['ERROR\r\n'], 'AT+CLAC\r': ['ERROR\r\n'], + 'AT+WIND=?\r': ['ERROR\r\n'], 'AT+WIND?\r': ['ERROR\r\n'], 'AT+WIND=50\r': ['ERROR\r\n'], 'AT+ZPAS?\r': ['+BEARTYPE: "UMTS","CS_PS"\r\n', 'OK\r\n'], @@ -388,6 +407,9 @@ def __init__(self): 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + if not self._pinLock: if cmd.startswith('AT+CSMP='): # Clear the SMSC number (this behaviour was reported in issue #8 on github) @@ -408,16 +430,16 @@ def getAtdResponse(self, number): self._callNumber = number self._callState = 0 return [] - + def getPreCallInitWaitSequence(self): return [0.1] - + def getCallInitNotification(self, callId, callType): return [] - + def getRemoteAnsweredNotification(self, callId, callType): return ['CONNECT\r\n'] - + def getRemoteHangupNotification(self, callId, callType): self._callState = 2 self._callNumber = None @@ -425,7 +447,7 @@ def getRemoteHangupNotification(self, callId, callType): def getIncomingCallNotification(self, callerNumber, callType='VOICE', ton=145): return ['+CRING: {0}\r\n'.format(callType), '+CLIP: "{1}",{2},,,,0\r\n'.format(callType, callerNumber, ton)] - + def __str__(self): return 'QUALCOMM M6280 (ZTE modem)' @@ -479,6 +501,9 @@ def __init__(self): 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} def getResponse(self, cmd): + if type(cmd) == bytes: + cmd = cmd.decode() + if not self._pinLock: if cmd.startswith('AT+CSMP='): # Clear the SMSC number (this behaviour was reported in issue #8 on github) @@ -529,7 +554,7 @@ def __str__(self): class NokiaN79(GenericTestModem): """ Nokia Symbian S60-based modem (details taken from a Nokia N79) and also from issue 15: https://github.com/faucamp/python-gsmmodem/issues/15 (Nokia N95) - + SMS reading is not supported on these devices via AT commands; thus commands like AT+CNMI are not supported. """ @@ -541,12 +566,16 @@ def __init__(self): 'AT+CGMR\r': ['V ICPR72_08w44.1\r\n', '24-11-08\r\n', 'RM-348\r\n', '(c) Nokia\r\n', '11.049\r\n', 'OK\r\n'], 'AT+CIMI\r': ['111111111111111\r\n', 'OK\r\n'], 'AT+CGSN\r': ['111111111111111\r\n', 'OK\r\n'], + 'AT+CNMI=?\r': ['ERROR\r\n'], # SMS reading and notifications not supported 'AT+CNMI=2,1,0,2\r': ['ERROR\r\n'], # SMS reading and notifications not supported + 'AT+CLAC=?\r': ['ERROR\r\n'], 'AT+CLAC\r': ['ERROR\r\n'], + 'AT+WIND=?\r': ['ERROR\r\n'], 'AT+WIND?\r': ['ERROR\r\n'], 'AT+WIND=50\r': ['ERROR\r\n'], + 'AT+ZPAS=?\r': ['ERROR\r\n'], 'AT+ZPAS?\r': ['ERROR\r\n'], - 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], + 'AT+CPMS="SM","SM","SR"\r': ['ERROR\r\n'], 'AT+CPMS=?\r': ['+CPMS: (),(),()\r\n', 'OK\r\n'], # not supported 'AT+CPMS?\r': ['+CPMS: ,,,,,,,,\r\n', 'OK\r\n'], # not supported 'AT+CPMS=,,\r': ['ERROR\r\n'], @@ -556,10 +585,10 @@ def __init__(self): 'AT+CNMI=2,1,0,2\r': ['ERROR\r\n'], # not supported 'AT+CVHU=0\r': ['OK\r\n'], 'AT+CPIN?\r': ['+CPIN: READY\r\n', 'OK\r\n']} - self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] - + self.commandsNoPinRequired = ['ATZ\r', 'ATE0\r', 'AT+CFUN?\r', 'AT+CFUN=1\r', 'AT+CMEE=1\r'] + def __str__(self): - return 'Nokia N79' + return 'Nokia N79' modemClasses = [HuaweiK3715, HuaweiE1752, WavecomMultiband900E1800, QualcommM6280, ZteK3565Z, NokiaN79] diff --git a/test/test_modem.py b/test/test_modem.py index 8d49897..420b827 100644 --- a/test/test_modem.py +++ b/test/test_modem.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf8 -*- """ Test suite for gsmmodem.modem """ @@ -101,6 +102,8 @@ def _setupReadValue(self, command): def write(self, data): if self.writeCallbackFunc != None: + if type(data) == bytes: + data = data.decode() self.writeCallbackFunc(data) self.writeQueue.append(data) @@ -195,6 +198,8 @@ def writeCallbackFunc(data): def test_supportedCommands(self): def writeCallbackFunc(data): + if data == 'AT\r': # Handle keep-alive AT command + return self.assertEqual('AT+CLAC\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CLAC\r', data)) self.modem.serial.writeCallbackFunc = writeCallbackFunc tests = ((['+CLAC:&C,D,E,\S,+CGMM,^DTMF\r\n', 'OK\r\n'], ['&C', 'D', 'E', '\S', '+CGMM', '^DTMF']), @@ -202,6 +207,8 @@ def writeCallbackFunc(data): (['FGH,RTY,UIO\r\n', 'OK\r\n'], ['FGH', 'RTY', 'UIO']), # nasty, but possible # ZTE-like response: do not start with +CLAC, and use multiple lines (['A\r\n', 'BCD\r\n', 'EFGH\r\n', 'OK\r\n'], ['A', 'BCD', 'EFGH']), + # Teleofis response: like ZTE but each command has AT prefix + (['AT&F\r\n', 'AT&V\r\n', 'AT&W\r\n', 'AT+CACM\r\n', 'OK\r\n'], ['&F', '&V', '&W', '+CACM']), # Some Huawei modems have a ZTE-like response, but add an addition \r character at the end of each listed command (['Q\r\r\n', 'QWERTY\r\r\n', '^DTMF\r\r\n', 'OK\r\n'], ['Q', 'QWERTY', '^DTMF'])) for responseSequence, expected in tests: @@ -356,6 +363,39 @@ def test_errorTypes(self): self.assertEqual(e.type, 'CMS') self.assertEqual(e.code, 310) + def test_smsEncoding(self): + def writeCallbackFunc(data): + if type(data) == bytes: + data = data.decode() + self.assertEqual('AT+CSCS?\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS?', data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc + tests = [('UNKNOWN', '+CSCSERROR'), + ('UCS2', '+CSCS: "UCS2"'), + ('ISO', '+CSCS:"ISO"'), + ('IRA', '+CSCS: "IRA"'), + ('UNKNOWN', '+CSCS: ("GSM", "UCS2")'), + ('UNKNOWN', 'OK'),] + for name, toWrite in tests: + self.modem._smsEncoding = 'UNKNOWN' + self.modem.serial.responseSequence = ['{0}\r\n'.format(toWrite), 'OK\r\n'] + self.assertEqual(name, self.modem.smsEncoding) + + def test_smsSupportedEncoding(self): + def writeCallbackFunc(data): + if type(data) == bytes: + data = data.decode() + self.assertEqual('AT+CSCS=?\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS?', data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc + tests = [(['GSM'], '+CSCS: ("GSM")'), + (['GSM', 'UCS2'], '+CSCS: ("GSM", "UCS2")'), + (['GSM', 'UCS2'], '+CSCS:("GSM","UCS2")'), + (['GSM', 'UCS2'], '+CSCS: ( "GSM" , "UCS2" )'), + (['GSM'], '+CSCS: ("GSM" "UCS2")'),] + for name, toWrite in tests: + self.modem._smsSupportedEncodingNames = None + self.modem.serial.responseSequence = ['{0}\r\n'.format(toWrite), 'OK\r\n'] + self.assertEqual(name, self.modem.smsSupportedEncoding) + class TestUssd(unittest.TestCase): """ Tests USSD session handling """ @@ -680,6 +720,7 @@ def test_cnmiNotSupported(self): global FAKE_MODEM FAKE_MODEM = copy(fakemodems.GenericTestModem()) FAKE_MODEM.responses['AT+CNMI=2,1,0,2\r'] = ['ERROR\r\n'] + FAKE_MODEM.responses['AT+CNMI=2,1,0,1,0\r'] = ['ERROR\r\n'] # This should pass without any problem, and AT+CNMI=2,1,0,2 should at least have been attempted during connect() cnmiWritten = [False] def writeCallbackFunc(data): @@ -1220,12 +1261,14 @@ def testDtmf(self): tests = (('3', 'AT{0}3\r'.format(fakeModem.dtmfCommandBase.format(cid=call.id))), ('1234', 'AT{0}1;{0}2;{0}3;{0}4\r'.format(fakeModem.dtmfCommandBase.format(cid=call.id))), ('#0*', 'AT{0}#;{0}0;{0}*\r'.format(fakeModem.dtmfCommandBase.format(cid=call.id)))) - + for tones, expectedCommand in tests: def writeCallbackFunc(data): + expectedCommand = 'AT{0}{1}\r'.format(fakeModem.dtmfCommandBase.format(cid=call.id), tones[self.currentTone]) + self.currentTone += 1; self.assertEqual(expectedCommand, data, 'Invalid data written to modem for tones: "{0}"; expected "{1}", got: "{2}". Modem: {3}'.format(tones, expectedCommand[:-1].format(cid=self.id), data[:-1] if data[-1] == '\r' else data, fakeModem)) self.modem.serial.writeCallbackFunc = writeCallbackFunc - call.sendDtmfTone(tones) + self.currentTone = 0; # Now attempt to send DTMF tones in an inactive call self.modem.serial.writeCallbackFunc = None @@ -1297,6 +1340,110 @@ def initModem(self, smsReceivedCallbackFunc): self.modem = gsmmodem.modem.GsmModem('-- PORT IGNORED DURING TESTS --', smsReceivedCallbackFunc=smsReceivedCallbackFunc) self.modem.connect() + def test_sendSmsLeaveTextModeOnInvalidCharacter(self): + """ Tests sending SMS messages in text mode """ + self.initModem(None) + self.modem.smsTextMode = True # Set modem to text mode + self.assertTrue(self.modem.smsTextMode) + # PDUs checked on https://www.diafaan.com/sms-tutorials/gsm-modem-tutorial/online-sms-pdu-decoder/ + tests = (('+0123456789', 'Helló worłd!', + 1, + datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), + '+2782913593', + [('00218D0A91103254769800081800480065006C006C00F300200077006F0072014200640021', 36, 141)], + 'SM', + 'UCS2'), + ('+0123456789', 'Hellò wor£d!', + 2, + datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), + '82913593', + [('00218E0A91103254769800000CC8329B8D00DDDFF2003904', 23, 142)], + 'SM', + 'GSM'), + ('+0123456789', '12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345-070 12345-080 12345-090 12345-100 12345-110 12345-120 12345-130 12345-140 12345-150 12345-160-Hellò wor£d!', + 3, + datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), + '82913593', + [('00618F0A9110325476980000A00500038F020162B219ADD682C560A0986C46ABB560321828269BD16A2DD80C068AC966B45A0B46838162B219ADD682D560A0986C46ABB560361828269BD16A2DD80D068AC966B45A0B86838162B219ADD682E560A0986C46ABB562301828269BD16AAD580C068AC966B45A2B26838162B219ADD68ACD60A0986C46ABB562341828269BD16AAD580D068AC966', 152, 143), +('00618F0A91103254769800001A0500038F020268B556CC066B21CB6C3602747FCB03E410', 35, 143)], + 'SM', + 'GSM'), + ('+0123456789', 'Hello world!\n Hello world!\n Hello world!\n Hello world!\n-> Helló worłd! ', + 4, + datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), + '+2782913593', + [('0061900A91103254769800088C05000390020100480065006C006C006F00200077006F0072006C00640021000A002000480065006C006C006F00200077006F0072006C00640021000A002000480065006C006C006F00200077006F0072006C00640021000A002000480065006C006C006F00200077006F0072006C00640021000A002D003E002000480065006C006C00F300200077006F0072', 152, 144), + ('0061900A91103254769800080E0500039002020142006400210020', 26, 144)], + 'SM', + 'UCS2'), +('+0123456789', '12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345-070 12345-080 12345-090 12345-100 12345-110 12345-120 12345-130 12345-140 12345-150 12345-160-Hello word!', + 5, + datetime(2013, 3, 8, 15, 2, 16, tzinfo=SimpleOffsetTzInfo(2)), + '82913593', + [('0061910A9110325476980000A005000391020162B219ADD682C560A0986C46ABB560321828269BD16A2DD80C068AC966B45A0B46838162B219ADD682D560A0986C46ABB560361828269BD16A2DD80D068AC966B45A0B86838162B219ADD682E560A0986C46ABB562301828269BD16AAD580C068AC966B45A2B26838162B219ADD68ACD60A0986C46ABB562341828269BD16AAD580D068AC966', 152, 145), +('0061910A91103254769800001905000391020268B556CC066B21CB6CF61B747FCBC921', 34, 145)], + 'SM', + 'GSM'),) + + for number, message, index, smsTime, smsc, pdus, mem, encoding in tests: + def writeCallbackFunc(data): + def writeCallbackFunc2(data): + # Second step - get available encoding schemes + self.assertEqual('AT+CSCS=?\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS=?', data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc3 + + def writeCallbackFunc3(data): + # Third step - set encoding + self.assertEqual('AT+CSCS="{0}"\r'.format(encoding), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS="{0}"\r'.format(encoding), data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc4 + + def writeCallbackFunc4(data): + # Fourth step - send PDU length + tpdu_length = pdus[self.currentPdu][1] + ref = pdus[self.currentPdu][2] + self.assertEqual('AT+CMGS={0}\r'.format(tpdu_length), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGS={0}'.format(tpdu_length), data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc5 + self.modem.serial.flushResponseSequence = False + self.modem.serial.responseSequence = ['> \r\n', '+CMGS: {0}\r\n'.format(ref), 'OK\r\n'] + + def writeCallbackFuncRaiseError(data): + self.assertEqual(self.currentPdu, len(pdus) - 1, 'Invalid data written to modem; expected {0} PDUs, got {1} PDU'.format(len(pdus), self.currentPdu + 1)) + + def writeCallbackFunc5(data): + # Fifth step - send SMS PDU + pdu = pdus[self.currentPdu][0] + self.assertEqual('{0}{1}'.format(pdu, chr(26)), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('{0}{1}'.format(pdu, chr(26)), data)) + self.modem.serial.flushResponseSequence = True + self.currentPdu += 1 + if len(pdus) > self.currentPdu: + self.modem.serial.writeCallbackFunc = writeCallbackFunc4 + else: + self.modem.serial.writeCallbackFunc = writeCallbackFuncRaiseError + + # First step - change to PDU mode + self.assertEqual('AT+CMGF=0\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGF=0', data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc2 + self.currentPdu = 0 + self.modem._smsRef = pdus[self.currentPdu][2] + + self.modem.serial.writeCallbackFunc = writeCallbackFunc + self.modem.serial.flushResponseSequence = True + sms = self.modem.sendSms(number, message) + self.assertFalse(self.modem.smsTextMode) + self.assertEqual(self.modem._smsEncoding, encoding, 'Modem uses invalid encoding. Expected "{0}", got "{1}"'.format(encoding, self.modem._smsEncoding)) + self.assertIsInstance(sms, gsmmodem.modem.SentSms) + self.assertEqual(sms.number, number, 'Sent SMS has invalid number. Expected "{0}", got "{1}"'.format(number, sms.number)) + self.assertEqual(sms.text, message, 'Sent SMS has invalid text. Expected "{0}", got "{1}"'.format(message, sms.text)) + self.assertIsInstance(sms.reference, int, 'Sent SMS reference type incorrect. Expected "{0}", got "{1}"'.format(int, type(sms.reference))) + ref = pdus[0][2] # All refference numbers should be equal + self.assertEqual(sms.reference, ref, 'Sent SMS reference incorrect. Expected "{0}", got "{1}"'.format(ref, sms.reference)) + self.assertEqual(sms.status, gsmmodem.modem.SentSms.ENROUTE, 'Sent SMS status should have been {0} ("ENROUTE"), but is: {1}'.format(gsmmodem.modem.SentSms.ENROUTE, sms.status)) + # Reset mode and encoding + self.modem._smsTextMode = True # Set modem to text mode + self.modem._smsEncoding = "GSM" # Set encoding to GSM-7 + self.modem._smsSupportedEncodingNames = None # Force modem to ask about possible encoding names + self.modem.close() + def test_sendSmsTextMode(self): """ Tests sending SMS messages in text mode """ self.initModem(None) @@ -1321,28 +1468,42 @@ def writeCallbackFunc2(data): self.assertEqual(sms.reference, ref, 'Sent SMS reference incorrect. Expected "{0}", got "{1}"'.format(ref, sms.reference)) self.assertEqual(sms.status, gsmmodem.modem.SentSms.ENROUTE, 'Sent SMS status should have been {0} ("ENROUTE"), but is: {1}'.format(gsmmodem.modem.SentSms.ENROUTE, sms.status)) self.modem.close() - + def test_sendSmsPduMode(self): """ Tests sending a SMS messages in PDU mode """ self.initModem(None) self.modem.smsTextMode = False # Set modem to PDU mode + self.modem._smsEncoding = "GSM" self.assertFalse(self.modem.smsTextMode) + self.firstSMS = True for number, message, index, smsTime, smsc, pdu, sms_deliver_tpdu_length, ref, mem in self.tests: self.modem._smsRef = ref calcPdu = gsmmodem.pdu.encodeSmsSubmitPdu(number, message, ref)[0] pduHex = codecs.encode(compat.str(calcPdu.data), 'hex_codec').upper() if PYTHON_VERSION >= 3: pduHex = str(pduHex, 'ascii') - + def writeCallbackFunc(data): + def writeCallbackFuncReadCSCS(data): + self.assertEqual('AT+CSCS=?\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS=?', data)) + self.firstSMS = False + def writeCallbackFunc2(data): + self.assertEqual('AT+CMGS={0}\r'.format(calcPdu.tpduLength), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGS={0}'.format(calcPdu.tpduLength), data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc3 + self.modem.serial.flushResponseSequence = False + self.modem.serial.responseSequence = ['> \r\n', '+CMGS: {0}\r\n'.format(ref), 'OK\r\n'] + + def writeCallbackFunc3(data): self.assertEqual('{0}{1}'.format(pduHex, chr(26)), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('{0}{1}'.format(pduHex, chr(26)), data)) - self.modem.serial.flushResponseSequence = True - self.assertEqual('AT+CMGS={0}\r'.format(calcPdu.tpduLength), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGS={0}'.format(calcPdu.tpduLength), data)) + self.modem.serial.flushResponseSequence = True + + if self.firstSMS: + return writeCallbackFuncReadCSCS(data) + self.assertEqual('AT+CSCS="{0}"\r'.format(self.modem._smsEncoding), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS="{0}"'.format(self.modem._smsEncoding), data)) self.modem.serial.writeCallbackFunc = writeCallbackFunc2 + self.modem.serial.writeCallbackFunc = writeCallbackFunc - self.modem.serial.flushResponseSequence = False - self.modem.serial.responseSequence = ['> \r\n', '+CMGS: {0}\r\n'.format(ref), 'OK\r\n'] sms = self.modem.sendSms(number, message) self.assertIsInstance(sms, gsmmodem.modem.SentSms) self.assertEqual(sms.number, number, 'Sent SMS has invalid number. Expected "{0}", got "{1}"'.format(number, sms.number)) @@ -1351,34 +1512,46 @@ def writeCallbackFunc2(data): self.assertEqual(sms.reference, ref, 'Sent SMS reference incorrect. Expected "{0}", got "{1}"'.format(ref, sms.reference)) self.assertEqual(sms.status, gsmmodem.modem.SentSms.ENROUTE, 'Sent SMS status should have been {0} ("ENROUTE"), but is: {1}'.format(gsmmodem.modem.SentSms.ENROUTE, sms.status)) self.modem.close() - + def test_sendSmsResponseMixedWithUnsolictedMessages(self): """ Tests sending a SMS messages (PDU mode), but with unsolicted messages mixed into the modem responses - the only difference here is that the modem's responseSequence contains unsolicted messages taken from github issue #11 """ self.initModem(None) - self.modem.smsTextMode = False # Set modem to PDU mode + self.modem.smsTextMode = False # Set modem to PDU mode + self.modem._smsEncoding = "GSM" + self.firstSMS = True for number, message, index, smsTime, smsc, pdu, sms_deliver_tpdu_length, ref, mem in self.tests: self.modem._smsRef = ref calcPdu = gsmmodem.pdu.encodeSmsSubmitPdu(number, message, ref)[0] pduHex = codecs.encode(compat.str(calcPdu.data), 'hex_codec').upper() if PYTHON_VERSION >= 3: pduHex = str(pduHex, 'ascii') - + def writeCallbackFunc(data): + def writeCallbackFuncReadCSCS(data): + self.assertEqual('AT+CSCS=?\r', data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS=?', data)) + self.firstSMS = False + def writeCallbackFunc2(data): + self.assertEqual('AT+CMGS={0}\r'.format(calcPdu.tpduLength), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGS={0}'.format(calcPdu.tpduLength), data)) + self.modem.serial.writeCallbackFunc = writeCallbackFunc3 + self.modem.serial.flushResponseSequence = True + # Note thee +ZDONR and +ZPASR unsolicted messages in the "response" + self.modem.serial.responseSequence = ['+ZDONR: "METEOR",272,3,"CS_ONLY","ROAM_OFF"\r\n', '+ZPASR: "UMTS"\r\n', '> \r\n'] + + def writeCallbackFunc3(data): self.assertEqual('{0}{1}'.format(pduHex, chr(26)), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('{0}{1}'.format(pduHex, chr(26)), data)) # Note thee +ZDONR and +ZPASR unsolicted messages in the "response" self.modem.serial.responseSequence = ['+ZDONR: "METEOR",272,3,"CS_ONLY","ROAM_OFF"\r\n', '+ZPASR: "UMTS"\r\n', '+ZDONR: "METEOR",272,3,"CS_PS","ROAM_OFF"\r\n', '+ZPASR: "UMTS"\r\n', '+CMGS: {0}\r\n'.format(ref), 'OK\r\n'] - self.assertEqual('AT+CMGS={0}\r'.format(calcPdu.tpduLength), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CMGS={0}'.format(calcPdu.tpduLength), data)) + + if self.firstSMS: + return writeCallbackFuncReadCSCS(data) + self.assertEqual('AT+CSCS="{0}"\r'.format(self.modem._smsEncoding), data, 'Invalid data written to modem; expected "{0}", got: "{1}"'.format('AT+CSCS="{0}"'.format(self.modem._smsEncoding), data)) self.modem.serial.writeCallbackFunc = writeCallbackFunc2 + self.modem.serial.writeCallbackFunc = writeCallbackFunc - self.modem.serial.flushResponseSequence = True - - # Note thee +ZDONR and +ZPASR unsolicted messages in the "response" - self.modem.serial.responseSequence = ['+ZDONR: "METEOR",272,3,"CS_ONLY","ROAM_OFF"\r\n', '+ZPASR: "UMTS"\r\n', '> \r\n'] - sms = self.modem.sendSms(number, message) self.assertIsInstance(sms, gsmmodem.modem.SentSms) self.assertEqual(sms.number, number, 'Sent SMS has invalid number. Expected "{0}", got "{1}"'.format(number, sms.number)) @@ -2039,6 +2212,49 @@ def writeCallbackFunc3(data): time.sleep(0.1) self.modem.close() + def test_receiveSmsPduMode_invalidPDUsRecordedFromModems(self): + """ Test receiving PDU-mode SMS using data captured from failed operations/bug reports """ + tests = ((['+CMGR: 1,,26\r\n', '0006230E9126983575169498610103409544C26101034095448200\r\n', 'OK\r\n'], # see: babca/python-gsmmodem#15 + Sms.STATUS_RECEIVED_READ, # message read status + '+62895357614989', # number + 35, # reference + datetime(2016, 10, 30, 4, 59, 44, tzinfo=SimpleOffsetTzInfo(8)), # sentTime + datetime(2016, 10, 30, 4, 59, 44, tzinfo=SimpleOffsetTzInfo(7)), # deliverTime + StatusReport.DELIVERED), # delivery status + ) + + callbackDone = [False] + + for modemResponse, msgStatus, number, reference, sentTime, deliverTime, deliveryStatus in tests: + def smsCallbackFunc1(sms): + try: + self.assertIsInstance(sms, gsmmodem.modem.StatusReport) + self.assertEqual(sms.status, msgStatus, 'Status report read status incorrect. Expected: "{0}", got: "{1}"'.format(msgStatus, sms.status)) + self.assertEqual(sms.number, number, 'SMS sender number incorrect. Expected: "{0}", got: "{1}"'.format(number, sms.number)) + self.assertEqual(sms.reference, reference, 'Status report SMS reference number incorrect. Expected: "{0}", got: "{1}"'.format(reference, sms.reference)) + self.assertIsInstance(sms.timeSent, datetime, 'SMS sent time type invalid. Expected: datetime.datetime, got: {0}"'.format(type(sms.timeSent))) + self.assertEqual(sms.timeSent, sentTime, 'SMS sent time incorrect. Expected: "{0}", got: "{1}"'.format(sentTime, sms.timeSent)) + self.assertIsInstance(sms.timeFinalized, datetime, 'SMS finalized time type invalid. Expected: datetime.datetime, got: {0}"'.format(type(sms.timeFinalized))) + self.assertEqual(sms.timeFinalized, deliverTime, 'SMS finalized time incorrect. Expected: "{0}", got: "{1}"'.format(deliverTime, sms.timeFinalized)) + self.assertEqual(sms.deliveryStatus, deliveryStatus, 'SMS delivery status incorrect. Expected: "{0}", got: "{1}"'.format(deliveryStatus, sms.deliveryStatus)) + self.assertEqual(sms.smsc, None, 'This SMS should not have any SMSC information') + finally: + callbackDone[0] = True + + def writeCallback1(data): + if data.startswith('AT+CMGR'): + self.modem.serial.flushResponseSequence = True + self.modem.serial.responseSequence = modemResponse + + self.initModem(smsStatusReportCallback=smsCallbackFunc1) + # Fake a "new message" notification + self.modem.serial.writeCallbackFunc = writeCallback1 + self.modem.serial.flushResponseSequence = True + self.modem.serial.responseSequence = ['+CDSI: "SM",1\r\n'] + # Wait for the handler function to finish + while callbackDone[0] == False: + time.sleep(0.1) + diff --git a/test/test_pdu.py b/test/test_pdu.py index 7f65f1d..f4094e4 100644 --- a/test/test_pdu.py +++ b/test/test_pdu.py @@ -47,7 +47,8 @@ class TestGsm7(unittest.TestCase): """ Tests the GSM-7 encoding/decoding algorithms """ def setUp(self): - self.tests = (('123', bytearray(b'123'), bytearray([49, 217, 12])), + self.tests = (('', bytearray(b''), bytearray([])), + ('123', bytearray(b'123'), bytearray([49, 217, 12])), ('12345678', bytearray(b'12345678'), bytearray([49, 217, 140, 86, 179, 221, 112])), ('123456789', bytearray(b'123456789'), bytearray([49, 217, 140, 86, 179, 221, 112, 57])), ('Hello World!', bytearray([0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64, 0x21]), bytearray([200, 50, 155, 253, 6, 93, 223, 114, 54, 57, 4])), @@ -492,6 +493,33 @@ def test_decode_invalidData(self): pdu = 'AEFDSDFSDFSDFS' self.assertRaises(gsmmodem.exceptions.EncodingError, gsmmodem.pdu.decodeSmsPdu, pdu) + def test_encode_Gsm7_divideSMS(self): + """ Tests whether text will be devided into a correct number of chunks while using GSM-7 alphabet""" + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060" + self.assertEqual(len(gsmmodem.pdu.divideTextGsm7(text)), 1) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345-070 12345-080 12345-090 12345-100 12345-010 12345-020 12345-030 12345-040 12345-050 123" + self.assertEqual(len(gsmmodem.pdu.divideTextGsm7(text)), 1) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345-070 12345-080 12345-090 12345-100 12345-010 12345-020 12345-030 12345-040 12345-050 1234" + self.assertEqual(len(gsmmodem.pdu.divideTextGsm7(text)), 2) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345-070 12345-080 12345-090 12345-100 12345-010 12345-020 12345-030 12345-040 12345-050 12]" + self.assertEqual(len(gsmmodem.pdu.divideTextGsm7(text)), 2) + text = "12345-010,12345-020,12345-030,12345-040,12345-050,12345-060,12345-070,12345-080,12345-090,12345-100,12345-110,12345-120,12345-130,12345-140,12345-150,[[[[[[[[" + self.assertEqual(len(gsmmodem.pdu.divideTextGsm7(text)), 2) + + def test_encode_Ucs2_divideSMS(self): + """ Tests whether text will be devided into a correct number of chunks while using UCS-2 alphabet""" + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 1) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 1234567" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 1) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 12345678" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 2) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 123456[" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 1) + text = "12345-010 12345-020 12345-030 12345-040 12345-050 12345-060 1234567[" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 2) + text = "12345-010,12345-020,12345-030,12345-040,12345-050,12345-060,123456 12345-010,12345-020,12345-030,12345-040,12345-050,12345-060,1234567" + self.assertEqual(len(gsmmodem.pdu.divideTextUcs2(text)), 2) if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/test_util.py b/test/test_util.py index 7e2b0f8..71ecd65 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -9,7 +9,7 @@ from . import compat # For Python 2.6 compatibility -from gsmmodem.util import allLinesMatchingPattern, lineMatching, lineStartingWith, lineMatchingPattern, SimpleOffsetTzInfo +from gsmmodem.util import allLinesMatchingPattern, lineMatching, lineStartingWith, lineMatchingPattern, SimpleOffsetTzInfo, removeAtPrefix class TestUtil(unittest.TestCase): """ Tests misc utilities from gsmmodem.util """ @@ -74,6 +74,13 @@ def test_SimpleOffsetTzInfo(self): self.assertEqual(tz.dst(None), timedelta(0)) self.assertIsInstance(tz.__repr__(), str) + def test_removeAtPrefix(self): + """ Tests function: removeAtPrefix""" + tests = (('AT+CLAC', '+CLAC'), ('ATZ', 'Z'), ('+CLAC', '+CLAC'), ('Z', 'Z')) + for src, dst in tests: + res = removeAtPrefix(src) + self.assertEqual(res, dst) + if __name__ == "__main__": logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) diff --git a/tools/gsmterm.py b/tools/gsmterm.py index 8a86431..3a2acb9 100755 --- a/tools/gsmterm.py +++ b/tools/gsmterm.py @@ -14,33 +14,33 @@ def parseArgs(): """ Argument parser for Python 2.7 and above """ from argparse import ArgumentParser - parser = ArgumentParser(description='User-friendly terminal for interacting with a connected GSM modem.') + parser = ArgumentParser(description='User-friendly terminal for interacting with a connected GSM modem.') parser.add_argument('port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') - parser.add_argument('-r', '--raw', action='store_true', help='switch to raw terminal mode') + parser.add_argument('-r', '--raw', action='store_true', help='switch to raw terminal mode') return parser.parse_args() def parseArgsPy26(): """ Argument parser for Python 2.6 """ - from gsmtermlib.posoptparse import PosOptionParser, Option - parser = PosOptionParser(description='User-friendly terminal for interacting with a connected GSM modem.') + from gsmtermlib.posoptparse import PosOptionParser, Option + parser = PosOptionParser(description='User-friendly terminal for interacting with a connected GSM modem.') parser.add_positional_argument(Option('--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.')) parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') - parser.add_option('-r', '--raw', action='store_true', help='switch to raw terminal mode') + parser.add_option('-r', '--raw', action='store_true', help='switch to raw terminal mode') options, args = parser.parse_args() - if len(args) != 1: + if len(args) != 1: parser.error('Incorrect number of arguments - please specify a PORT to connect to, e.g. {0} /dev/ttyUSB0'.format(sys.argv[0])) else: options.port = args[0] return options - + def main(): args = parseArgsPy26() if sys.version_info[0] == 2 and sys.version_info[1] < 7 else parseArgs() if args.raw: gsmTerm = RawTerm(args.port, args.baud) else: gsmTerm = GsmTerm(args.port, args.baud) - + gsmTerm.start() gsmTerm.rxThread.join() print('Done.') diff --git a/tools/gsmtermlib/atcommands.py b/tools/gsmtermlib/atcommands.py index 7085179..f3baaad 100644 --- a/tools/gsmtermlib/atcommands.py +++ b/tools/gsmtermlib/atcommands.py @@ -124,7 +124,7 @@ 1: registered, home network.\n2: not registered, ME currently searching for a new operator to register to.\n\ 3: registration denied.\n4: unknown.\n5: registered, roaming.'), ('', 'string type; two byte location area code in hexadecimal format'), - ('', 'string type; two byte cell ID in hexadecimal format')), + ('', 'string type; two byte cell ID in hexadecimal format')), 'This command is used by the application to ascertain the registration status of the device.')), ('AT+WOPN', (c[2], 'Read Operator Name')), ('AT+WOPN', (c[2], 'Selection of Preferred PLMN List')), @@ -326,6 +326,30 @@ ('AT+ILRR', (c[7], 'DTE-DCE Local Rate Reporting')), ('AT+CRLP', (c[7], 'Radio Link Protocol Parameters')), ('AT+DOPT', (c[7], 'Radio Link Protocol Parameters')), +('AT+CGDCONT', (c[7], 'Define PDP Context', (('', 'PDP Context Identifier - a numeric parameter (1-32) which specifies a particular \ +PDP context definition. The parameter is local to the TE-MT interface and is used in \ +other PDP context-related commands.'), + ('', 'A string parameter which specifies the type of packet data protocol. (IP, IPV6, PPP, X.25 etc)'), + ('', 'Access Point Name. String parameter; logical name that is used to select the GGSN or external packet data network'), + ('', 'String parameter that identifies the MT in the address space applicable to the PDP. If null/omitted, a dynamic address may be requested.'), + ('', 'PDP data compression. Values:\n\ +0 - off (default)\n\ +1 - on'), + ('', 'PDP header compression. Values:\n\ +0 - off (default)\n\ +1 - on')), None, 'This command specifies the PDP (Packet Data Protocol) context parameter values, such as PDP type (IP, IPV6, PPP, X.25 etc), APN, data compression, header compression, etc.')), +('AT+CGATT', (c[7], 'GPRS attach or detach', (('', 'indicates the state of GPRS attachment:\n\ +0 - detached\n\ +1 - attached\n'),), None, 'The execution command is used to attach the MT to, or detach the MT from, the GPRS\ +service. After the command has completed, the MT remains in V.25ter command state.\n\ +Any active PDP contexts will be automatically deactivated when the attachment state changes to detached.')), + +('AT+CGACT', (c[7], 'PDP context activate or deactivate', (('', 'indicates the state of PDP context activation:\n\ +0 - deactivated\n\ +1 - activated\n'), + ('', 'a numeric parameter which specifies a particular PDP context.')), + None, 'The execution command is used to activate or deactivate the specified PDP context (s).\n\ +After the command has completed, the MT remains in V.25ter command state.')), # Fax ('AT+FTM', (c[8], 'Transmit Speed')), ('AT+FRM', (c[8], 'Receive Speed')), diff --git a/tools/gsmtermlib/posoptparse.py b/tools/gsmtermlib/posoptparse.py index c540f61..8707803 100644 --- a/tools/gsmtermlib/posoptparse.py +++ b/tools/gsmtermlib/posoptparse.py @@ -1,5 +1,5 @@ """ PosOptionParser class gotten from Douglas Mayle at StackOverflow: -http://stackoverflow.com/a/664614/1980416 +http://stackoverflow.com/a/664614/1980416 Used for positional argument support similar to argparse (for Python 2.6 compatibility) """ diff --git a/tools/gsmtermlib/terminal.py b/tools/gsmtermlib/terminal.py index 4bc665d..dcea328 100644 --- a/tools/gsmtermlib/terminal.py +++ b/tools/gsmtermlib/terminal.py @@ -16,13 +16,13 @@ from .trie import Trie from gsmmodem.exceptions import TimeoutException -# first choose a platform dependant way to read single characters from the console +# first choose a platform dependant way to read single characters from the console global console if os.name == 'nt': import msvcrt class Console(object): - + CURSOR_UP = '{0}{1}'.format(chr(0xe0), chr(0x48)) CURSOR_DOWN = '{0}{1}'.format(chr(0xe0), chr(0x50)) CURSOR_LEFT = '{0}{1}'.format(chr(0xe0), chr(0x4b)) @@ -31,7 +31,7 @@ class Console(object): DELETE = '' HOME = '' END = '' - + def __init__(self): pass @@ -59,7 +59,7 @@ def getkey(self): elif os.name == 'posix': import termios, tty class Console(object): - + CURSOR_UP = '{0}{1}{2}'.format(chr(27), chr(91), chr(65)) CURSOR_DOWN = '{0}{1}{2}'.format(chr(27), chr(91), chr(66)) CURSOR_LEFT = '{0}{1}{2}'.format(chr(27), chr(91), chr(68)) @@ -67,7 +67,7 @@ class Console(object): DELETE = '{0}{1}{2}{3}'.format(chr(27), chr(91), chr(51), chr(126)) HOME = '{0}{1}{2}'.format(chr(27), chr(79), chr(72)) END = '{0}{1}{2}'.format(chr(27), chr(79), chr(70)) - + def __init__(self): self.fd = sys.stdin.fileno() @@ -80,14 +80,14 @@ def setup(self): termios.tcsetattr(self.fd, termios.TCSANOW, new) # def setup(self): -# self.oldSettings = termios.tcgetattr(self.fd) -# tty.setraw(self.fd) +# self.oldSettings = termios.tcgetattr(self.fd) +# tty.setraw(self.fd) def getkey(self): c = os.read(self.fd, 4) #print (len(c)) #for a in c: - # print('rx:',ord(a)) + # print('rx:',ord(a)) return c def cleanup(self): @@ -106,24 +106,24 @@ def cleanup_console(): class RawTerm(SerialComms): - """ "Raw" terminal - basically just copies console input to serial, and prints out anything read """ - - EXIT_CHARACTER = '\x1d' # CTRL+] + """ "Raw" terminal - basically just copies console input to serial, and prints out anything read """ + + EXIT_CHARACTER = '\x1d' # CTRL+] WRITE_TERM = '\r' # Write terminator character - + def __init__(self, port, baudrate=9600): super(RawTerm, self).__init__(port, baudrate, notifyCallbackFunc=self._handleModemNotification) self.port = port self.baudrate = baudrate self.echo = True - + def _handleModemNotification(self, lines): for line in lines: print(line) - + def printStartMessage(self): print('\nRaw terminal connected to {0} at {1}bps.\nPress CTRL+] to exit.\n'.format(self.port, self.baudrate)) - + def start(self): self.connect() # Start input thread @@ -131,24 +131,24 @@ def start(self): self.inputThread = threading.Thread(target=self._inputLoop) self.inputThread.daemon = True self.inputThread.start() - self.printStartMessage() - + self.printStartMessage() + def stop(self): self.alive = False if threading.current_thread() != self.inputThread: self.inputThread.join() self.close() - + def _inputLoop(self): """ Loop and copy console->serial until EXIT_CHARCTER character is found. """ try: while self.alive: try: - c = console.getkey() + c = console.getkey() except KeyboardInterrupt: print('kbint') c = serial.to_bytes([3]) - if c == self.EXIT_CHARACTER: + if c == self.EXIT_CHARACTER: self.stop() elif c == '\n': # Convert newline input into \r @@ -169,22 +169,22 @@ def _inputLoop(self): class GsmTerm(RawTerm): """ User-friendly terminal for interacting with a GSM modem. - + Some features: tab-completion, help """ - + PROMPT = 'GSM> ' SMS_PROMPT = '> ' EXIT_CHARACTER_2 = chr(4) # CTRL+D - + BACKSPACE_CHARACTER = chr(127) CTRL_Z_CHARACTER = chr(26) # Used when entering SMS messages with AT+CMGS ESC_CHARACTER = chr(27) # Used to cancel entering SMS messages with AT+CMGS - + RESET_SEQ = '\033[0m' COLOR_SEQ = '\033[1;{0}m' BOLD_SEQ = '\033[1m' - + # ANSI colour escapes COLOR_RED = COLOR_SEQ.format(30+1) COLOR_GREEN = COLOR_SEQ.format(30+2) @@ -200,7 +200,7 @@ def __init__(self, port, baudrate=9600, useColor=True): self.history = [] self.historyPos = 0 self.useColor = useColor - self.cursorPos = 0 + self.cursorPos = 0 if self.useColor: self.PROMPT = self._color(self.COLOR_GREEN, self.PROMPT) self.SMS_PROMPT = self._color(self.COLOR_GREEN, self.SMS_PROMPT) @@ -218,7 +218,7 @@ def _color(self, color, msg): if self.useColor: return '{0}{1}{2}'.format(color, msg, self.RESET_SEQ) else: - return msg + return msg def _boldFace(self, msg): """ Converts a message to be printed to the user's terminal in bold """ @@ -232,32 +232,32 @@ def _handleModemNotification(self, lines): if lines[-1] == 'ERROR': print(self._color(self.COLOR_RED, '\n'.join(lines))) else: - print(self._color(self.COLOR_CYAN, '\n'.join(lines))) + print(self._color(self.COLOR_CYAN, '\n'.join(lines))) self._refreshInputPrompt() - + def _addToHistory(self, command): self.history.append(command) if len(self.history) > 100: self.history = self.history[1:] - + def _inputLoop(self): """ Loop and copy console->serial until EXIT_CHARCTER character is found. """ - + # Switch statement for handling "special" characters actionChars = {self.EXIT_CHARACTER: self._exit, self.EXIT_CHARACTER_2: self._exit, - + console.CURSOR_LEFT: self._cursorLeft, console.CURSOR_RIGHT: self._cursorRight, console.CURSOR_UP: self._cursorUp, console.CURSOR_DOWN: self._cursorDown, - + '\n': self._doConfirmInput, '\t': self._doCommandCompletion, - + self.CTRL_Z_CHARACTER: self._handleCtrlZ, self.ESC_CHARACTER: self._handleEsc, - + self.BACKSPACE_CHARACTER: self._handleBackspace, console.DELETE: self._handleDelete, console.HOME: self._handleHome, @@ -270,7 +270,7 @@ def _inputLoop(self): except KeyboardInterrupt: c = serial.to_bytes([3]) if c in actionChars: - # Handle character directly + # Handle character directly actionChars[c]() elif len(c) == 1 and self._isPrintable(c): self.inputBuffer.insert(self.cursorPos, c) @@ -293,7 +293,7 @@ def _handleCtrlZ(self): self.cursorPos = 0 sys.stdout.write('\n') self._refreshInputPrompt() - + def _handleEsc(self): """ Handler for CTRL+Z keypresses """ if self._typingSms: @@ -305,7 +305,7 @@ def _handleEsc(self): def _exit(self): """ Shuts down the terminal (and app) """ self._removeInputPrompt() - print(self._color(self.COLOR_YELLOW, 'CLOSING TERMINAL...')) + print(self._color(self.COLOR_YELLOW, 'CLOSING TERMINAL...')) self.stop() def _cursorLeft(self): @@ -336,7 +336,7 @@ def _cursorDown(self): if self.historyPos < len(self.history)-1: clearLen = len(self.inputBuffer) self.historyPos += 1 - self.inputBuffer = list(self.history[self.historyPos]) + self.inputBuffer = list(self.history[self.historyPos]) self.cursorPos = len(self.inputBuffer) self._refreshInputPrompt(clearLen) @@ -346,13 +346,13 @@ def _handleBackspace(self): #print( 'cp:',self.cursorPos,'was:', self.inputBuffer) self.inputBuffer = self.inputBuffer[0:self.cursorPos-1] + self.inputBuffer[self.cursorPos:] self.cursorPos -= 1 - #print ('cp:', self.cursorPos,'is:', self.inputBuffer) + #print ('cp:', self.cursorPos,'is:', self.inputBuffer) self._refreshInputPrompt(len(self.inputBuffer)+1) def _handleDelete(self): """ Handles "delete" characters """ if self.cursorPos < len(self.inputBuffer): - self.inputBuffer = self.inputBuffer[0:self.cursorPos] + self.inputBuffer[self.cursorPos+1:] + self.inputBuffer = self.inputBuffer[0:self.cursorPos] + self.inputBuffer[self.cursorPos+1:] self._refreshInputPrompt(len(self.inputBuffer)+1) def _handleHome(self): @@ -374,24 +374,24 @@ def _doConfirmInput(self): self.cursorPos = 0 sys.stdout.write('\n') self._refreshInputPrompt() - return - # Convert newline input into \r\n + return + # Convert newline input into \r\n if len(self.inputBuffer) > 0: inputStr = ''.join(self.inputBuffer).strip() self.inputBuffer = [] self.cursorPos = 0 - inputStrLen = len(inputStr) + inputStrLen = len(inputStr) if len(inputStr) > 0: self._addToHistory(inputStr) self.historyPos = len(self.history) if inputStrLen > 2: if inputStr[0] == '?': # ?COMMAND - # Help requested with function + # Help requested with function self._printCommandHelp(inputStr[1:]) return elif inputStr[-1] == inputStr[-2] == '?': # COMMAND?? # Help requested with function - cmd = inputStr[:-3 if inputStr[-3] == '=' else -2] + cmd = inputStr[:-3 if inputStr[-3] == '=' else -2] self._printCommandHelp(cmd) return inputStrLower = inputStr.lower() @@ -399,9 +399,9 @@ def _doConfirmInput(self): # Alternative help invocation self._printCommandHelp(inputStr[5:]) return - elif inputStrLower.startswith('ls'): + elif inputStrLower.startswith('ls'): if inputStrLower == 'lscat': - sys.stdout.write('\n') + sys.stdout.write('\n') for category in self.completion.categories: sys.stdout.write('{0}\n'.format(category)) self._refreshInputPrompt(len(self.inputBuffer)) @@ -411,9 +411,9 @@ def _doConfirmInput(self): for command in self.completion: sys.stdout.write('{0:<8} - {1}\n'.format(command, self.completion[command][1])) self._refreshInputPrompt(len(self.inputBuffer)) - return + return else: - ls = inputStrLower.split(' ', 1) + ls = inputStrLower.split(' ', 1) if len(ls) == 2: category = ls[1].lower() if category in [cat.lower() for cat in self.completion.categories]: @@ -426,26 +426,26 @@ def _doConfirmInput(self): return elif inputStrLower.startswith('load'): # Load a file containing AT commands to issue - load = inputStr.split(' ', 1) - if len(load) == 2: + load = inputStr.split(' ', 1) + if len(load) == 2: filename = load[1].strip() try: f = open(filename, 'r') except IOError: sys.stdout.write('\n{0}\n'.format(self._color(self.COLOR_RED, 'File not found: "{0}"'.format(filename)))) - self._refreshInputPrompt(len(self.inputBuffer)) + self._refreshInputPrompt(len(self.inputBuffer)) else: atCommands = f.readlines() - f.close() + f.close() sys.stdout.write('\n') - for atCommand in atCommands: + for atCommand in atCommands: atCommand = atCommand.strip() if len(atCommand) > 0 and atCommand[0] != '#': self.inputBuffer = list(atCommand.strip()) self._refreshInputPrompt(len(self.inputBuffer)) self._doConfirmInput() time.sleep(0.1) - return + return if len(inputStr) > 0: if inputStrLower.startswith('at+cmgs='): # Prepare for SMS input @@ -455,11 +455,11 @@ def _doConfirmInput(self): sys.stdout.flush() response = self.write(inputStr + self.WRITE_TERM, waitForResponse=True, timeout=3, expectedResponseTermSeq='> ') except TimeoutException: - self._typingSms = False + self._typingSms = False else: - sys.stdout.write(self._color(self.COLOR_YELLOW, 'Type your SMS message, and press CTRL+Z to send it or press ESC to cancel.\n')) + sys.stdout.write(self._color(self.COLOR_YELLOW, 'Type your SMS message, and press CTRL+Z to send it or press ESC to cancel.\n')) self.SMS_PROMPT = self._color(self.COLOR_GREEN, response[0]) - self._refreshInputPrompt() + self._refreshInputPrompt() return self.serial.write(inputStr) self.serial.write(self.WRITE_TERM) @@ -468,7 +468,7 @@ def _doConfirmInput(self): sys.stdout.flush() def _printGeneralHelp(self): - sys.stdout.write(self._color(self.COLOR_WHITE, '\n\n== GSMTerm Help ==\n\n')) + sys.stdout.write(self._color(self.COLOR_WHITE, '\n\n== GSMTerm Help ==\n\n')) sys.stdout.write('{0} Press the up & down arrow keys to move backwards or forwards through your command history.\n\n'.format(self._color(self.COLOR_YELLOW, 'Command History:'))) sys.stdout.write('{0} Press the TAB key to provide command completion suggestions. Press the TAB key after a command is fully typed (with or without a "=" character) to quickly see its syntax.\n\n'.format(self._color(self.COLOR_YELLOW, 'Command Completion:'))) sys.stdout.write('{0} Type a command, followed with two quesetion marks to access its documentation, e.g. "??". Alternatively, precede the command with a question mark ("?"), or type "help ".\n\n'.format(self._color(self.COLOR_YELLOW, 'Command Documentation:'))) @@ -491,7 +491,7 @@ def _printCommandHelp(self, command=None): noHelp = commandHelp == None if noHelp: sys.stdout.write('\r No help available for: {0}\n'.format(self._color(self.COLOR_WHITE, command))) - else: + else: sys.stdout.write('\n\n{0} ({1})\n\n'.format(self._color(self.COLOR_WHITE, commandHelp[1]), command)) sys.stdout.write('{0} {1}\n'.format(self._color(self.COLOR_YELLOW, 'Category:'), commandHelp[0])) if len(commandHelp) == 2: @@ -499,8 +499,8 @@ def _printCommandHelp(self, command=None): self._refreshInputPrompt(len(self.inputBuffer)) return sys.stdout.write('{0} {1}\n'.format(self._color(self.COLOR_YELLOW, 'Description:'), commandHelp[4])) - - valuesIsEnum = len(commandHelp) >= 6 + + valuesIsEnum = len(commandHelp) >= 6 if valuesIsEnum: # "Values" is an enum of allowed values (not multiple variables); use custom label sys.stdout.write('{0} '.format(self._color(self.COLOR_YELLOW, commandHelp[5]))) else: @@ -513,11 +513,11 @@ def _printCommandHelp(self, command=None): sys.stdout.write('\n') first = True for value, valueDesc in commandValues: - if first: + if first: first = False else: syntax.append(',' if not valuesIsEnum else '|') - syntax.append(self._color(self.COLOR_MAGENTA, value)) + syntax.append(self._color(self.COLOR_MAGENTA, value)) sys.stdout.write(' {0} {1}\n'.format(self._color(self.COLOR_MAGENTA, value), valueDesc.replace('\n', '\n' + ' ' * (len(value) + 2)) if valueDesc != None else '')) else: sys.stdout.write('No parameters.\n') @@ -531,18 +531,18 @@ def _printCommandHelp(self, command=None): self._refreshInputPrompt(len(self.inputBuffer)) def _doCommandCompletion(self): - """ Command-completion method """ + """ Command-completion method """ prefix = ''.join(self.inputBuffer).strip().upper() matches = self.completion.keys(prefix) - matchLen = len(matches) + matchLen = len(matches) if matchLen == 0 and prefix[-1] == '=': - try: + try: command = prefix[:-1] except KeyError: - pass + pass else: self.__printCommandSyntax(command) - elif matchLen > 0: + elif matchLen > 0: if matchLen == 1: if matches[0] == prefix: # User has already entered command - show command syntax @@ -584,10 +584,10 @@ def __printCommandSyntax(self, command): self._refreshInputPrompt(len(self.inputBuffer)) def _isPrintable(self, char): - return 33 <= ord(char) <= 126 or char.isspace() + return 33 <= ord(char) <= 126 or char.isspace() def _refreshInputPrompt(self, clearLen=0): - termPrompt = self.SMS_PROMPT if self._typingSms else self.PROMPT + termPrompt = self.SMS_PROMPT if self._typingSms else self.PROMPT endPoint = clearLen if clearLen > 0 else len(self.inputBuffer) sys.stdout.write('\r{0}{1}{2}{3}'.format(termPrompt, ''.join(self.inputBuffer), (clearLen - len(self.inputBuffer)) * ' ', console.CURSOR_LEFT * (endPoint - self.cursorPos))) sys.stdout.flush() diff --git a/tools/gsmtermlib/trie.py b/tools/gsmtermlib/trie.py index 184a2fd..c63f1db 100644 --- a/tools/gsmtermlib/trie.py +++ b/tools/gsmtermlib/trie.py @@ -13,30 +13,30 @@ class Trie(object): - + def __init__(self, key=None, value=None): - self.slots = {} + self.slots = {} self.key = key self.value = value def __setitem__(self, key, value): - if key == None: + if key == None: raise ValueError('Key may not be None') - + if len(key) == 0: - # All of the original key's chars have been nibbled away + # All of the original key's chars have been nibbled away self.value = value self.key = '' - return - + return + c = key[0] - + if c not in self.slots: # Unused slot - no collision if self.key != None and len(self.key) > 0: # This was a "leaf" previously - create a new branch for its current value branchC = self.key[0] - branchKey = self.key[1:] if len(self.key) > 1 else '' + branchKey = self.key[1:] if len(self.key) > 1 else '' self.slots[branchC] = Trie(branchKey, self.value) self.key = None self.value = None @@ -45,15 +45,15 @@ def __setitem__(self, key, value): else: self.slots[c][key[1:]] = value else: - # Store specified value in a new branch and return + # Store specified value in a new branch and return self.slots[c] = Trie(key[1:], value) else: trie = self.slots[c] - trie[key[1:]] = value + trie[key[1:]] = value def __delitem__(self, key): - if key == None: + if key == None: raise ValueError('Key may not be None') if len(key) == 0: if self.key == '': @@ -75,9 +75,9 @@ def __delitem__(self, key): del trie[key[1:]] else: raise KeyError(key) - + def __getitem__(self, key): - if key == None: + if key == None: raise ValueError('Key may not be None') if len(key) == 0: if self.key == '': @@ -85,7 +85,7 @@ def __getitem__(self, key): return self.value else: raise KeyError(key) - c = key[0] + c = key[0] if c in self.slots: trie = self.slots[c] return trie[key[1:]] @@ -118,13 +118,13 @@ def _allKeys(self, prefix): """ Private implementation method. Use keys() instead. """ global dictItemsIter result = [prefix + self.key] if self.key != None else [] - for key, trie in dictItemsIter(self.slots): - result.extend(trie._allKeys(prefix + key)) + for key, trie in dictItemsIter(self.slots): + result.extend(trie._allKeys(prefix + key)) return result def keys(self, prefix=None): - """ Return all or possible keys in this trie - + """ Return all or possible keys in this trie + If prefix is None, return all keys. If prefix is a string, return all keys that start with this string """ @@ -132,7 +132,7 @@ def keys(self, prefix=None): return self._allKeys('') else: return self._filteredKeys(prefix, '') - + def _filteredKeys(self, key, prefix): global dictKeysIter global dictItemsIter @@ -140,7 +140,7 @@ def _filteredKeys(self, key, prefix): result = [prefix + self.key] if self.key != None else [] for c, trie in dictItemsIter(self.slots): result.extend(trie._allKeys(prefix + c)) - else: + else: c = key[0] if c in dictKeysIter(self.slots): result = [] @@ -178,9 +178,8 @@ def _longestCommonPrefix(self, key, prefix): return self.slots[c]._longestCommonPrefix(key[1:], prefix + c) else: return '' # nothing starts with the specified prefix - + def __iter__(self): for k in list(self.keys()): yield k raise StopIteration - \ No newline at end of file diff --git a/tools/identify-modem.py b/tools/identify-modem.py index b56bdf9..44b7e65 100755 --- a/tools/identify-modem.py +++ b/tools/identify-modem.py @@ -17,23 +17,25 @@ def parseArgs(): """ Argument parser for Python 2.7 and above """ from argparse import ArgumentParser - parser = ArgumentParser(description='Identify and debug attached GSM modem') + parser = ArgumentParser(description='Identify and debug attached GSM modem') parser.add_argument('port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') parser.add_argument('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') - parser.add_argument('-d', '--debug', action='store_true', help='dump modem debug information (for python-gsmmodem development)') + parser.add_argument('-d', '--debug', action='store_true', help='dump modem debug information (for python-gsmmodem development)') + parser.add_argument('-w', '--wait', type=int, default=0, help='Wait for modem to start, in seconds') return parser.parse_args() def parseArgsPy26(): """ Argument parser for Python 2.6 """ - from gsmtermlib.posoptparse import PosOptionParser, Option - parser = PosOptionParser(description='Identify and debug attached GSM modem') + from gsmtermlib.posoptparse import PosOptionParser, Option + parser = PosOptionParser(description='Identify and debug attached GSM modem') parser.add_positional_argument(Option('--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.')) parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') parser.add_option('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') parser.add_option('-d', '--debug', action='store_true', help='dump modem debug information (for python-gsmmodem development)') + parser.add_option('-w', '--wait', type=int, default=0, help='Wait for modem to start, in seconds') options, args = parser.parse_args() - if len(args) != 1: + if len(args) != 1: parser.error('Incorrect number of arguments - please specify a PORT to connect to, e.g. {0} /dev/ttyUSB0'.format(sys.argv[0])) else: options.port = args[0] @@ -42,11 +44,11 @@ def parseArgsPy26(): def main(): args = parseArgsPy26() if sys.version_info[0] == 2 and sys.version_info[1] < 7 else parseArgs() print ('args:',args) - modem = GsmModem(args.port, args.baud) - + modem = GsmModem(args.port, args.baud) + print('Connecting to GSM modem on {0}...'.format(args.port)) try: - modem.connect(args.pin) + modem.connect(args.pin, waitingForModemToStartInSeconds=args.wait) except PinRequiredError: sys.stderr.write('Error: SIM card PIN required. Please specify a PIN with the -p argument.\n') sys.exit(1) diff --git a/tools/sendsms.py b/tools/sendsms.py index 31c42bd..c1082e4 100755 --- a/tools/sendsms.py +++ b/tools/sendsms.py @@ -17,12 +17,17 @@ def parseArgs(): from argparse import ArgumentParser parser = ArgumentParser(description='Simple script for sending SMS messages') parser.add_argument('-i', '--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') + parser.add_argument('-l', '--lock-path', metavar='PATH', help='Use oslo.concurrency to prevent concurrent access to modem') parser.add_argument('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') parser.add_argument('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') - parser.add_argument('-d', '--deliver', action='store_true', help='wait for SMS delivery report') - parser.add_argument('destination', metavar='DESTINATION', help='destination mobile number') + parser.add_argument('-d', '--deliver', action='store_true', help='wait for SMS delivery report') + parser.add_argument('-w', '--wait', type=int, default=0, help='Wait for modem to start, in seconds') + parser.add_argument('--CNMI', default='', help='Set the CNMI of the modem, used for message notifications') + parser.add_argument('--debug', action='store_true', help='turn on debug (serial port dump)') + parser.add_argument('destination', metavar='DESTINATION', help='destination mobile number') + parser.add_argument('message', nargs='?', metavar='MESSAGE', help='message to send, defaults to stdin-prompt') return parser.parse_args() - + def parseArgsPy26(): """ Argument parser for Python 2.6 """ from gsmtermlib.posoptparse import PosOptionParser, Option @@ -30,13 +35,17 @@ def parseArgsPy26(): parser.add_option('-i', '--port', metavar='PORT', help='port to which the GSM modem is connected; a number or a device name.') parser.add_option('-b', '--baud', metavar='BAUDRATE', default=115200, help='set baud rate') parser.add_option('-p', '--pin', metavar='PIN', default=None, help='SIM card PIN') - parser.add_option('-d', '--deliver', action='store_true', help='wait for SMS delivery report') - parser.add_positional_argument(Option('--destination', metavar='DESTINATION', help='destination mobile number')) + parser.add_option('-d', '--deliver', action='store_true', help='wait for SMS delivery report') + parser.add_option('-w', '--wait', type=int, default=0, help='Wait for modem to start, in seconds') + parser.add_option('--CNMI', default='', help='Set the CNMI of the modem, used for message notifications') + parser.add_positional_argument(Option('--destination', metavar='DESTINATION', help='destination mobile number')) options, args = parser.parse_args() - if len(args) != 1: + if len(args) != 1: parser.error('Incorrect number of arguments - please specify a DESTINATION to send to, e.g. {0} 012789456'.format(sys.argv[0])) else: options.destination = args[0] + options.message = None + options.lock_path = None return options def main(): @@ -44,13 +53,29 @@ def main(): if args.port == None: sys.stderr.write('Error: No port specified. Please specify the port to which the GSM modem is connected using the -i argument.\n') sys.exit(1) - modem = GsmModem(args.port, args.baud) - # Uncomment the following line to see what the modem is doing: - #logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + + if args.lock_path is None: + send_sms(args) + else: + try: + from oslo_concurrency import lockutils + except ImportError: + print('oslo_concurrency package is missing') + sys.exit(1) + # apply `lockutils.synchronized` decorator and run + decorator = lockutils.synchronized('python_gsmmodem_sendsms', external=True, lock_path=args.lock_path) + decorator(send_sms)(args) + + +def send_sms(args): + modem = GsmModem(args.port, args.baud, AT_CNMI=args.CNMI) + if args.debug: + # enable dump on serial port + logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) print('Connecting to GSM modem on {0}...'.format(args.port)) try: - modem.connect(args.pin) + modem.connect(args.pin, waitingForModemToStartInSeconds=args.wait) except PinRequiredError: sys.stderr.write('Error: SIM card PIN required. Please specify a PIN with the -p argument.\n') sys.exit(1) @@ -65,8 +90,11 @@ def main(): modem.close() sys.exit(1) else: - print('\nPlease type your message and press enter to send it:') - text = raw_input('> ') + if args.message is None: + print('\nPlease type your message and press enter to send it:') + text = raw_input('> ') + else: + text = args.message if args.deliver: print ('\nSending SMS and waiting for delivery report...') else: