├── .gitignore ├── README ├── pymodem ├── __init__.py ├── chat.py ├── gsm_03_38.py ├── hexbuffer.py ├── modem.py └── sms.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.swp 3 | *.pyc 4 | *~ 5 | *.egg 6 | *.egg-info 7 | build 8 | dist 9 | eggs 10 | parts 11 | bin 12 | var 13 | sdist 14 | develop-eggs 15 | .installed.cfg 16 | lib 17 | lib64 18 | *.so 19 | pip-log.txt 20 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | 2 | 3 | Simple Python interface to generic terminal and (specifically) GSM modem 4 | devices. Includes general serial connection/chat, AT command, SMS (including 5 | GSM.03.38) support. 6 | 7 | License: MIT 8 | -------------------------------------------------------------------------------- /pymodem/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | version = "0.2" 3 | description = """ 4 | 5 | Simple Python interface to generic terminal and (specifically) GSM modem 6 | devices. Includes general serial connection/chat, AT command, SMS (including 7 | GSM.03.38) support. 8 | 9 | """ 10 | -------------------------------------------------------------------------------- /pymodem/chat.py: -------------------------------------------------------------------------------- 1 | 2 | import os,re,select,sys,termios,time 3 | 4 | class NotFound(Exception): 5 | pass 6 | 7 | class TTYReader(object): 8 | 9 | def __init__(self,device,speed=termios.B115200): 10 | self.device = device 11 | self.fd = os.open(device, os.O_RDWR|os.O_NONBLOCK|os.O_NOCTTY) 12 | self.buffer = [] 13 | self.tty_setup(speed) 14 | self.user_setup() 15 | 16 | def tty_setup(self,speed): 17 | params = termios.tcgetattr(self.fd) 18 | params[0] = termios.IGNBRK 19 | params[1] = 0 20 | params[2] = (termios.CS8 | termios.CLOCAL | termios.CREAD) 21 | params[3] = 0 22 | params[4] = speed 23 | params[5] = speed 24 | termios.tcsetattr(self.fd, termios.TCSANOW, params) 25 | 26 | def user_setup(self): 27 | pass 28 | 29 | def write(self,data): 30 | os.write(self.fd,data) 31 | 32 | def ready(self,timeout=0): 33 | return select.select([self.fd],[],[],timeout)[0] == [self.fd] 34 | 35 | def interact(self,filter=lambda x:True): 36 | print "Connected: %s" % self.device 37 | while True: 38 | readable = select.select([0,self.fd],[],[])[0] 39 | if 0 in readable: 40 | data = sys.stdin.readline().rstrip() 41 | if data: 42 | self.write(data + "\r\n") 43 | if self.fd in readable: 44 | data = self.readline() 45 | if data and filter(data): 46 | print ">>>", data 47 | 48 | def read(self,n=1): 49 | return os.read(self.fd,n) 50 | 51 | def readline(self,timeout=1): 52 | data = "" 53 | while self.ready(timeout): 54 | data += os.read(self.fd,1) 55 | if data[-2:] == "\r\n": 56 | break 57 | return data[:-2] 58 | 59 | def match(self,f=None,timeout=2): 60 | self.buffer = [] 61 | now = time.time() 62 | #while self.ready(timeout) and (time.time() - now < timeout): 63 | while time.time() - now < timeout: 64 | line = self.readline() 65 | if line: 66 | if f(line): 67 | return line 68 | else: 69 | self.buffer.append(line) 70 | raise NotFound() 71 | 72 | def readlines(self,f=None): 73 | lines = [] 74 | while self.ready(): 75 | lines.append(self.readline()) 76 | return filter(f,lines) 77 | 78 | if __name__ == '__main__': 79 | import optparse 80 | parser = optparse.OptionParser() 81 | parser.add_option("-d", "--device", help="TTY device to open") 82 | parser.add_option("-b", "--speed", help="Speed (Default: 115200)") 83 | options,args = parser.parse_args() 84 | 85 | if options.speed: 86 | try: 87 | speed = termios.__dict__["B%s" % options.speed] 88 | except KeyError: 89 | print "ERROR: Invalid TTY speed - %s" % options.speed 90 | speeds = [ int(x[1:]) for x in dir(termios) if \ 91 | re.search('^B[0-9]*$',x) and int(x[1:]) > 0 ] 92 | speeds.sort() 93 | print "(Valid: %s)" % ",".join(map(str,speeds)) 94 | sys.exit(0) 95 | else: 96 | speed = termios.B115200 97 | 98 | if options.device: 99 | tty = TTYReader(options.device,speed) 100 | print "Opening Device:", options.device 101 | tty.interact() 102 | else: 103 | parser.print_help() 104 | -------------------------------------------------------------------------------- /pymodem/gsm_03_38.py: -------------------------------------------------------------------------------- 1 | 2 | GSM_03_38_ALPHABET = [ 3 | u'@', u'\xa3', u'$', u'\xa5', u'\xe8', u'\xe9', u'\xf9', 4 | u'\xec', u'\xf2', u'\xc7', u'\n', u'\xd8', u'\xf8', u'', 5 | u'\xc5', u'\xe5', u'\u0394', u'_', u'\u03a6', u'\u0393', 6 | u'\u039b', u'\u03a9', u'\u03a0', u'\u03a8', u'\u03a3', 7 | u'\u0398', u'\u039e', u'\x1b', u'\xc6', u'\xe6', u'\xdf', 8 | u'\xc9', u' ', u'!', u'"', u'#', u'\u20ac', u'%', u'&', u"'", 9 | u'(', u')', u'*', u'+', u',', u'-', u'.', u'/', u'0', u'1', 10 | u'2', u'3', u'4', u'5', u'6', u'7', u'8', u'9', u':', u';', 11 | u'<', u'=', u'>', u'?', u'\xa1', u'A', u'B', u'C', u'D', 12 | u'E', u'F', u'G', u'H', u'I', u'J', u'K', u'L', u'M', u'N', 13 | u'O', u'P', u'Q', u'R', u'S', u'T', u'U', u'V', u'W', u'X', 14 | u'Y', u'Z', u'\xc4', u'\xd6', u'\xd1', u'\xdc', u'\xa7', 15 | u'\xbf', u'a', u'b', u'c', u'd', u'e', u'f', u'g', u'h', 16 | u'i', u'j', u'k', u'l', u'm', u'n', u'o', u'p', u'q', u'r', 17 | u's', u't', u'u', u'v', u'w', u'x', u'y', u'z', u'\xe4', 18 | u'\xf6', u'\xf1', u'\xfc', u'\xe0' 19 | ] 20 | 21 | GSM_03_38_ESCAPE = 27 22 | 23 | GSM_03_38_EXTENSION = { 24 | 10: u'\x0c', 20: u'^', 40: u'{', 41: u'}', 47: u'\\', 25 | 60: u'[', 61: u'~', 62: u']', 64: u'|', 101: u'\xa4' 26 | } 27 | 28 | def decode_septets(text): 29 | shift = 0 30 | prev = 0 31 | bytes = [] 32 | encoded = [] 33 | escape = False 34 | for i in range(0,len(text),2): 35 | current = int(text[i:i+2],16) 36 | byte = ((current << shift) + (prev >> (8 - shift))) & 0x7f 37 | bytes.append(byte) 38 | shift = (shift + 1) % 7 39 | if shift == 0: 40 | bytes.append((current >> 1) & 0x7f) 41 | prev = current 42 | for b in bytes: 43 | if b == GSM_03_38_ESCAPE: 44 | escape = True 45 | else: 46 | if escape: 47 | encoded.append(GSM_03_38_EXTENSION.get(b,u' ')) 48 | escape = False 49 | else: 50 | encoded.append(GSM_03_38_ALPHABET[b]) 51 | return u"".join(encoded) 52 | 53 | if __name__ == '__main__': 54 | 55 | while True: 56 | text = raw_input("GSM.03.38 Text >>> ") 57 | print decode_septets(text) 58 | 59 | -------------------------------------------------------------------------------- /pymodem/hexbuffer.py: -------------------------------------------------------------------------------- 1 | 2 | class HexBuffer(object): 3 | 4 | def __init__(self,data,debug=None): 5 | self.data = data 6 | self.debug = debug 7 | self.length = len(data) 8 | self.index = 0 9 | 10 | def __iter__(self): 11 | return self 12 | 13 | def reset(self): 14 | self.index = 0 15 | 16 | def remainder(self,f=""): 17 | start = self.index 18 | self.index = self.length 19 | if self.debug: 20 | self.debug(f,self.data[start:]) 21 | return self.data[start:] 22 | 23 | def peek(self,n=1): 24 | return self.data[self.index:self.index+(2*n)] 25 | 26 | def next(self,n=1,f=""): 27 | start = self.index 28 | end = start + (2 * n) 29 | if end > self.length: 30 | raise StopIteration() 31 | self.index = end 32 | if self.debug: 33 | self.debug(f,self.data[start:end]) 34 | return self.data[start:end] 35 | 36 | def nextInt(self,n=1,f=""): 37 | return int(self.next(n,f),16) 38 | 39 | def nextByte(self,n=1,f=""): 40 | return self.next(n,f).decode('hex') 41 | 42 | def nextArray(self,n=1,f=""): 43 | return [ self.next(1,"%s[%d]" % (f,i)) for i in range(n) ] 44 | -------------------------------------------------------------------------------- /pymodem/modem.py: -------------------------------------------------------------------------------- 1 | 2 | import re,optparse,sys 3 | from sms import SMSDeliver, SMSException 4 | from chat import TTYReader,NotFound 5 | 6 | def pairs(l): 7 | i = iter(l) 8 | while True: 9 | yield(i.next(),i.next()) 10 | 11 | class ATError(Exception): 12 | pass 13 | 14 | class ModemReader(TTYReader): 15 | 16 | def user_setup(self): 17 | self.AT('E0') 18 | self.readlines() 19 | 20 | def sendAT(self,message,timeout=2,check_ok=True): 21 | self.readlines() 22 | if not message.startswith('AT'): 23 | message = 'AT' + message 24 | self.write(message + "\r\n") 25 | if check_ok: 26 | try: 27 | self.match(lambda l:l == "OK",timeout=timeout) 28 | return filter(None,self.buffer) 29 | except(NotFound): 30 | raise ATError(message,filter(None,self.buffer)) 31 | 32 | def sendExtendedAT(self,message,data,timeout=2,check_ok=True): 33 | self.readlines() 34 | if not message.startswith('AT'): 35 | message = 'AT' + message 36 | self.write(message + "\r") 37 | self.ready(1) 38 | self.write(data + "\x1a") 39 | if check_ok: 40 | try: 41 | self.match(lambda l:l == "OK",timeout=timeout) 42 | return filter(None,self.buffer) 43 | except(NotFound): 44 | raise ATError(message,filter(None,self.buffer)) 45 | 46 | def AT(self,command,strip=True): 47 | lines = self.sendAT(command) 48 | if lines: 49 | result = lines[0] 50 | if strip and result.startswith('+'): 51 | return result.split(': ',1)[1] 52 | else: 53 | return result 54 | else: 55 | return None 56 | 57 | class GSMModemReader(ModemReader): 58 | 59 | def user_setup(self): 60 | super(GSMModemReader,self).user_setup() 61 | self.pduMode() 62 | 63 | def getModel(self): 64 | mnfr = self.AT('+CGMI') 65 | model = self.AT('+CGMM') 66 | return "%s %s" % (mnfr.title(),model) 67 | 68 | def getIMEI(self): 69 | return self.AT('+CGSN') 70 | 71 | def getIMSI(self): 72 | return self.AT('+CIMI') 73 | 74 | def getMSISDN(self): 75 | return self.AT('+CNUM').split(",")[1].strip('"') 76 | 77 | def getMode(self): 78 | mode = self.AT("+CMGF?") 79 | return {"0":"PDU","1":"Text"}[mode] 80 | 81 | def textMode(self): 82 | self.sendAT("+CMGF=1") 83 | 84 | def pduMode(self): 85 | self.sendAT("+CMGF=0") 86 | 87 | def checkSMSSupport(self): 88 | try: 89 | service,mt,mo,bm = re.search('(\d,\d,\d,\d)',self.AT('+CSMS?')).group().split(",") 90 | if mt: 91 | return True 92 | else: 93 | return False 94 | except Exception: 95 | return False 96 | 97 | def sendSMS(self,number,message): 98 | if len(message) > 160: 99 | raise ValueError("SMS message too long") 100 | self.textMode() 101 | return self.sendExtendedAT('+CMGS="%s"' % number,message,timeout=10)[0] 102 | 103 | def deleteSMS(self,n): 104 | try: 105 | self.pduMode() 106 | self.AT("AT+CMGD=%d" % n) 107 | return True 108 | except ATError: 109 | return False 110 | 111 | def getSMS(self,n,debug=None): 112 | self.pduMode() 113 | header,tpdu = self.sendAT('+CMGR=%d' % n) 114 | sms = SMSDeliver(debug) 115 | sms.parse(header,tpdu,n) 116 | return sms 117 | 118 | def getSMSList(self,new=True,debug=None): 119 | sms_list = [] 120 | if new: 121 | cmd = '+CMGL=0' 122 | else: 123 | cmd = '+CMGL=4' 124 | try: 125 | self.pduMode() 126 | for header,tpdu in pairs(self.sendAT(cmd)): 127 | try: 128 | sms = SMSDeliver(debug) 129 | sms.parse(header,tpdu) 130 | sms_list.append(sms) 131 | except SMSException, e: 132 | print >>sys.stdout, "Invalid SMS Message:" + header 133 | return sms_list 134 | except (ATError,ValueError): 135 | return None 136 | 137 | if __name__ == '__main__': 138 | import code,optparse 139 | parser = optparse.OptionParser() 140 | parser.add_option("-d", "--device", help="Modem device to open") 141 | options,args = parser.parse_args() 142 | 143 | if options.device: 144 | modem = GSMModemReader(options.device) 145 | print 146 | print "Device:", modem.getModel() 147 | print "MSISDN:", modem.getMSISDN() 148 | print "IMSI: ", modem.getIMSI() 149 | print "IMEI: ", modem.getIMEI() 150 | print 151 | code.interact(banner="Modem device configured as 'modem'\n",local=locals()) 152 | else: 153 | parser.print_help() 154 | -------------------------------------------------------------------------------- /pymodem/sms.py: -------------------------------------------------------------------------------- 1 | 2 | # AT+CMGF=1 (Text Mode) 3 | # AT+CNUM (Phone Num) 4 | # AT+CMGL=4 /"All" (List SMS) 5 | # AT+CMGR=n (Get SMS) 6 | 7 | from datetime import datetime 8 | 9 | from hexbuffer import HexBuffer 10 | from gsm_03_38 import decode_septets 11 | 12 | def decode_number(bytes): 13 | number = [] 14 | for pair in bytes: 15 | number.append(pair[1]) 16 | number.append(pair[0]) 17 | if number[-1].lower() == 'f': 18 | number.pop() 19 | return u"".join(number) 20 | 21 | def decode_timestamp(bytes): 22 | timestamp = [] 23 | for pair in bytes: 24 | timestamp.append(int(pair[1]+pair[0])) 25 | tz = timestamp.pop() 26 | timestamp[0] += 2000 27 | return datetime(*timestamp) 28 | 29 | def get_bits(data,offset,bits=1): 30 | mask = ((1 << bits) - 1) << offset 31 | return (data & mask) >> offset 32 | 33 | def binary(n,count=16,reverse=0): 34 | bits = [str((n >> y) & 1) for y in range(count-1, -1, -1)] 35 | if reverse: 36 | bits.reverse() 37 | return "".join(bits) 38 | 39 | def truncate(s,n,suffix=u"..."): 40 | if len(s) > n: 41 | return s[:n-len(suffix)]+suffix 42 | else: 43 | return s 44 | 45 | class SMSException(Exception): 46 | pass 47 | 48 | class SMSDeliver(object): 49 | 50 | def __init__(self,debug=None): 51 | self.status = 0 52 | self.index = 0 53 | self.address = u"" 54 | self.SCtype = 0 55 | self.SCplan = 0 56 | self.SCnumber = u"" 57 | self.PDUtype = 0 58 | self.OAtype = 0 59 | self.OAplan = 0 60 | self.OAnumber = u"" 61 | self.ProtocolID = 0 62 | self.DataCoding = None 63 | self.TS = 0 64 | self.UD = "" 65 | self.decoded = u"" 66 | self.debug = debug 67 | 68 | def parse(self,header,tpdu,index=0): 69 | self.parseHeader(header,index) 70 | self.parseTPDU(tpdu) 71 | 72 | def parseHeader(self,header,index=0): 73 | (cmd,status) = header.split(None,1) 74 | if cmd == "+CMGL:": 75 | (index,message_status,address_text,tpdu_length) = status.split(",") 76 | elif cmd == "+CMGR:": 77 | (message_status,address_text,tpdu_length) = status.split(",") 78 | else: 79 | raise SMSException("Invalid Message: %s" % cmd) 80 | self.index = int(index) 81 | self.status = int(message_status) 82 | self.address = address_text 83 | 84 | def parseSCAddress(self,buffer): 85 | length = buffer.nextInt(f="SCA Length") 86 | type = buffer.nextInt(f='SCA Type') 87 | self.SCtype = (type >> 4) & 7 88 | self.SCplan = type & 15 89 | if self.SCtype == 5: 90 | self.SCnumber = decode_septets(buffer.next(length-1,f='SCA')) 91 | elif self.SCtype == 1: 92 | self.SCnumber = "+" + decode_number(buffer.nextArray(length-1,f='SCA')) 93 | else: 94 | self.SCnumber = decode_number(buffer.nextArray(length-1,f='SCA')) 95 | 96 | def parsePDU(self,buffer): 97 | self.PDUtype = buffer.nextInt(f='PDU Type') 98 | 99 | def parseOA(self,buffer): 100 | length = sum(divmod(buffer.nextInt(f='OA Length'),2)) 101 | type = buffer.nextInt(f='OA Type') 102 | self.OAtype = (type >> 4) & 7 103 | self.OAplan = type & 15 104 | if self.OAtype == 5: 105 | self.OAnumber = decode_septets(buffer.next(length,f='OA')) 106 | elif self.OAtype == 1: 107 | self.OAnumber = "+" + decode_number(buffer.nextArray(length,f='OA')) 108 | else: 109 | self.OAnumber = decode_number(buffer.nextArray(length,f='OA')) 110 | 111 | def parseProtocol(self,buffer): 112 | self.ProtocolID = buffer.nextInt(f='Protocol') 113 | dcs = buffer.nextInt(f='Protocol') 114 | coding_group = get_bits(dcs,6,2) 115 | if coding_group == 0: 116 | self.DataCoding = dict( 117 | message_class = get_bits(dcs,0,2), 118 | alphabet = get_bits(dcs,2,2), 119 | use_message_class = get_bits(dcs,4,1), 120 | compressed = get_bits(dcs,5,1) 121 | ) 122 | elif coding_group == 2: 123 | # Message Waiting Indication 124 | raise SMSException("Unsupported DCS - Message Waiting Indication") 125 | else: 126 | # Reserved 127 | raise SMSException("Unsupported DCS - Reserved") 128 | 129 | def parseTS(self,buffer): 130 | self.TS = decode_timestamp(buffer.nextArray(7,f='TS')) 131 | 132 | def parseUD(self,buffer): 133 | length = buffer.nextInt(f='UD Length') 134 | self.UD = buffer.remainder(f='UD') 135 | alphabet = self.DataCoding['alphabet'] 136 | if alphabet == 0: 137 | self.decoded = decode_septets(self.UD) 138 | elif alphabet == 1: 139 | self.decoded = self.UD.decode('hex') 140 | elif alphabet == 2: 141 | self.decoded = unicode(self.UD.decode('hex'),'utf-16be') 142 | else: 143 | raise SMSException("Unsupported Alphabet - Reserved") 144 | 145 | def parseTPDU(self,tpdu): 146 | if self.debug: 147 | self.debug("TPDU",tpdu) 148 | buffer = HexBuffer(tpdu,debug=self.debug) 149 | try: 150 | # PDU Type 151 | self.parseSCAddress(buffer) 152 | self.parsePDU(buffer) 153 | self.parseOA(buffer) 154 | self.parseProtocol(buffer) 155 | self.parseTS(buffer) 156 | self.parseUD(buffer) 157 | except StopIteration, ValueError: 158 | raise SMSException("Invalid Message") 159 | 160 | def __str__(self): 161 | return '<>' % (self.index,self.OAnumber.encode('utf8'),truncate(self.decoded,40).encode('utf8')) 162 | 163 | def dump(self): 164 | 165 | properties = [ 'index', 'status', 'address', 'SCtype', 'SCplan', 'SCnumber', 166 | 'PDUtype', 'OAtype', 'OAplan', 'OAnumber', 'ProtocolID', 167 | 'DataCoding', 'TS', 'UD', 'decoded' ] 168 | 169 | print "SMSDeliver" 170 | print "==========" 171 | print 172 | 173 | for x in properties: 174 | if x not in [ 'UD' ]: 175 | print " %12s : %s" % (x,self.__dict__.get(x,"")) 176 | 177 | print 178 | 179 | if __name__ == '__main__': 180 | 181 | import optparse,sys 182 | parser = optparse.OptionParser() 183 | parser.add_option("--debug",action='store_true') 184 | options,args = parser.parse_args() 185 | 186 | def log_data(field,data): 187 | print "++ %s: %s" % (field,data) 188 | 189 | while True: 190 | try: 191 | tpdu = raw_input('SMS Deliver TPDU >>> ') 192 | if tpdu == "": 193 | sys.exit() 194 | sms = SMSDeliver() 195 | if options.debug: 196 | sms.debug = log_data 197 | sms.parseTPDU(tpdu) 198 | sms.dump() 199 | except SMSException, e: 200 | print "** ERROR: SMSException:", e 201 | 202 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | try: 4 | from setuptools import setup, Command 5 | except ImportError: 6 | from distutils.core import Command,setup 7 | 8 | import pymodem 9 | long_description = pymodem.description 10 | version = pymodem.version 11 | 12 | class GenerateReadme(Command): 13 | description = "Generates README file from long_description" 14 | user_options = [] 15 | def initialize_options(self): pass 16 | def finalize_options(self): pass 17 | def run(self): 18 | open("README","w").write(long_description) 19 | 20 | setup(name='pymodem', 21 | version = version, 22 | description = 'Simple Python interface to generic terminal and (specifically) GSM modem devices. Includes general serial connection/chat, AT command, SMS (including GSM.03.38) support.', 23 | long_description = long_description, 24 | author = 'Paul Chakravarti', 25 | author_email = 'paul.chakravarti@gmail.com', 26 | url = 'https://github.com/paulchakravarti/pymodem', 27 | cmdclass = { 'readme' : GenerateReadme }, 28 | packages = ['pymodem'], 29 | license = 'MIT', 30 | classifiers = [ "Topic :: Terminals :: Serial" ] 31 | ) 32 | --------------------------------------------------------------------------------