Release Vulpes OS 2.7-preview.22
This commit is contained in:
parent
200d1fb235
commit
a138f97877
294 changed files with 515786 additions and 187 deletions
331
tundra/devices/beatles/radio_control.py
Normal file
331
tundra/devices/beatles/radio_control.py
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Nokia RIL control broker. Socket clients are Gecko's uid, never web content.
|
||||
|
||||
Parcel layouts follow Gecko 52 dom/system/gonk/ril_worker.js. The vendor
|
||||
socket stays in Android's network namespace; Gecko only sees this broker.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from call_audio import CallAudio
|
||||
from stream_volume import StreamVolume
|
||||
from hardware_control import HardwareControl
|
||||
from radio_status import Radio, battery, REQUESTS
|
||||
from sms_pdu import submit, deliver, number
|
||||
|
||||
# Fixed allowlist excludes every SIM PIN/PUK, identity and provisioning request.
|
||||
CONTROL_REQUESTS={9,10,12,23,25,37,40}
|
||||
SOCKET='/work/phone-control.sock'
|
||||
STORE=Path('/work/phone-private/inbox.json')
|
||||
|
||||
|
||||
def integer(*values):return struct.pack('<'+'i'*len(values),*values)
|
||||
def string(value):
|
||||
if value is None:return integer(-1)
|
||||
raw=value.encode('utf-16-le');body=integer(len(raw)//2)+raw+b'\0\0'
|
||||
return body+b'\0'*((-len(body))%4)
|
||||
|
||||
|
||||
def current_calls(parcel):
|
||||
count=parcel.integer() if parcel.offset<len(parcel.data) else 0
|
||||
if not 0<=count<=16:raise ValueError('INVALID_CALL_LIST')
|
||||
rows=[]
|
||||
for _ in range(count):
|
||||
state,index,toa,multi,incoming,als,voice,privacy=[parcel.integer() for _ in range(8)]
|
||||
num=parcel.string() or '';presentation=parcel.integer()
|
||||
parcel.string();parcel.integer();uus=parcel.integer()
|
||||
if uus:raise ValueError('UNSUPPORTED_CALL_UUS')
|
||||
if state not in range(6) or not 1<=index<=255:raise ValueError('INVALID_CALL_LIST')
|
||||
if voice:
|
||||
if presentation!=0:num=''
|
||||
elif toa==145 and num and not num.startswith('+'):num='+'+num
|
||||
rows.append({'index':index,'state':('active','held','dialing','alerting','incoming','waiting')[state],
|
||||
'number':num[:80],'direction':'incoming' if incoming else 'outgoing'})
|
||||
return rows
|
||||
|
||||
|
||||
class Inbox:
|
||||
def __init__(self,path=STORE):
|
||||
self.path=path
|
||||
self.value=json.loads(path.read_text()) if path.exists() else {'inbox':[],'parts':{},'seen':{}}
|
||||
def save(self):
|
||||
self.path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
self.path.parent.chmod(0o700)
|
||||
temporary=self.path.with_suffix('.tmp')
|
||||
with open(temporary,'w',opener=lambda p,f:os.open(p,f,0o600)) as stream:
|
||||
os.fchmod(stream.fileno(),0o600);json.dump(self.value,stream)
|
||||
stream.flush();os.fsync(stream.fileno())
|
||||
temporary.replace(self.path)
|
||||
fd=os.open(self.path.parent,os.O_DIRECTORY)
|
||||
try:os.fsync(fd)
|
||||
finally:os.close(fd)
|
||||
def receive(self,pdu):
|
||||
previous=copy.deepcopy(self.value)
|
||||
try:self._receive(pdu)
|
||||
except Exception:
|
||||
self.value=previous;raise
|
||||
def _receive(self,pdu):
|
||||
row=deliver(pdu);key=hashlib.sha256(pdu.upper().encode()).hexdigest();now=time.time()
|
||||
if key in self.value['seen']:return
|
||||
if len(self.value['inbox'])>=500:raise ValueError('SMS_STORE_FULL')
|
||||
self.value['seen']={k:v for k,v in self.value['seen'].items() if now-v<7*86400}
|
||||
self.value['parts']={k:v for k,v in self.value['parts'].items() if now-v['created']<86400}
|
||||
concat=row.pop('concat')
|
||||
if concat:
|
||||
ref,total,part=concat
|
||||
group=hashlib.sha256((row['sender']+':'+str(ref)+':'+str(total)).encode()).hexdigest()
|
||||
if group not in self.value['parts'] and len(self.value['parts'])>=100:raise ValueError('SMS_STORE_FULL')
|
||||
record=self.value['parts'].setdefault(group,{'created':now,'pieces':{}})
|
||||
record['pieces'][str(part)]=row['body']
|
||||
if len(record['pieces'])==total:
|
||||
row['body']=''.join(record['pieces'][str(i)] for i in range(1,total+1))
|
||||
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
|
||||
del self.value['parts'][group]
|
||||
else:
|
||||
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
|
||||
self.value['seen'][key]=now;self.save()
|
||||
def acknowledge(self,ids):
|
||||
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)!=36 for i in ids):raise ValueError('INVALID_REQUEST')
|
||||
previous=copy.deepcopy(self.value)
|
||||
try:
|
||||
self.value['inbox']=[r for r in self.value['inbox'] if r['receipt'] not in ids];self.save()
|
||||
except Exception:
|
||||
self.value=previous;raise
|
||||
return True
|
||||
|
||||
|
||||
class ControlRadio(Radio):
|
||||
def __init__(self,path,slot,inbox):
|
||||
super().__init__(path,slot);self.inbox=inbox;self.result=None;self.waiting=None
|
||||
def send(self,request,payload=b''):
|
||||
if request not in CONTROL_REQUESTS:raise ValueError('RADIO_INVALID_REQUEST')
|
||||
if not self.sock:raise RuntimeError('RADIO_NOT_READY')
|
||||
self.serial+=1;body=integer(request,self.serial)+payload
|
||||
self.sock.sendall(struct.pack('>I',len(body))+body)
|
||||
self.pending[self.serial]=(request,time.monotonic())
|
||||
return self.serial
|
||||
def response(self,request,error,parcel,serial=None):
|
||||
if request in REQUESTS:return super().response(request,error,parcel)
|
||||
if request==37:return
|
||||
if (request,serial)!=self.waiting:return
|
||||
if error:self.result=(False,'RADIO_REQUEST_FAILED');return
|
||||
if request==9:
|
||||
try:result=current_calls(parcel)
|
||||
except (ValueError,UnicodeError) as error:
|
||||
print('Call list decode:',str(error),'offset',parcel.offset,'length',len(parcel.data),flush=True)
|
||||
self.result=(False,'RADIO_INVALID_CALL_LIST');return
|
||||
elif request==25:
|
||||
parcel.integer();parcel.string();parcel.integer()
|
||||
result={'state':'sent'}
|
||||
else:result=True
|
||||
self.result=(True,result)
|
||||
def unsolicited(self,event,parcel):
|
||||
if event==1003 and self.slot==0:
|
||||
try:
|
||||
self.inbox.receive(parcel.string());success,cause=1,0
|
||||
except (OSError,ValueError,UnicodeError):
|
||||
# Do not acknowledge success until the message is durably stored.
|
||||
print('Incoming SMS not stored',flush=True);success,cause=0,255
|
||||
self.send(37,integer(2,success,cause))
|
||||
else:super().unsolicited(event,parcel)
|
||||
|
||||
|
||||
class Broker:
|
||||
def __init__(self,radios,inbox,audio=None,volume=None,hardware=None):
|
||||
self.radios=radios;self.inbox=inbox;self.session=uuid.uuid4().hex;self.published=0;self.audio=audio;self.volume=volume;self.hardware=hardware;self.original_power={}
|
||||
def step(self,timeout=.1):
|
||||
for radio in self.radios:
|
||||
try:radio.poll()
|
||||
except (OSError,ValueError):radio.close()
|
||||
sockets={r.sock:r for r in self.radios if r.sock}
|
||||
ready,_,_=select.select(list(sockets),[],[],timeout)
|
||||
for sock in ready:
|
||||
try:sockets[sock].receive()
|
||||
except (OSError,ValueError,UnicodeError) as error:
|
||||
print('RIL receive:',type(error).__name__,str(error),flush=True);sockets[sock].close()
|
||||
if time.monotonic()-self.published>=1:
|
||||
dest=Path('/run/tundra-hardware/status.json');dest.parent.mkdir(exist_ok=True,parents=True)
|
||||
try:wifi=json.loads(Path('/run/tundra-wifi/state.json').read_text())
|
||||
except (OSError,ValueError):wifi={'available':False,'state':'unavailable'}
|
||||
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
|
||||
'wifi':wifi,'radio':[r.state for r in self.radios]}
|
||||
temp=dest.with_suffix('.tmp');temp.write_text(json.dumps(value));temp.chmod(0o644);temp.replace(dest)
|
||||
self.published=time.monotonic()
|
||||
def exchange(self,request,payload=b'',timeout=12,slot=0):
|
||||
radio=self.radios[slot]
|
||||
# A UI timeout does not cancel the request in the vendor daemon. Do not
|
||||
# enqueue more copies while waiting for its reply or transport recovery.
|
||||
if any(kind==request for kind,_ in radio.pending.values()):
|
||||
raise RuntimeError('RADIO_BUSY')
|
||||
if request==9:timeout=min(timeout,3)
|
||||
radio.result=None;radio.waiting=None
|
||||
try:
|
||||
radio.waiting=(request,radio.send(request,payload));until=time.monotonic()+timeout
|
||||
while radio.result is None and time.monotonic()<until:
|
||||
self.step()
|
||||
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
|
||||
if radio.result is None:raise RuntimeError('RADIO_TIMEOUT')
|
||||
success,value=radio.result
|
||||
if not success:raise RuntimeError(value)
|
||||
return value
|
||||
finally:radio.waiting=None
|
||||
def radio_power(self,slot,enabled,remember=True):
|
||||
if type(slot) is not int or slot not in range(len(self.radios)) or type(enabled) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
radio=self.radios[slot]
|
||||
before=radio.state.get('radioState')
|
||||
if before not in ('enabled','disabled'):raise RuntimeError('RADIO_NOT_READY')
|
||||
target='enabled' if enabled else 'disabled'
|
||||
if before==target:return {'slot':slot,'radioState':target}
|
||||
# Do not interrupt an ongoing or incoming call when changing radio power.
|
||||
if not enabled and self.radios[0].state.get('radioState')=='enabled' and self.calls():
|
||||
raise RuntimeError('RADIO_BUSY')
|
||||
if remember:self.original_power.setdefault(slot,before=='enabled')
|
||||
self.exchange(23,integer(1,int(enabled)),slot=slot)
|
||||
deadline=time.monotonic()+8
|
||||
while radio.state.get('radioState')!=target and time.monotonic()<deadline:
|
||||
self.step()
|
||||
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
|
||||
if radio.state.get('radioState')!=target:raise RuntimeError('RADIO_READBACK_FAILED')
|
||||
radio.last_poll=0
|
||||
return {'slot':slot,'radioState':target}
|
||||
|
||||
def restore_power(self):
|
||||
for slot,enabled in self.original_power.items():
|
||||
try:self.radio_power(slot,enabled,remember=False)
|
||||
except (OSError,RuntimeError,ValueError) as error:
|
||||
print('Radio power restore failed:',slot,str(error),flush=True)
|
||||
|
||||
def calls(self):
|
||||
rows=[{**row,'path':'/beatles/'+self.session+'/call/'+str(row['index'])} for row in self.exchange(9)]
|
||||
if self.audio:self.audio.update(rows)
|
||||
return rows
|
||||
def request(self,value):
|
||||
if not isinstance(value,dict) or value.get('version')!=1:raise ValueError('INVALID_REQUEST')
|
||||
op=value.get('operation')
|
||||
if op=='status':return {'ready':self.radios[0].state.get('available',False),'voiceAudio':bool(self.audio and self.audio.available)}
|
||||
if op=='radioPower':return self.radio_power(value.get('slot'),value.get('enabled'))
|
||||
if op=='hardware':
|
||||
if not self.hardware:raise RuntimeError('NOT_SUPPORTED')
|
||||
return self.hardware.request(value.get('settings'))
|
||||
if op=='volumeState':
|
||||
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return self.volume.state()
|
||||
if op=='volume':
|
||||
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return self.volume.request(value)
|
||||
if op=='callAudio':
|
||||
if not self.audio:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return self.audio.request(value.get('settings',{}))
|
||||
if op=='incomingMessages':return self.inbox.value['inbox'][:10]
|
||||
if op=='ackMessages':return self.inbox.acknowledge(value.get('ids'))
|
||||
if op=='calls':return self.calls()
|
||||
if op in ('dial','sms'):
|
||||
destination=number(value.get('number'))
|
||||
if not self.radios[0].state.get('voice',{}).get('connected'):raise RuntimeError('RADIO_NOT_REGISTERED')
|
||||
if op=='sms':return self.exchange(25,integer(2)+string(None)+string(submit(destination,value.get('body'))))
|
||||
before={row['path'] for row in self.calls()}
|
||||
if before:raise RuntimeError('RADIO_BUSY')
|
||||
self.exchange(10,string(destination)+integer(0,0,0))
|
||||
for _ in range(30):
|
||||
for row in self.calls():
|
||||
if row['path'] not in before and row['direction']=='outgoing':return {'path':row['path'],'startedAt':time.time()}
|
||||
self.step(.1)
|
||||
raise RuntimeError('RADIO_TIMEOUT')
|
||||
if op in ('hangup','answer'):
|
||||
path=value.get('path');call=next((r for r in self.calls() if r['path']==path),None)
|
||||
if not call:raise ValueError('INVALID_CALL')
|
||||
if op=='answer':
|
||||
if call['state'] not in ('incoming','waiting'):raise ValueError('INVALID_CALL')
|
||||
self.exchange(40)
|
||||
else:self.exchange(12,integer(1,call['index']))
|
||||
return {'path':path}
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def read_request(conn):
|
||||
data=b''
|
||||
while b'\n' not in data:
|
||||
chunk=conn.recv(4096)
|
||||
if not chunk:break
|
||||
data+=chunk
|
||||
if len(data)>8192:raise ValueError('INVALID_REQUEST')
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def serve():
|
||||
os.umask(0o022);inbox=Inbox()
|
||||
radios=[ControlRadio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,i,inbox) for i,name in enumerate(('rild','rild2'))]
|
||||
audio=CallAudio();volume=StreamVolume();hardware=HardwareControl();broker=Broker(radios,inbox,audio,volume,hardware);running=True
|
||||
def stop(*_):
|
||||
nonlocal running
|
||||
running=False
|
||||
signal.signal(signal.SIGTERM,stop)
|
||||
wifi_log=open('/work/wifi-control.log','w')
|
||||
wifi_process=subprocess.Popen([sys.executable,'/work/wifi_control.py'],stdout=wifi_log,stderr=wifi_log)
|
||||
path=Path(SOCKET);path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
||||
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.setblocking(False)
|
||||
print('Nokia phone broker ready; slot 0; Android 6 audio adapter',flush=True)
|
||||
try:
|
||||
while running:
|
||||
broker.step()
|
||||
try:hardware.tick()
|
||||
except OSError:
|
||||
hardware.pattern=[]
|
||||
print('Nokia vibration control unavailable',flush=True)
|
||||
if not select.select([server],[],[],0)[0]:continue
|
||||
conn,_=server.accept()
|
||||
with conn:
|
||||
conn.settimeout(2)
|
||||
pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
||||
if uid not in (0,2000):continue
|
||||
try:response={'ok':True,'result':broker.request(read_request(conn))}
|
||||
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
|
||||
allowed={'INVALID_REQUEST','INVALID_NUMBER','INVALID_SMS','SMS_TOO_LONG','INVALID_CALL','RADIO_NOT_REGISTERED','RADIO_NOT_READY','RADIO_BUSY','RADIO_REQUEST_FAILED','RADIO_READBACK_FAILED','RADIO_INVALID_CALL_LIST','RADIO_TIMEOUT','NOT_SUPPORTED','CALL_AUDIO_UNAVAILABLE','HARDWARE_READBACK_FAILED'}
|
||||
message=str(error);response={'ok':False,'error':message if message in allowed else 'RADIO_REQUEST_FAILED'}
|
||||
try:conn.sendall(json.dumps(response).encode()+b'\n')
|
||||
except OSError:pass
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
broker.restore_power()
|
||||
wifi_process.terminate()
|
||||
try:wifi_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:wifi_process.kill();wifi_process.wait()
|
||||
wifi_log.close()
|
||||
hardware.restore()
|
||||
volume.restore()
|
||||
audio.restore()
|
||||
for radio in radios:radio.close()
|
||||
|
||||
|
||||
def client():
|
||||
raw=sys.stdin.buffer.read(8193)
|
||||
if len(raw)>8192:raise ValueError('INVALID_REQUEST')
|
||||
value=json.loads(raw)
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
|
||||
conn.settimeout(25);conn.connect(SOCKET)
|
||||
conn.sendall(json.dumps(value).encode()+b'\n')
|
||||
response=b''
|
||||
while b'\n' not in response:
|
||||
chunk=conn.recv(8192)
|
||||
if not chunk:raise RuntimeError('RADIO_INVALID_REPLY')
|
||||
response+=chunk
|
||||
if len(response)>65536:raise RuntimeError('RADIO_INVALID_REPLY')
|
||||
sys.stdout.write(response.decode())
|
||||
|
||||
if __name__=='__main__':
|
||||
if sys.argv[1:]==['--client-envelope']:client()
|
||||
elif not sys.argv[1:]:serve()
|
||||
else:raise SystemExit('Unsupported arguments')
|
||||
Loading…
Reference in a new issue