diff --git a/gsmmodem/models/Call.py b/gsmmodem/models/Call.py new file mode 100644 index 0000000..757f9e2 --- /dev/null +++ b/gsmmodem/models/Call.py @@ -0,0 +1,80 @@ +import weakref + +from gsmmodem.exceptions import CmeError, InterruptedException, InvalidStateException + + +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 + """ + self._gsmModem = weakref.proxy(gsmModem) + self._callStatusUpdateCallbackFunc = callStatusUpdateCallbackFunc + # Unique ID of this call + self.id = callId + # Call type (VOICE == 0, etc) + self.type = callType + # The remote number of this call (destination or origin) + 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 + # (meaning it may be ringing or answered, but not ended because of a hangup event) + self.active = True + + @property + def answered(self): + return self._answered + + @answered.setter + 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) + + 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 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) + 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) + elif e.code == 3: + # Operation not allowed - can happen if call is ended during DTMF transmission + raise InterruptedException('Operation not allowed', e) + else: + 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: + self._gsmModem.write('ATH') + self.answered = False + self.active = False + if self.id in self._gsmModem.activeCalls: + del self._gsmModem.activeCalls[self.id] diff --git a/gsmmodem/models/IncomingCall.py b/gsmmodem/models/IncomingCall.py new file mode 100644 index 0000000..917ef9e --- /dev/null +++ b/gsmmodem/models/IncomingCall.py @@ -0,0 +1,40 @@ +from gsmmodem.modem import Call + + +class IncomingCall(Call): + + CALL_TYPE_MAP = {'VOICE': 0} + + """ 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 + :param number: Caller number + :param ton: TON (type of number/address) in integer format + :param callType: Type of the incoming call (VOICE, FAX, DATA, etc) + """ + 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 + # Flag indicating whether the call is ringing or not + self.ringing = True + # Amount of times this call has rung (before answer/hangup) + self.ringCount = 1 + + def answer(self): + """ 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 + + def hangup(self): + """ End the phone call. """ + self.ringing = False + super(IncomingCall, self).hangup() \ No newline at end of file diff --git a/gsmmodem/models/ReceivedSms.py b/gsmmodem/models/ReceivedSms.py new file mode 100644 index 0000000..60edd23 --- /dev/null +++ b/gsmmodem/models/ReceivedSms.py @@ -0,0 +1,27 @@ +import weakref + +from gsmmodem.models.Sms import Sms + + +class ReceivedSms(Sms): + """ An SMS message that has been received (MT) """ + + def __init__(self, gsmModem, status, number, time, text, smsc=None, udh=[], index=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 + + 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 diff --git a/gsmmodem/models/SentSms.py b/gsmmodem/models/SentSms.py new file mode 100644 index 0000000..ff032c8 --- /dev/null +++ b/gsmmodem/models/SentSms.py @@ -0,0 +1,27 @@ +from gsmmodem.models.Sms import Sms +from gsmmodem.models.StatusReport import StatusReport + + +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 + + 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' + """ + if self.report == None: + return SentSms.ENROUTE + else: + return SentSms.DELIVERED if self.report.deliveryStatus == StatusReport.DELIVERED else SentSms.FAILED diff --git a/gsmmodem/models/Sms.py b/gsmmodem/models/Sms.py new file mode 100644 index 0000000..fc7f711 --- /dev/null +++ b/gsmmodem/models/Sms.py @@ -0,0 +1,24 @@ +import abc + + +class Sms(object): + """ Abstract SMS message base class """ + __metaclass__ = abc.ABCMeta + + # Some constants to ease handling SMS statuses + STATUS_RECEIVED_UNREAD = 0 + STATUS_RECEIVED_READ = 1 + STATUS_STORED_UNSENT = 2 + STATUS_STORED_SENT = 3 + STATUS_ALL = 4 + # ...and a handy converter for text mode statuses + TEXT_MODE_STATUS_MAP = {'REC UNREAD': STATUS_RECEIVED_UNREAD, + 'REC READ': STATUS_RECEIVED_READ, + 'STO UNSENT': STATUS_STORED_UNSENT, + 'STO SENT': STATUS_STORED_SENT, + 'ALL': STATUS_ALL} + + def __init__(self, number, text, smsc=None): + self.number = number + self.text = text + self.smsc = smsc \ No newline at end of file diff --git a/gsmmodem/models/StatusReport.py b/gsmmodem/models/StatusReport.py new file mode 100644 index 0000000..b58a2d6 --- /dev/null +++ b/gsmmodem/models/StatusReport.py @@ -0,0 +1,24 @@ +import weakref + +from gsmmodem.models.Sms import Sms + + +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) + self.status = status + self.reference = reference + self.timeSent = timeSent + self.timeFinalized = timeFinalized + self.deliveryStatus = deliveryStatus diff --git a/gsmmodem/models/__init__.py b/gsmmodem/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gsmmodem/modem.py b/gsmmodem/modem.py index a436172..efacd14 100644 --- a/gsmmodem/modem.py +++ b/gsmmodem/modem.py @@ -2,18 +2,27 @@ """ High-level API classes for an attached GSM modem """ -import sys, re, logging, weakref, time, threading, abc, codecs -from datetime import datetime -from time import sleep +import logging +import re +import sys +import threading +import time +import weakref -from .serial_comms import SerialComms -from .exceptions import CommandError, InvalidStateException, CmeError, CmsError, InterruptedException, TimeoutException, PinRequiredError, IncorrectPinError, SmscNumberUnknownError -from .pdu import encodeSmsSubmitPdu, decodeSmsPdu, encodeGsm7, encodeTextMode -from .util import SimpleOffsetTzInfo, lineStartingWith, allLinesMatchingPattern, parseTextModeTimeStr, removeAtPrefix - -#from . import compat # For Python 2.6 compatibility -from gsmmodem.util import lineMatching from gsmmodem.exceptions import EncodingError +from gsmmodem.util import lineMatching +from .exceptions import CommandError, InvalidStateException, CmeError, CmsError, TimeoutException, PinRequiredError, \ + SmscNumberUnknownError +from .models.Call import Call +from .models.IncomingCall import IncomingCall +from .models.ReceivedSms import ReceivedSms +from .models.SentSms import SentSms +from .models.Sms import Sms +from .models.StatusReport import StatusReport +from .pdu import encodeSmsSubmitPdu, decodeSmsPdu, encodeGsm7, encodeTextMode +from .serial_comms import SerialComms +from .util import lineStartingWith, parseTextModeTimeStr, removeAtPrefix + PYTHON_VERSION = sys.version_info[0] CTRLZ = '\x1a' @@ -23,102 +32,6 @@ if PYTHON_VERSION >= 3: xrange = range dictValuesIter = dict.values dictItemsIter = dict.items -else: #pragma: no cover - dictValuesIter = dict.itervalues - dictItemsIter = dict.iteritems - - -class Sms(object): - """ Abstract SMS message base class """ - __metaclass__ = abc.ABCMeta - - # Some constants to ease handling SMS statuses - STATUS_RECEIVED_UNREAD = 0 - STATUS_RECEIVED_READ = 1 - STATUS_STORED_UNSENT = 2 - STATUS_STORED_SENT = 3 - STATUS_ALL = 4 - # ...and a handy converter for text mode statuses - TEXT_MODE_STATUS_MAP = {'REC UNREAD': STATUS_RECEIVED_UNREAD, - 'REC READ': STATUS_RECEIVED_READ, - 'STO UNSENT': STATUS_STORED_UNSENT, - 'STO SENT': STATUS_STORED_SENT, - 'ALL': STATUS_ALL} - - def __init__(self, number, text, smsc=None): - self.number = number - self.text = text - self.smsc = smsc - - -class ReceivedSms(Sms): - """ An SMS message that has been received (MT) """ - - def __init__(self, gsmModem, status, number, time, text, smsc=None, udh=[], index=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 - - 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 - - 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' - """ - if self.report == None: - return SentSms.ENROUTE - else: - return SentSms.DELIVERED if self.report.deliveryStatus == StatusReport.DELIVERED else SentSms.FAILED - - -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) - self.status = status - self.reference = reference - self.timeSent = timeSent - self.timeFinalized = timeFinalized - self.deliveryStatus = deliveryStatus - class GsmModem(SerialComms): """ Main class for interacting with an attached GSM modem """ @@ -434,7 +347,7 @@ class GsmModem(SerialComms): else: raise PinRequiredError('AT+CPIN') - def write(self, data, waitForResponse=True, timeout=10, parseError=True, writeTerm=TERMINATOR, expectedResponseTermSeq=None): + def write(self, data, waitForResponse=True, timeout:float =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 @@ -1290,7 +1203,7 @@ class GsmModem(SerialComms): call = activeCall call.ringCount += 1 if call == None: - callId = len(self.activeCalls) + 1; + callId = len(self.activeCalls) + 1 call = IncomingCall(self, callerNumber, ton, callerName, callId, callType) self.activeCalls[callId] = call self.incomingCallCallback(call) @@ -1601,121 +1514,6 @@ class GsmModem(SerialComms): if timeLeft <= 0: raise TimeoutException() - -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 - """ - self._gsmModem = weakref.proxy(gsmModem) - self._callStatusUpdateCallbackFunc = callStatusUpdateCallbackFunc - # Unique ID of this call - self.id = callId - # Call type (VOICE == 0, etc) - self.type = callType - # The remote number of this call (destination or origin) - 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 - # (meaning it may be ringing or answered, but not ended because of a hangup event) - self.active = True - - @property - def answered(self): - return self._answered - @answered.setter - 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) - - 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 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) - 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) - elif e.code == 3: - # Operation not allowed - can happen if call is ended during DTMF transmission - raise InterruptedException('Operation not allowed', e) - else: - 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: - self._gsmModem.write('ATH') - self.answered = False - self.active = False - if self.id in self._gsmModem.activeCalls: - del self._gsmModem.activeCalls[self.id] - - -class IncomingCall(Call): - - CALL_TYPE_MAP = {'VOICE': 0} - - """ 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 - :param number: Caller number - :param ton: TON (type of number/address) in integer format - :param callType: Type of the incoming call (VOICE, FAX, DATA, etc) - """ - 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 - # Flag indicating whether the call is ringing or not - self.ringing = True - # Amount of times this call has rung (before answer/hangup) - self.ringCount = 1 - - def answer(self): - """ 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 - - def hangup(self): - """ End the phone call. """ - self.ringing = False - super(IncomingCall, self).hangup() - class Ussd(object): """ Unstructured Supplementary Service Data (USSD) message. diff --git a/gsmmodem/pdu.py b/gsmmodem/pdu.py index a3e28e8..167ce2f 100644 --- a/gsmmodem/pdu.py +++ b/gsmmodem/pdu.py @@ -18,11 +18,6 @@ if PYTHON_VERSION >= 3: unichr = chr toByteArray = lambda x: bytearray(codecs.decode(x, 'hex_codec')) if type(x) == bytes else bytearray(codecs.decode(bytes(x, 'ascii'), 'hex_codec')) if type(x) == str else x rawStrToByteArray = lambda x: bytearray(bytes(x, 'latin-1')) -else: #pragma: no cover - MAX_INT = sys.maxint - dictItemsIter = dict.iteritems - 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