116 lines
5 KiB
Python
116 lines
5 KiB
Python
# SPDX-License-Identifier: MPL-2.0
|
||
"""GSM SMS transport for the Nokia RIL (3GPP TS 23.040/23.038).
|
||
|
||
Default alphabet and extension characters match Gecko's GsmPDUHelper.
|
||
No binary messages, SIM commands or national-language shift tables are accepted.
|
||
"""
|
||
import datetime
|
||
import re
|
||
|
||
GSM='@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
|
||
EXT={10:'\f',20:'^',40:'{',41:'}',47:'\\',60:'[',61:'~',62:']',64:'|',101:'€'}
|
||
|
||
|
||
def number(value):
|
||
if not isinstance(value,str) or not re.fullmatch(r'\+?[0-9]{3,20}',value):raise ValueError('INVALID_NUMBER')
|
||
return value
|
||
|
||
|
||
def septets(text):
|
||
values=[]
|
||
for char in text:
|
||
if char in GSM and char!='\x1b':values.append(GSM.index(char))
|
||
elif char in EXT.values():values.extend((27,next(k for k,v in EXT.items() if v==char)))
|
||
else:return None
|
||
return values
|
||
|
||
|
||
def pack7(values):
|
||
packed=bytearray((len(values)*7+7)//8)
|
||
for i,value in enumerate(values):
|
||
bit=i*7;packed[bit//8]|=(value<<(bit%8))&255
|
||
if bit%8>1:packed[bit//8+1]|=value>>(8-bit%8)
|
||
return bytes(packed)
|
||
|
||
|
||
def unpack7(data,count,start=0):
|
||
if (start+count)*7>len(data)*8:raise ValueError('TRUNCATED_SMS')
|
||
result=[];escape=False
|
||
for i in range(start,start+count):
|
||
bit=i*7;value=(int.from_bytes(data[bit//8:bit//8+2],'little')>>(bit%8))&127
|
||
if escape:result.append(EXT.get(value,'<EFBFBD>'));escape=False
|
||
elif value==27:escape=True
|
||
else:result.append(GSM[value])
|
||
if escape:raise ValueError('TRUNCATED_SMS_ESCAPE')
|
||
return ''.join(result)
|
||
|
||
|
||
def submit(destination,text):
|
||
number(destination)
|
||
if not isinstance(text,str) or not text or '\x00' in text:raise ValueError('INVALID_SMS')
|
||
values=septets(text)
|
||
if values is not None:
|
||
if len(values)>160:raise ValueError('SMS_TOO_LONG')
|
||
payload=pack7(values);dcs=0;length=len(values)
|
||
else:
|
||
payload=text.encode('utf-16-be');dcs=8;length=len(payload)
|
||
if length>140:raise ValueError('SMS_TOO_LONG')
|
||
digits=destination.lstrip('+');padded=digits+('F' if len(digits)%2 else '')
|
||
address=bytes(int(padded[i+1]+padded[i],16) for i in range(0,len(padded),2))
|
||
# SMS-SUBMIT, no status-report request; SMSC is supplied separately by RIL.
|
||
return (bytes((1,0,len(digits),145 if destination.startswith('+') else 129))+
|
||
address+bytes((0,dcs,length))+payload).hex().upper()
|
||
|
||
|
||
def deliver(hexadecimal):
|
||
if not isinstance(hexadecimal,str) or len(hexadecimal)>1024:raise ValueError('INVALID_SMS_PDU')
|
||
try:data=bytes.fromhex(hexadecimal)
|
||
except ValueError:raise ValueError('INVALID_SMS_PDU') from None
|
||
pos=0
|
||
def take(n):
|
||
nonlocal pos
|
||
if n<0 or pos+n>len(data):raise ValueError('TRUNCATED_SMS')
|
||
part=data[pos:pos+n];pos+=n;return part
|
||
smsc=take(1)[0];take(smsc)
|
||
flags=take(1)[0]
|
||
if flags&3:raise ValueError('UNSUPPORTED_SMS_TYPE')
|
||
digits,toa=take(2);raw=take((digits+1)//2)
|
||
if toa&0x70==0x50:sender=unpack7(raw,digits*4//7)
|
||
else:
|
||
sender=''.join(format(b&15,'x')+format(b>>4,'x') for b in raw)[:digits]
|
||
if not sender.isdigit():raise ValueError('INVALID_SMS_SENDER')
|
||
if toa&0x70==0x10:sender='+'+sender
|
||
pid,dcs=take(2)
|
||
# Reject SIM data download / class-2 / compressed payloads rather than consuming them.
|
||
if pid or dcs not in (0,8):raise ValueError('UNSUPPORTED_SMS_ENCODING')
|
||
stamp=take(7)
|
||
def decimal(b):
|
||
lo,hi=b&15,b>>4
|
||
if lo>9 or hi>9:raise ValueError('INVALID_SMS_DATE')
|
||
return lo*10+hi
|
||
year,month,day,hour,minute,second=map(decimal,stamp[:6])
|
||
quarters=decimal(stamp[6]&0xf7)*(-1 if stamp[6]&8 else 1)
|
||
if abs(quarters)>56:raise ValueError('INVALID_SMS_DATE')
|
||
date=datetime.datetime(2000+year,month,day,hour,minute,second,
|
||
tzinfo=datetime.timezone(datetime.timedelta(minutes=quarters*15)))
|
||
length=take(1)[0];payload=take((length*7+7)//8 if dcs==0 else length)
|
||
header=0;concat=None
|
||
if flags&64:
|
||
if not payload:raise ValueError('TRUNCATED_SMS_HEADER')
|
||
header=payload[0]+1
|
||
if header>len(payload):raise ValueError('TRUNCATED_SMS_HEADER')
|
||
cursor=1
|
||
while cursor<header:
|
||
if cursor+2>header:raise ValueError('TRUNCATED_SMS_HEADER')
|
||
kind,size=payload[cursor:cursor+2];cursor+=2
|
||
part=payload[cursor:cursor+size];cursor+=size
|
||
if cursor>header:raise ValueError('TRUNCATED_SMS_HEADER')
|
||
if kind==0 and size==3:concat=(part[0],part[1],part[2])
|
||
elif kind==8 and size==4:concat=(part[0]*256+part[1],part[2],part[3])
|
||
else:raise ValueError('UNSUPPORTED_SMS_HEADER')
|
||
if concat and not 1<=concat[2]<=concat[1]<=10:raise ValueError('INVALID_SMS_PART')
|
||
if dcs==0:
|
||
skip=(header*8+6)//7
|
||
body=unpack7(payload,length-skip,skip)
|
||
else:body=payload[header:].decode('utf-16-be')
|
||
return {'sender':sender,'body':body,'timestamp':round(date.timestamp()*1000),'concat':concat}
|