VulpesOS/tundra/devices/beatles/radio_status.py
2026-10-22 20:42:26 +02:00

201 lines
9.4 KiB
Python

# SPDX-License-Identifier: MPL-2.0
"""Read-only Android RIL status for beatles; no PIN, identity, SMS or call requests."""
import json
import os
from pathlib import Path
import select
import socket
import struct
import time
REQUESTS = (1, 19, 20, 21, 22) # SIM status, signal, voice/data registration, operator.
TECHNOLOGIES = {1:'gprs',2:'edge',3:'umts',4:'is95a',5:'is95b',6:'1xrtt',
7:'evdo0',8:'evdoa',9:'hsdpa',10:'hsupa',11:'hspa',12:'evdob',
13:'ehrpd',14:'lte',15:'hspa+',16:'gsm'}
class Parcel:
def __init__(self, data):
self.data, self.offset = data, 0
def integer(self):
if self.offset + 4 > len(self.data): raise ValueError('TRUNCATED_PARCEL')
value, = struct.unpack_from('<i', self.data, self.offset)
self.offset += 4
return value
def string(self):
length = self.integer()
if length == -1: return None
if not 0 <= length <= 4096: raise ValueError('INVALID_STRING_LENGTH')
end = self.offset + length * 2
if end + 2 > len(self.data): raise ValueError('TRUNCATED_STRING')
if self.data[end:end+2] != b'\0\0': raise ValueError('INVALID_TERMINATOR')
value = self.data[self.offset:end].decode('utf-16-le')
self.offset = (end + 2 + 3) & ~3
if self.offset > len(self.data): raise ValueError('TRUNCATED_PADDING')
return value
def strings(self):
count = self.integer()
if not 0 <= count <= 32: raise ValueError('INVALID_ARRAY_LENGTH')
return [self.string() for _ in range(count)]
def registration(values):
state = int(values[0]) if values and values[0] else -1
tech = int(values[3]) if len(values)>3 and values[3] else 0
return {'state':{0:'notSearching',1:'registered',2:'searching',3:'denied',
4:'unknown',5:'registered',10:'notSearching',12:'searching',
13:'denied',14:'unknown'}.get(state,'unknown'),
'connected':state in (1,5), 'roaming':state == 5,
'emergencyCallsOnly':state in (10,12,13,14), 'type':TECHNOLOGIES.get(tech)}
def decode(request, parcel):
if request == 1:
card = parcel.integer(); parcel.integer()
gsm = parcel.integer(); parcel.integer(); parcel.integer()
count = parcel.integer()
if not 0 <= count <= 8: raise ValueError('INVALID_SIM_APPLICATION_COUNT')
app_state = None
for index in range(count):
parcel.integer(); state=parcel.integer(); parcel.integer()
parcel.string(); parcel.string() # AID/label are deliberately discarded.
parcel.integer(); parcel.integer(); parcel.integer()
if index == gsm: app_state=state
return {'sim':{'present':card == 1, 'cardState':
'absent' if card == 0 else {2:'pinRequired',3:'pukRequired',4:'networkLocked',5:'ready'}.get(app_state,'unknown')}}
if request in (20,21):
state=registration(parcel.strings())
# Packet-network registration does not prove that a data bearer is open.
if request == 21: state['connected']=False
return {('voice' if request == 20 else 'data'):state}
if request == 22:
values = parcel.strings()
return {'operator':{'longName':(values[0] or '')[:100] if values else '',
'shortName':(values[1] or '')[:100] if len(values)>1 else ''}}
if request == 19:
# GSM ASU can be unknown on LTE. RIL v6+ stores positive LTE RSRP
# at index 8; normalize its valid 44..140 dBm magnitude for Gaia.
values=[]
while parcel.offset + 4 <= len(parcel.data): values.append(parcel.integer())
if not values: raise ValueError('TRUNCATED_SIGNAL')
asu=values[0]
rsrp=values[8] if len(values)>8 else None
if rsrp is not None and 44<=rsrp<=140:
return {'signal':round((140-rsrp)*100/96)}
return {'signal':round(asu*100/31) if 0<=asu<=31 else None}
raise ValueError('READ_ONLY_REQUEST_REQUIRED')
class Radio:
def __init__(self, path, slot):
self.path,self.slot=path,slot
self.sock=None;self.buffer=b'';self.pending={};self.serial=0
self.last_poll=0;self.retry_at=0;self.retry_delay=5
self.state={'slot':slot,'available':False,'radioState':'unknown'}
def close(self):
if self.sock:self.sock.close()
self.sock=None;self.buffer=b'';self.pending={}
self.state={'slot':self.slot,'available':False,'radioState':'unknown'}
self.retry_at=time.monotonic()+self.retry_delay
self.retry_delay=min(60,self.retry_delay*2)
def connect(self):
connection=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
try:
connection.settimeout(1);connection.connect(self.path)
connection.setblocking(False);self.sock=connection;self.last_poll=0
print('RIL slot',self.slot,'connected',flush=True)
except OSError as error:
print('RIL slot',self.slot,'connect errno',error.errno,flush=True)
connection.close();self.close()
def poll(self):
now=time.monotonic()
if not self.sock:
if now>=self.retry_at:self.connect()
return
# CS fallback and network handover can outlast an individual UI request.
# Keep the transport open for late replies instead of repeatedly filling
# the vendor socket's accept queue with abandoned reconnects.
if any(now-created>45 for _,created in self.pending.values()):
print('RIL slot',self.slot,'request timeout; kinds',sorted({kind for kind,_ in self.pending.values()}),flush=True)
self.close();return
if now-self.last_poll<5:return
self.last_poll=now
for request in REQUESTS:
if any(kind==request for kind,_ in self.pending.values()):continue
self.serial+=1
body=struct.pack('<ii',request,self.serial)
self.sock.sendall(struct.pack('>I',len(body))+body)
self.pending[self.serial]=(request,now)
def receive(self):
data=self.sock.recv(65536)
if not data:raise OSError('RIL_DISCONNECTED')
self.buffer+=data
while len(self.buffer)>=4:
length,=struct.unpack_from('>I',self.buffer)
if not 4<=length<=65536:raise ValueError('INVALID_FRAME_LENGTH')
if len(self.buffer)<length+4:return
parcel=Parcel(self.buffer[4:4+length]);self.buffer=self.buffer[4+length:]
kind=parcel.integer()
if kind == 0:
serial,error=parcel.integer(),parcel.integer()
pending=self.pending.pop(serial,None)
if pending is None:continue
request,_=pending
self.retry_delay=5
self.response(request,error,parcel,serial)
elif kind == 1:
self.unsolicited(parcel.integer(),parcel)
def response(self,request,error,parcel,serial=None):
self.state['available']=True
if error:
self.state.setdefault('errors',{})[str(request)]=error
self.state.pop({1:'sim',19:'signal',20:'voice',21:'data',22:'operator'}[request],None)
return
self.state.setdefault('errors',{}).pop(str(request),None)
self.state.update(decode(request,parcel))
def unsolicited(self,event,parcel):
if event == 1000:
self.state['radioState']={0:'disabled',1:'unavailable',10:'enabled'}.get(parcel.integer(),'unknown')
elif event == 1034:
count=parcel.integer()
if count==1:self.state['rilVersion']=parcel.integer()
elif event in (1002,1009,1019):self.last_poll=0
# The read-only adapter never consumes or acknowledges incoming SMS.
def battery(root=Path('/sys/class/power_supply/battery')):
try:
level=int((root/'capacity').read_text().strip())
status=(root/'status').read_text().strip()
if not 0<=level<=100 or status not in ('Charging','Full','Discharging','Not charging'):return None
return {'level':level/100,'charging':status in ('Charging','Full')}
except (OSError,ValueError):return None
def main():
os.umask(0o022)
destination=Path('/run/tundra-hardware/status.json')
destination.parent.mkdir(parents=True,exist_ok=True)
destination.parent.chmod(0o755)
radios=[Radio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,index) for index,name in enumerate(('rild','rild2'))]
published=0
while True:
for radio in radios:
try:radio.poll()
except (OSError,ValueError) as error:
print('RIL slot',radio.slot,'poll',type(error).__name__,getattr(error,'errno',None),flush=True);radio.close()
active={r.sock:r for r in radios if r.sock}
readable,_,_=select.select(list(active),[],[],.25)
for sock in readable:
try:active[sock].receive()
except (OSError,ValueError,UnicodeError) as error:
print('RIL slot',active[sock].slot,'receive',type(error).__name__,getattr(error,'errno',None),flush=True);active[sock].close()
if time.monotonic()-published<1:continue
published=time.monotonic()
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
'wifi':{'available':False,'state':'unavailable'},'radio':[r.state for r in radios]}
temporary=destination.with_suffix('.tmp')
temporary.write_text(json.dumps(value)+'\n');temporary.chmod(0o644);temporary.replace(destination)
if __name__=='__main__':main()