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
6
tundra/devices/beatles/boot/init.vulpes.rc
Normal file
6
tundra/devices/beatles/boot/init.vulpes.rc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Private boot trial: keep the vendor services, replace only the foreground UI.
|
||||
service vulpes-trial /system/bin/sh /vulpes-boot.sh
|
||||
class late_start
|
||||
user root
|
||||
group root
|
||||
oneshot
|
||||
31
tundra/devices/beatles/boot/vulpes-boot.sh
Normal file
31
tundra/devices/beatles/boot/vulpes-boot.sh
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/system/bin/sh
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
# A missing runtime or failed launch must return to the installed KaiOS UI.
|
||||
BASE=/data/local/vulpes-beatles
|
||||
fallback() {
|
||||
/system/bin/start b2g
|
||||
exit 0
|
||||
}
|
||||
count=0
|
||||
while [ "$count" -lt 120 ]; do
|
||||
[ -f "$BASE/disable-boot" ] && fallback
|
||||
[ -f "$BASE/runtime-ready" ] && [ -x "$BASE/rootfs/opt/firefox/firefox" ] &&
|
||||
[ -e /dev/graphics/fb0 ] && [ -S /dev/socket/rilproxy ] && break
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ "$count" -lt 120 ] || fallback
|
||||
rm -f "$BASE/display-session.done" "$BASE/rootfs/work/gaia-audit.json"
|
||||
/sbin/vulpes-display --phone-1800s || fallback
|
||||
# The supervisor has a 30-minute hard deadline. Check startup independently.
|
||||
count=0
|
||||
while [ "$count" -lt 150 ]; do
|
||||
[ -f "$BASE/display-session.done" ] && fallback
|
||||
if [ -s "$BASE/rootfs/work/gaia-audit.json" ]; then
|
||||
/system/bin/busybox grep -q '"readyMs"' "$BASE/rootfs/work/gaia-audit.json" && exit 0
|
||||
fi
|
||||
count=$((count + 1))
|
||||
sleep 1
|
||||
done
|
||||
touch "$BASE/stop-session"
|
||||
fallback
|
||||
71
tundra/devices/beatles/call_audio.py
Normal file
71
tundra/devices/beatles/call_audio.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Android 6 audio services used by the Nokia firmware, outside Gecko.
|
||||
|
||||
Transaction IDs and argument types: AOSP android-6.0.1_r1,
|
||||
frameworks/av/media/libmedia/{IAudioPolicyService,IAudioFlinger}.cpp.
|
||||
This adapter is specific to that ABI. No arbitrary Binder transaction is exposed.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class CallAudio:
|
||||
def __init__(self):
|
||||
self.mode=0;self.muted=False;self.speaker=False;self.available=False
|
||||
self.original=None;self.retry_at=0
|
||||
def transact(self,service,method,*values):
|
||||
command=['/system/bin/service','call','media.audio_'+service,str(method)]
|
||||
for value in values:command.extend(('i32',str(value)))
|
||||
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
|
||||
match=re.fullmatch(r"Result: Parcel\(([0-9a-fA-F]{8})\s+'[^']*'\)\s*",result.stdout)
|
||||
if result.returncode or not match:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return int(match[1],16)
|
||||
def initialize(self):
|
||||
if self.available:return
|
||||
if time.monotonic()<self.retry_at:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
try:
|
||||
muted=self.transact('flinger',18);route=self.transact('policy',6,0)
|
||||
if muted not in (0,1) or route not in range(16):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
if self.original is None:self.original=(muted,route)
|
||||
self.muted=bool(muted);self.speaker=route==1;self.available=True
|
||||
except (OSError,RuntimeError,subprocess.SubprocessError):
|
||||
self.retry_at=time.monotonic()+5;raise RuntimeError('CALL_AUDIO_UNAVAILABLE') from None
|
||||
def set_mode(self,mode):
|
||||
self.initialize()
|
||||
if self.mode==mode:return
|
||||
if self.transact('policy',3,mode):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
self.mode=mode
|
||||
def update(self,calls):
|
||||
mode=2 if any(c['state'] in ('active','held','dialing','alerting') for c in calls) else 1 if calls else 0
|
||||
try:self.set_mode(mode)
|
||||
except (OSError,RuntimeError,subprocess.SubprocessError):
|
||||
self.available=False;self.retry_at=time.monotonic()+5
|
||||
def request(self,value):
|
||||
operation=value.get('operation')
|
||||
if operation not in ('status','set'):raise ValueError('INVALID_REQUEST')
|
||||
for key in ('muted','speaker'):
|
||||
if key in value and type(value[key]) is not bool:raise ValueError('INVALID_REQUEST')
|
||||
self.initialize()
|
||||
if operation=='set':
|
||||
if 'speaker' in value and value['speaker']!=self.speaker:
|
||||
if self.transact('policy',5,0,int(value['speaker'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
actual=self.transact('policy',6,0)
|
||||
if actual!=int(value['speaker']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
self.speaker=value['speaker']
|
||||
if 'muted' in value and value['muted']!=self.muted:
|
||||
if self.transact('flinger',17,int(value['muted'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
if self.transact('flinger',18)!=int(value['muted']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
self.muted=value['muted']
|
||||
return {'muted':self.muted,'speaker':self.speaker,'active':self.mode==2,'available':self.available}
|
||||
def restore(self):
|
||||
if self.original is None:return
|
||||
muted,route=self.original
|
||||
changes=[]
|
||||
if self.mode:changes.append(('policy',3,(0,)))
|
||||
if self.speaker!=(route==1):changes.append(('policy',5,(0,route)))
|
||||
if self.muted!=bool(muted):changes.append(('flinger',17,(muted,)))
|
||||
for service,method,values in changes:
|
||||
try:self.transact(service,method,*values)
|
||||
except (OSError,RuntimeError,subprocess.SubprocessError):pass
|
||||
223
tundra/devices/beatles/cover-display.py
Normal file
223
tundra/devices/beatles/cover-display.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Small, independent Fira clock for the Nokia's RGB565 SPI cover panel."""
|
||||
import ctypes as C
|
||||
import bisect
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import mmap
|
||||
import re
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
value=json.loads(Path(path).read_text())
|
||||
return value if isinstance(value,dict) else {}
|
||||
except (OSError,ValueError):return {}
|
||||
|
||||
|
||||
SETTINGS=Path('/work/gaia-profile/vulpes-settings-v1.json')
|
||||
|
||||
|
||||
class Animation:
|
||||
"""Opt-in private test; decode happens on the PC, not in the phone loop."""
|
||||
FRAME_BYTES=240*240*2
|
||||
def __init__(self,path):
|
||||
self.file=Path(path).open('rb');self.data=None
|
||||
try:
|
||||
size=self.file.seek(0,2)
|
||||
if not 12<=size<=12+240*(4+self.FRAME_BYTES):raise ValueError('Animation size')
|
||||
self.data=mmap.mmap(self.file.fileno(),0,access=mmap.ACCESS_READ)
|
||||
magic,count=struct.unpack_from('<8sI',self.data)
|
||||
if magic!=b'VULPANM1' or not 1<=count<=240 or size!=12+count*(4+self.FRAME_BYTES):
|
||||
raise ValueError('Animation header')
|
||||
self.ends=[];self.offsets=[];total=0
|
||||
for index in range(count):
|
||||
offset=12+index*(4+self.FRAME_BYTES)
|
||||
duration,=struct.unpack_from('<I',self.data,offset)
|
||||
if not 50<=duration<=1000:raise ValueError('Animation timing')
|
||||
total+=duration;self.ends.append(total);self.offsets.append(offset+4)
|
||||
except Exception:
|
||||
self.close();raise
|
||||
|
||||
def frame(self,elapsed,stride):
|
||||
phase=int(elapsed*1000)%self.ends[-1]
|
||||
index=bisect.bisect_right(self.ends,phase);offset=self.offsets[index]
|
||||
raw=self.data[offset:offset+self.FRAME_BYTES]
|
||||
if stride!=480:
|
||||
raw=b''.join(raw[y*480:(y+1)*480]+bytes(stride-480) for y in range(240))
|
||||
return raw,(self.ends[index]-phase)/1000,index
|
||||
|
||||
def close(self):
|
||||
if self.data is not None:self.data.close()
|
||||
self.file.close()
|
||||
|
||||
|
||||
def preferences(values):
|
||||
title=values.get('vulpes.cover.title','VULPES')
|
||||
title=''.join(c for c in title if c.isprintable()).strip()[:24] if isinstance(title,str) else ''
|
||||
def color(key):
|
||||
value=values.get(key,'#33a6ff')
|
||||
if not isinstance(value,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',value):value='#33a6ff'
|
||||
return tuple(int(value[i:i+2],16)/255 for i in (1,3,5))
|
||||
return title or 'VULPES',color('vulpes.cover.titleColor'),color('vulpes.cover.batteryColor')
|
||||
|
||||
|
||||
def labels(state,now):
|
||||
if abs(now-state.get('recordedAt',0))>20:return ('—','—')
|
||||
battery=state.get('battery') or {}
|
||||
level=battery.get('level')
|
||||
percent=str(round(level*100))+' %' if isinstance(level,(int,float)) and 0<=level<=1 else '—'
|
||||
radios=state.get('radio') or []
|
||||
network=next((r.get('operator',{}).get('longName','') for r in radios if r.get('available')), '')
|
||||
return percent,network[:24] or '—'
|
||||
|
||||
|
||||
def present(panel, variable):
|
||||
# The display driver may reject a pan during a panel power transition.
|
||||
# Record the failure and retry later; keep Gaia and the keypad running.
|
||||
try:
|
||||
fcntl.ioctl(panel,0x4606,variable,True)
|
||||
return {'ok':True}
|
||||
except OSError as error:
|
||||
return {'ok':False,'errno':error.errno}
|
||||
|
||||
|
||||
class Drawing:
|
||||
def __init__(self,width,height,stride,font_path='/opt/vulpes/gaia/shared/elements/vulpes-fonts/FiraSans-Regular.otf'):
|
||||
self.c=C.CDLL('libcairo.so.2');self.ft=C.CDLL('libfreetype.so.6')
|
||||
p=C.c_void_p; i=C.c_int; d=C.c_double
|
||||
signatures={
|
||||
'cairo_image_surface_create_for_data':(p,[p,i,i,i,i]),
|
||||
'cairo_create':(p,[p]), 'cairo_destroy':(None,[p]),
|
||||
'cairo_surface_destroy':(None,[p]), 'cairo_surface_flush':(None,[p]),
|
||||
'cairo_surface_write_to_png':(i,[p,C.c_char_p]),
|
||||
'cairo_set_source_rgb':(None,[p,d,d,d]), 'cairo_paint':(None,[p]),
|
||||
'cairo_rectangle':(None,[p,d,d,d,d]), 'cairo_fill':(None,[p]),
|
||||
'cairo_move_to':(None,[p,d,d]), 'cairo_set_font_size':(None,[p,d]),
|
||||
'cairo_show_text':(None,[p,C.c_char_p]), 'cairo_text_extents':(None,[p,C.c_char_p,p]),
|
||||
'cairo_ft_font_face_create_for_ft_face':(p,[p,i]),
|
||||
'cairo_set_font_face':(None,[p,p]),'cairo_font_face_destroy':(None,[p]),
|
||||
'cairo_status':(i,[p]),
|
||||
}
|
||||
for name,(result,args) in signatures.items():
|
||||
fn=getattr(self.c,name);fn.restype=result;fn.argtypes=args
|
||||
self.ft.FT_Init_FreeType.argtypes=[C.POINTER(p)]
|
||||
self.ft.FT_New_Face.argtypes=[p,C.c_char_p,C.c_long,C.POINTER(p)]
|
||||
self.ft.FT_Done_Face.argtypes=[p];self.ft.FT_Done_FreeType.argtypes=[p]
|
||||
self.library=p();self.face=p()
|
||||
if self.ft.FT_Init_FreeType(C.byref(self.library)):raise RuntimeError('FreeType initialization')
|
||||
font=str(font_path).encode()
|
||||
if self.ft.FT_New_Face(self.library,font,0,C.byref(self.face)):raise RuntimeError('Gaia Fira font unavailable')
|
||||
self.buffer=C.create_string_buffer(stride*height)
|
||||
self.surface=self.c.cairo_image_surface_create_for_data(self.buffer,4,width,height,stride)
|
||||
self.context=self.c.cairo_create(self.surface)
|
||||
self.font=self.c.cairo_ft_font_face_create_for_ft_face(self.face,0)
|
||||
self.c.cairo_set_font_face(self.context,self.font)
|
||||
if self.c.cairo_status(self.context):raise RuntimeError('Cairo RGB565 context unavailable')
|
||||
|
||||
def text(self,text,size,y,color=(.92,.96,1)):
|
||||
c=self.c;ctx=self.context;encoded=text.encode()
|
||||
c.cairo_set_font_size(ctx,size)
|
||||
extents=(C.c_double*6)();c.cairo_text_extents(ctx,encoded,extents)
|
||||
# Fit custom titles without cropping wide letters or accented text.
|
||||
if extents[2]>216:
|
||||
c.cairo_set_font_size(ctx,size*216/extents[2])
|
||||
c.cairo_text_extents(ctx,encoded,extents)
|
||||
c.cairo_set_source_rgb(ctx,*color)
|
||||
c.cairo_move_to(ctx,120-extents[2]/2-extents[0],y)
|
||||
c.cairo_show_text(ctx,encoded)
|
||||
|
||||
def render(self,state):
|
||||
c=self.c;ctx=self.context;now=datetime.datetime.now()
|
||||
c.cairo_set_source_rgb(ctx,.015,.025,.045);c.cairo_paint(ctx)
|
||||
title,title_color,battery_color=preferences(read_json(SETTINGS))
|
||||
self.text(title,14,30,title_color)
|
||||
self.text(now.strftime('%H:%M'),56,110)
|
||||
self.text(now.strftime('%d / %m / %Y'),16,143,(.6,.7,.8))
|
||||
battery,network=labels(state,time.time())
|
||||
self.text(network,15,185)
|
||||
self.text(battery,17,218,battery_color)
|
||||
c.cairo_surface_flush(self.surface)
|
||||
if c.cairo_status(ctx):raise RuntimeError('Cover rendering failed')
|
||||
return self.buffer.raw
|
||||
|
||||
def close(self):
|
||||
self.c.cairo_destroy(self.context);self.c.cairo_surface_destroy(self.surface)
|
||||
self.c.cairo_font_face_destroy(self.font);self.ft.FT_Done_Face(self.face)
|
||||
self.ft.FT_Done_FreeType(self.library)
|
||||
|
||||
|
||||
def main():
|
||||
if C.sizeof(C.c_void_p)!=4:raise RuntimeError('Nokia ARM32 framebuffer layout required')
|
||||
running=True
|
||||
def stop(*_):
|
||||
nonlocal running
|
||||
running=False
|
||||
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
|
||||
with open('/dev/fb1','r+b',buffering=0) as panel:
|
||||
variable=bytearray(160);fixed=bytearray(68)
|
||||
fcntl.ioctl(panel,0x4600,variable,True);fcntl.ioctl(panel,0x4602,fixed,True)
|
||||
v=struct.unpack('=40I',variable);stride=struct.unpack_from('=I',fixed,44)[0]
|
||||
length=struct.unpack_from('=I',fixed,20)[0]
|
||||
if (v[0:2]!=(240,240) or v[6]!=16 or v[8:10]!=(11,5) or
|
||||
v[11:13]!=(5,6) or v[14:16]!=(0,5) or v[4]!=0):
|
||||
raise RuntimeError('Unsupported external panel format: '+repr(v[:17]))
|
||||
offset=v[5]*stride;size=240*stride
|
||||
if stride<480 or offset+size>length:raise RuntimeError('Framebuffer bounds invalid')
|
||||
with mmap.mmap(panel.fileno(),length) as pixels:
|
||||
original=pixels[offset:offset+size];drawing=Drawing(240,240,stride)
|
||||
animation=None
|
||||
try:animation=Animation('/work/cover-animation.rgb565')
|
||||
except (OSError,ValueError) as error:
|
||||
if not isinstance(error,FileNotFoundError):print('Cover animation unavailable:',type(error).__name__,flush=True)
|
||||
try:
|
||||
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
|
||||
drawing.c.cairo_surface_write_to_png(drawing.surface,b'/work/cover-preview.png')
|
||||
Path('/work/cover-ready').write_text('ready\n')
|
||||
print('Cover ready: 240x240 RGB565, Gaia Fira Sans',flush=True)
|
||||
last=0;was_on=False;last_error=None;animation_start=None;submitted=0;failed=0;pan_seconds=0
|
||||
while running:
|
||||
on=read_json('/work/lid-state.json').get('externalOn') is True
|
||||
now=time.monotonic()
|
||||
if on and not was_on and animation:
|
||||
animation_start=now;submitted=0;failed=0;pan_seconds=0
|
||||
if on and animation_start is not None:
|
||||
elapsed=now-animation_start
|
||||
if elapsed<8:
|
||||
frame,delay,_=animation.frame(elapsed,stride)
|
||||
pixels[offset:offset+size]=frame
|
||||
before=time.monotonic();result=present(panel,variable)
|
||||
pan_seconds+=time.monotonic()-before
|
||||
if result['ok']:submitted+=1
|
||||
else:failed+=1
|
||||
was_on=on;last=0
|
||||
time.sleep(max(.005,delay-(time.monotonic()-now)))
|
||||
continue
|
||||
Path('/work/cover-animation-report.json').write_text(json.dumps({
|
||||
'seconds':elapsed,'submissions':submitted,'failed':failed,
|
||||
'submissionsPerSecond':submitted/elapsed,
|
||||
'meanPanMs':1000*pan_seconds/max(1,submitted+failed)}))
|
||||
animation_start=None;last=0
|
||||
if not on:animation_start=None
|
||||
if on and (not was_on or time.monotonic()-last>=5):
|
||||
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
|
||||
result=present(panel,variable)
|
||||
Path('/work/cover-refresh.json').write_text(json.dumps({**result,'recordedAt':time.time()}))
|
||||
if not result['ok'] and result['errno']!=last_error:
|
||||
print('Cover refresh deferred, errno:',result['errno'],flush=True)
|
||||
if result['ok'] and last_error is not None:print('Cover refresh recovered',flush=True)
|
||||
last_error=result.get('errno')
|
||||
last=time.monotonic()
|
||||
was_on=on;time.sleep(.2)
|
||||
finally:
|
||||
Path('/work/cover-ready').unlink(missing_ok=True)
|
||||
pixels[offset:offset+size]=original;drawing.close()
|
||||
if animation:animation.close()
|
||||
|
||||
if __name__=='__main__':main()
|
||||
200
tundra/devices/beatles/device.json
Normal file
200
tundra/devices/beatles/device.json
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"device": "beatles",
|
||||
"name": "Nokia 2720 Flip",
|
||||
"architecture": "armv7",
|
||||
"abi": "armeabi-v7a",
|
||||
"soc": "MSM8905",
|
||||
"board": "msm8909",
|
||||
"taVariant": null,
|
||||
"observedFirmware": {
|
||||
"kaios": "2.5.2.2",
|
||||
"gecko": "48.0a2",
|
||||
"build": "40.00.17.01",
|
||||
"androidHardwareBase": "6.0.1",
|
||||
"androidApi": 23,
|
||||
"kernel": "3.10.49-g8801858442f"
|
||||
},
|
||||
"display": {
|
||||
"internal": {
|
||||
"width": 240,
|
||||
"height": 320
|
||||
},
|
||||
"external": {
|
||||
"width": 240,
|
||||
"height": 240
|
||||
},
|
||||
"touchscreen": false,
|
||||
"clamshell": {
|
||||
"sysfs": "/sys/devices/soc.0/hall_sensor1.73/hall_status",
|
||||
"rawOpen": 0,
|
||||
"rawClosed": 1,
|
||||
"evidence": "Physical inversion reported by owner on 2026-10-18; software polarity corrected"
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"kernelVisibleKiB": 409248,
|
||||
"zramSwapKiB": 262140
|
||||
},
|
||||
"access": {
|
||||
"adb": true,
|
||||
"adbUid": 2000,
|
||||
"debuggerChromeProcessUid": 0,
|
||||
"bootloaderUnlocked": null
|
||||
},
|
||||
"validation": {
|
||||
"nativeArmv7UserspaceProbe": true,
|
||||
"diagnosticAppOnExistingKaiOS": true,
|
||||
"newGeckoRuntime": true,
|
||||
"gaiaBoot": true,
|
||||
"physicalKeys": true,
|
||||
"customBootImage": true,
|
||||
"bootPartitionBackupsVerified": true,
|
||||
"restorationTested": false,
|
||||
"physicalKeysEvidence": "Owner reports diagnostic works well, 2026-10-17; no exhaustive keypad matrix qualification.",
|
||||
"predictiveT9DictionariesBuilt": true,
|
||||
"predictiveT9DictionaryLanguages": 42,
|
||||
"predictiveT9PhysicalQualification": false,
|
||||
"newGeckoOffscreenHtmlCssJs": true,
|
||||
"newGeckoInteractiveDisplay": true,
|
||||
"nativeXorgFramebuffer": true,
|
||||
"framebufferCaptureVerified": true,
|
||||
"nativeKeypadEventQualification": true,
|
||||
"nativeRilStatus": true,
|
||||
"nativeBatteryStatus": true,
|
||||
"gaiaSimAndSignalStatus": true,
|
||||
"nativeSms": true,
|
||||
"nativeCalls": true,
|
||||
"kernelEvdevKeypadQualification": true,
|
||||
"nativePhoneBroker": true,
|
||||
"nativeCallList": true,
|
||||
"nativeAudioServiceAccess": true,
|
||||
"nativeSmsCodecTests": true,
|
||||
"externalDisplayRendered": true,
|
||||
"physicalClamshellCycle": true,
|
||||
"externalDisplayRefresh": true,
|
||||
"nativeCallAudio": true,
|
||||
"userQualification": "2026-10-18: owner confirms call audio in both directions and SMS; previous confirmation of clamshell/backlight operation. Not an exhaustive reliability test.",
|
||||
"customBootImageBooted": false,
|
||||
"persistentRuntime": true,
|
||||
"persistentRuntimeSurvivedReboot": true,
|
||||
"nativeVolumeControls": true,
|
||||
"nativeBacklightControl": true,
|
||||
"boundedVibration": true,
|
||||
"nativeRadioPower": true,
|
||||
"gaiaAirplaneMode": true,
|
||||
"airplaneModeQualification": "tickets/evidence-2026-10-20/nokia-airplane.json"
|
||||
},
|
||||
"flashReady": false,
|
||||
"evidence": [
|
||||
"logs/nokia2720-inventory-20261017/inventory.json",
|
||||
"tundra/out/beatles/armv7-smoke/device-test.json",
|
||||
"tundra/out/beatles/display-diagnostic/install-report.json",
|
||||
"tundra/out/beatles/backups/20261017/verification.json",
|
||||
"tundra/out/beatles/gecko-armhf/version-result.json",
|
||||
"tundra/out/beatles/gecko-armhf/render-result.json",
|
||||
"tundra/out/beatles/gecko-armhf/gecko.png",
|
||||
"tundra/out/beatles/gecko-156.0.1/engine-staging.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/version-result.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/render-result.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205052/gaia-audit.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/framebuffer-home-156.png",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205433/gaia-audit.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/qualification.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-230112/gaia-audit.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-233954/gaia-audit.json",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105254/camera-frame.png",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-photo.jpg",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-frame-encoded.jpg",
|
||||
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-110735/display-session.log"
|
||||
],
|
||||
"experimentalEngine": {
|
||||
"version": "156.0.1",
|
||||
"architecture": "armv7 / hard-float",
|
||||
"source": "Mozilla release sources, locally cross-compiled",
|
||||
"sourceRevision": "6f2c158dfc7e9693f880fad2510ceb51a158c069",
|
||||
"sourcePatches": [],
|
||||
"mode": "shared Gaia, Xorg/fbdev software display, bounded private session with Nokia radio and Wi-Fi adapters"
|
||||
},
|
||||
"limitations": [
|
||||
"KaiOS kernel and existing firmware; no standalone boot image",
|
||||
"KaiOS stock Camera is confirmed working by the user; Vulpes Gecko 156 still has no beatles camera adapter. The standalone QCamera diagnostic returned empty frames, so its output is not evidence of a sensor or kernel fault",
|
||||
"Software framebuffer around 11 submissions/second; no GPU acceleration",
|
||||
"Multi-tap input qualified; offline predictive T9 is implemented for 42 Gaia keyboard locales, pending physical qualification",
|
||||
"Gecko runs as uid 2000; root watchdog and Xorg restore KaiOS after each trial",
|
||||
"First SIM only; no data bearer, MMS or camera adapter; sustained radio reliability and incoming call lifecycle still need qualification",
|
||||
"External display refresh retries driver refusals during panel transitions",
|
||||
"Bluetooth firmware backend exists, but Vulpes has no Nokia adapter; pairing and profiles are not available",
|
||||
"Predictive T9 lexicons are generated from Gaia keyboard wordlists; 12,000 frequent words per locale, loaded on demand. User-dictionary learning is not implemented"
|
||||
],
|
||||
"privateBootBuild": {
|
||||
"path": "tundra/out/beatles/boot-trial-20261018-r5",
|
||||
"signed": false,
|
||||
"bootTested": false,
|
||||
"standalone": false,
|
||||
"fastbootDetected": false,
|
||||
"bootloaderLockState": null
|
||||
},
|
||||
"hardwareInventory": {
|
||||
"camera": {
|
||||
"count": 1,
|
||||
"facing": "back",
|
||||
"orientation": 270,
|
||||
"moduleApi": "1.0",
|
||||
"gaiaAdapter": false,
|
||||
"maxPictureSize": [
|
||||
1600,
|
||||
1200
|
||||
],
|
||||
"focusModes": [
|
||||
"fixed"
|
||||
],
|
||||
"maxDigitalZoom": 6.06,
|
||||
"firmwareOpenReleaseVerified": true,
|
||||
"advertisedFlashModes": [
|
||||
"off",
|
||||
"auto",
|
||||
"on",
|
||||
"torch"
|
||||
],
|
||||
"flashPhysicallyVerified": false,
|
||||
"temporaryProbe": {
|
||||
"date": "2026-10-21",
|
||||
"backend": "mm-qcamera-app as Android camera uid 1006",
|
||||
"previewUi": "QCamera preview starts; frames are not yet delivered to Gaia",
|
||||
"snapshotYuv": "NV21 1280x960 captured and visually decoded",
|
||||
"snapshotJpeg": "invalid for use; nearly uniform gray/black",
|
||||
"gaiaAdapter": false,
|
||||
"evidence": "tickets/2026-10-21.md",
|
||||
"cameraDaemon": "qcamerasvr running; /system/bin/mm-qcamera-daemon (Android camera UID) keeps /dev/video0 busy after B2G stops",
|
||||
"v4l2Direct": "unavailable; Qualcomm interface client required"
|
||||
}
|
||||
},
|
||||
"volumeKeys": {
|
||||
"up": "event3/115",
|
||||
"down": "event2/114"
|
||||
},
|
||||
"sensors": {
|
||||
"modulePresent": true,
|
||||
"enumeratedAsShell": 0,
|
||||
"enumeratedAsRoot": 0,
|
||||
"automaticBrightnessAvailable": false,
|
||||
"accelerometerAvailable": false
|
||||
},
|
||||
"wifi": {
|
||||
"interface": "wlan0",
|
||||
"controlSocket": "wpa_wlan0",
|
||||
"gaiaAdapter": true,
|
||||
"qualification": "tickets/evidence-2026-10-20/nokia-wifi.json"
|
||||
},
|
||||
"evidence": "tundra/out/beatles/hardware-inventory-20261018",
|
||||
"qualification": "tundra/out/beatles/hardware-qualification-20261018/controls-final.json",
|
||||
"bluetooth": {
|
||||
"firmwareBackend": "bluetoothd",
|
||||
"firmwareDaemonPresent": true,
|
||||
"firmwareServiceRunning": false,
|
||||
"vulpesAdapter": false,
|
||||
"evidence": "tickets/evidence-2026-10-20/nokia-media-stack.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
tundra/devices/beatles/diagnostic-app/app.js
Normal file
21
tundra/devices/beatles/diagnostic-app/app.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
(function () {
|
||||
'use strict';
|
||||
var pages = document.querySelectorAll('.page');
|
||||
var current = 0;
|
||||
function show(index) {
|
||||
current = (index + pages.length) % pages.length;
|
||||
for (var i = 0; i < pages.length; i++) {
|
||||
pages[i].classList.toggle('active', i === current);
|
||||
}
|
||||
document.getElementById('position').textContent = (current + 1) + ' / ' + pages.length;
|
||||
}
|
||||
document.addEventListener('keydown', function (event) {
|
||||
document.getElementById('last-key').textContent = event.key || String(event.keyCode);
|
||||
if (event.key === 'ArrowRight' || event.key === 'Enter') {
|
||||
event.preventDefault(); show(current + 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault(); show(current - 1);
|
||||
}
|
||||
});
|
||||
}());
|
||||
BIN
tundra/devices/beatles/diagnostic-app/gecko-proof.png
Normal file
BIN
tundra/devices/beatles/diagnostic-app/gecko-proof.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
tundra/devices/beatles/diagnostic-app/icon.png
Normal file
BIN
tundra/devices/beatles/diagnostic-app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 769 KiB |
39
tundra/devices/beatles/diagnostic-app/index.html
Normal file
39
tundra/devices/beatles/diagnostic-app/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Vulpes — Diagnostic Nokia</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><img src="icon.png" alt=""><div><h1>VULPES</h1><p>Laboratoire · Nokia 2720</p></div></header>
|
||||
<main>
|
||||
<section class="page active">
|
||||
<span class="eyebrow">PREMIER CONTACT</span>
|
||||
<h2>Bonjour, Nokia.</h2>
|
||||
<p><strong>Gecko 153.5 ESR</strong> a calculé une page HTML/CSS/JS sur ce Nokia.</p>
|
||||
<div class="notice">Cet écran utilise <strong>KaiOS / Gecko 48</strong>.<br>Le moteur récent est testé hors écran. Gaia reste à porter.</div>
|
||||
</section>
|
||||
<section class="page">
|
||||
<span class="eyebrow">LE MATÉRIEL</span>
|
||||
<h2>Petit format.</h2>
|
||||
<dl><dt>Architecture</dt><dd>ARMv7 · 32 bits</dd><dt>Noyau</dt><dd>Linux 3.10.49</dd><dt>Mémoire visible</dt><dd>400 Mio environ</dd><dt>Écran intérieur</dt><dd>240 × 320</dd></dl>
|
||||
</section>
|
||||
<section class="page">
|
||||
<span class="eyebrow">ESSAI DES TOUCHES</span>
|
||||
<h2>À toi de jouer.</h2>
|
||||
<p>Appuie sur les chiffres, les flèches ou la touche centrale.</p>
|
||||
<output id="last-key">En attente…</output>
|
||||
<p class="small">Aucune donnée personnelle lue. Aucune partition système modifiée.</p>
|
||||
</section>
|
||||
<section class="page proof">
|
||||
<span class="eyebrow">RENDU RÉEL · GECKO 153.5 ESR</span>
|
||||
<img src="gecko-proof.png" alt="Page calculée sur le Nokia : Gecko 153 et JavaScript moderne OK">
|
||||
<p class="small">Image du rendu hors écran.<br>Cette prévisualisation reste dans KaiOS.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer><span>← →</span><span id="position">1 / 4</span><span>OK : suite</span></footer>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
tundra/devices/beatles/diagnostic-app/manifest.webapp
Normal file
10
tundra/devices/beatles/diagnostic-app/manifest.webapp
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "Vulpes Diagnostic",
|
||||
"description": "Essai d’affichage et des touches du Nokia 2720. Utilise le Gecko de KaiOS ; ce n’est pas Vulpes OS installé.",
|
||||
"version": "0.2.0",
|
||||
"launch_path": "/index.html",
|
||||
"developer": {"name": "OverSu", "url": "https://vulpes-os.org"},
|
||||
"icons": {"128": "/icon.png"},
|
||||
"default_locale": "fr",
|
||||
"orientation": "portrait"
|
||||
}
|
||||
22
tundra/devices/beatles/diagnostic-app/style.css
Normal file
22
tundra/devices/beatles/diagnostic-app/style.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #07182c; color: #edf6ff; font: 14px/1.35 sans-serif; }
|
||||
header { display: flex; align-items: center; padding: 10px 12px; border-bottom: 1px solid #24405c; }
|
||||
header img { width: 32px; height: 32px; margin-right: 9px; object-fit: contain; }
|
||||
h1 { font-size: 19px; letter-spacing: 3px; margin: 0; }
|
||||
header p { margin: 0; font-size: 10px; color: #a8c3db; }
|
||||
main { padding: 12px; }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
.eyebrow { color: #75c7ff; font-size: 10px; letter-spacing: 1px; }
|
||||
h2 { margin: 6px 0 9px; font-size: 23px; line-height: 1.1; }
|
||||
p { margin: 8px 0; }
|
||||
.notice { padding: 9px; margin-top: 12px; background: #14304a; border-left: 3px solid #60baff; font-size: 12px; }
|
||||
dl { margin: 8px 0; }
|
||||
dt { color: #a8c3db; font-size: 10px; }
|
||||
dd { margin: 0 0 2px; }
|
||||
output { display: block; padding: 10px; border: 1px solid #60baff; border-radius: 5px; overflow-wrap: break-word; }
|
||||
.small { font-size: 11px; color: #a8c3db; }
|
||||
.proof { text-align: center; }
|
||||
.proof img { display: block; width: 96px; height: 128px; margin: 6px auto; }
|
||||
footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; padding: 6px 10px; background: #102b45; font-size: 11px; }
|
||||
37
tundra/devices/beatles/display-test.py
Normal file
37
tundra/devices/beatles/display-test.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
os.umask(0o022)
|
||||
with Path('/work/xorg-console.log').open('w') as log:
|
||||
server = subprocess.Popen(['/usr/lib/xorg/Xorg', ':0', 'vt1',
|
||||
'-config', '/etc/X11/xorg.conf', '-logfile', '/work/Xorg.log',
|
||||
'-nolisten', 'tcp', '-ac', '-noreset', '-novtswitch', '-sharevts'],
|
||||
stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
for attempt in range(40):
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError('Xorg exited: ' + str(server.returncode))
|
||||
if Path('/tmp/.X11-unix/X0').exists():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
with Path('/work/keypad.log').open('w') as keys, Path('/work/x11-test.log').open('w') as client_log:
|
||||
keypad = subprocess.Popen(['/work/keypad'], stdout=keys, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
client = subprocess.Popen(['/work/x11-test'], stdout=client_log, stderr=subprocess.STDOUT)
|
||||
time.sleep(2)
|
||||
subprocess.run(['/work/keypad','--self-test'],check=True)
|
||||
print('X11 client exit:', client.wait(timeout=28), flush=True)
|
||||
finally:
|
||||
keypad.terminate()
|
||||
keypad.wait(timeout=2)
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
169
tundra/devices/beatles/gaia-audit.sys.mjs
Normal file
169
tundra/devices/beatles/gaia-audit.sys.mjs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import {tundraRadio} from 'resource://vulpes/host/Tundra.sys.mjs';
|
||||
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
|
||||
export function install() {
|
||||
Services.obs.addObserver(function opened(win) {
|
||||
win.addEventListener('load',()=>{
|
||||
if(win.location.href==='chrome://vulpes/content/shell.xhtml') run(win).catch(Cu.reportError);
|
||||
},{once:true});
|
||||
},'chrome-document-global-created');
|
||||
}
|
||||
|
||||
async function run(win) {
|
||||
const report={engine:Services.appinfo.platformVersion,build:Services.appinfo.platformBuildID,started:Date.now()};
|
||||
const save=()=>IOUtils.writeJSON('/work/gaia-audit.json',report);
|
||||
try {
|
||||
for(let i=0;i<600&&!win.hostReady;i++)await sleep(200);
|
||||
if(!win.hostReady)throw Error('Host startup timeout');
|
||||
ChromeUtils.registerWindowActor('VulpesProbe',{
|
||||
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
|
||||
allFrames:true,safeForUntrustedWebProcess:true,
|
||||
});
|
||||
const system=win.document.getElementById('system').browsingContext;
|
||||
const find=(context,app)=>{
|
||||
if(context.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return context;
|
||||
for(const child of context.children){const result=find(child,app);if(result)return result;}
|
||||
return null;
|
||||
};
|
||||
const evaluate=(app,script)=>find(system,app).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',script);
|
||||
for(let i=0;i<300;i++) {
|
||||
if(find(system,'homescreen') && await evaluate('homescreen',"return document.querySelectorAll('gaia-app-icon').length>0;"))break;
|
||||
await sleep(200);
|
||||
}
|
||||
report.system=await evaluate('system','return {ready:!!window.applications?.ready,size:[innerWidth,innerHeight]};');
|
||||
report.homescreen=await evaluate('homescreen',"return {icons:document.querySelectorAll('gaia-app-icon').length,size:[innerWidth,innerHeight],text:document.body.innerText.slice(0,300)};");
|
||||
for(let i=0;i<60;i++) {
|
||||
report.icons=await evaluate('homescreen',"return [...document.querySelectorAll('gaia-app-icon')].map(e=>({id:e.dataset.identifier,loaded:!!e._hasIcon,pending:!!e._image,images:[...(e._container?.querySelectorAll('img') || [])].map(i=>({width:i.naturalWidth,complete:i.complete}))}));");
|
||||
report.logoHidden=await evaluate('system',"var e=document.getElementById('os-logo');return !e || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) || Number(getComputedStyle(e).opacity)<.05;");
|
||||
if(report.logoHidden&&report.icons.length>=4&&report.icons.slice(0,4).every(icon=>icon.images.some(image=>image.complete&&image.width>0)))break;
|
||||
await sleep(500);
|
||||
}
|
||||
report.homescreen.icons=report.icons.length;
|
||||
report.compactUI=await evaluate('homescreen',"return {height:innerHeight,singlePanel:document.getElementById('panels').scrollWidth===document.getElementById('panels').clientWidth,indicatorsHidden:getComputedStyle(document.getElementById('page-indicator-header')).display==='none',pinnedPagesHidden:getComputedStyle(document.getElementById('pages-panel')).display==='none'};");
|
||||
report.homeSearchHidden=await evaluate('system',"return getComputedStyle(document.querySelector('#homescreen > .chrome')).display==='none';");
|
||||
report.layout=await evaluate('homescreen',"return {mode:document.documentElement.dataset.vulpesLayout,icons:[...document.querySelectorAll('gaia-app-icon')].slice(0,9).map(e=>({rect:e.getBoundingClientRect().toJSON(),image:e._container?.querySelector('img')?.getBoundingClientRect().toJSON()}))};");
|
||||
report.hardware=await evaluate('system',"return await VulpesCompat.call('platform.snapshot',{});");
|
||||
report.radioAPI=await evaluate('system',"return {slots:navigator.mozMobileConnections?.length || 0,battery:navigator.battery.level,signal:!!document.getElementById('statusbar-mobile-connection'),radioUnavailable:document.documentElement.hasAttribute('data-vulpes-radio-unavailable'),cards:navigator.mozIccManager.iccIds.map(id=>navigator.mozIccManager.getIccById(id).cardState),connections:[...navigator.mozMobileConnections].map(c=>({radioState:c.radioState,registered:c.voice.connected,type:c.voice.type,signal:c.voice.relSignalStrength}))};");
|
||||
if(Services.env.get('VULPES_TUNDRA_RADIO')==='1') {
|
||||
for(let attempt=0;attempt<30;attempt++) {
|
||||
report.phoneBroker=await tundraRadio('status');
|
||||
if(report.phoneBroker.ready) {
|
||||
try {report.currentCallCount=(await tundraRadio('calls')).length;break;} catch (_) {}
|
||||
}
|
||||
await sleep(1000);
|
||||
}
|
||||
if(report.currentCallCount===undefined)throw Error('Phone broker did not become ready');
|
||||
try {report.callAudio=await tundraRadio('callAudio',{settings:{operation:'status'}});}
|
||||
catch(error) {report.callAudio={error:error.message};}
|
||||
try { await tundraRadio('sms',{number:'INVALID',body:'validation only'});report.invalidSmsRejected=false; }
|
||||
catch(error) { report.invalidSmsRejected=error.message==='INVALID_NUMBER'; }
|
||||
}
|
||||
report.readyMs=Date.now()-report.started;
|
||||
const image=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
|
||||
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
|
||||
canvas.width=image.width;canvas.height=image.height;canvas.getContext('2d').drawImage(image,0,0);image.close();
|
||||
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
||||
await IOUtils.write('/work/gaia-native.png',new Uint8Array(await blob.arrayBuffer()));
|
||||
await save();
|
||||
if(Services.env.get("VULPES_TRIAL_MANUAL")==="1") {
|
||||
let last='';
|
||||
while(!win.closed) {
|
||||
try {
|
||||
const request=await IOUtils.readJSON('/work/manual-request.json');
|
||||
if(typeof request.id==='string' && request.id!==last) {
|
||||
last=request.id;
|
||||
let response;
|
||||
try {
|
||||
if(request.app==='screenshot') {
|
||||
const picture=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
|
||||
canvas.getContext('2d').drawImage(picture,0,0);picture.close();
|
||||
const capture=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
||||
await IOUtils.write('/work/manual-screen.png',new Uint8Array(await capture.arrayBuffer()));
|
||||
response={id:last,value:true};
|
||||
} else response={id:last,value:await Promise.race([
|
||||
evaluate(request.app,request.script),
|
||||
sleep(30000).then(()=>{throw Error('Manual probe timed out');})
|
||||
])};
|
||||
} catch(error) {response={id:last,error:String(error)};}
|
||||
await IOUtils.writeJSON('/work/manual-response.json',response,{tmpPath:'/work/manual-response.tmp'});
|
||||
}
|
||||
} catch (_) {}
|
||||
await sleep(200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
report.animation=await evaluate('homescreen',`return new Promise(resolve=>{let frames=0;const begin=performance.now();function tick(now){frames++;if(now-begin>=3000)resolve({frames,seconds:(now-begin)/1000,fps:frames*1000/(now-begin)});else requestAnimationFrame(tick);}requestAnimationFrame(tick);});`);
|
||||
const until=async read=>{let value;for(let i=0;i<40;i++){value=await read();if(value)return value;await sleep(250);}return value;};
|
||||
await evaluate('homescreen',"var first=document.querySelector('gaia-app-icon');first.setAttribute('tabindex','-1');first.focus();return document.activeElement===first;");
|
||||
let sequence=0;
|
||||
const key=async key=>{
|
||||
const sent=++sequence;
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:sent,key},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
for(let i=0;i<40;i++) {
|
||||
try {if((await IOUtils.readJSON('/work/keypad-response.json')).sequence===sent)return;} catch (_) {}
|
||||
await sleep(100);
|
||||
}
|
||||
throw Error('Keypad acknowledgement timed out');
|
||||
};
|
||||
report.inputPath='kernel evdev → keypad reader → XTest → GTK → host → Gaia';
|
||||
await key('ArrowRight');
|
||||
report.keypadSelection=await until(()=>evaluate('homescreen',"var id=document.querySelector('[data-vulpes-keypad-focus]')?.dataset.identifier;return id?.includes('sms.localhost') ? id : null;"));
|
||||
if(!report.keypadSelection)throw Error('Right did not select Messages');
|
||||
report.selectedAppName=await until(()=>evaluate('homescreen',"return document.getElementById('vulpes-compact-app-name')?.textContent==='Messages';"));
|
||||
await key('Enter');
|
||||
for(let i=0;i<150&&!find(system,'sms');i++)await sleep(200);
|
||||
report.smsOpened=!!find(system,'sms');
|
||||
if(report.smsOpened) {
|
||||
await until(()=>evaluate('sms',"return document.readyState==='complete' && !!document.body;"));
|
||||
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('sms.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
||||
await evaluate('sms',"var input=document.createElement('input');input.id='vulpes-keypad-probe';input.type='tel';input.style='position:fixed;top:80px;left:0;z-index:99999';document.body.append(input);input.focus();return true;");
|
||||
await key('2');
|
||||
report.keypadInput=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value;"));
|
||||
await evaluate('sms',"var input=document.getElementById('vulpes-keypad-probe');input.value='';input.type='text';input.focus();return true;");
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
report.multitap=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bob';"));
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#2#2'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
report.inputModes=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA2';"));
|
||||
await key('Backspace');
|
||||
report.deleteText=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA';"));
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-mode')?.textContent.startsWith('abc');"));
|
||||
await evaluate('sms',"document.getElementById('vulpes-keypad-probe').remove();document.body.focus();return true;");
|
||||
// Use the original SMS composer as well as the isolated editor probe.
|
||||
await evaluate('sms',"var button=document.getElementById('threads-composer-link');button.focus();return true;");
|
||||
await key('Enter');
|
||||
if(!await until(()=>evaluate('sms',"return typeof ConversationView!=='undefined' && Navigation.isCurrentPanel('composer') && document.activeElement.classList.contains('recipient');")))throw Error('SMS composer did not open');
|
||||
await evaluate('sms',"await App.whenReady();await new Promise(r=>setTimeout(r,400));ConversationView.recipients.focus();return true;");
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
if(!await until(()=>evaluate('sms',"return document.querySelector('#messages-recipients-list [contenteditable]')?.textContent==='5550100';")))throw Error('Recipient digits did not reach the original form');
|
||||
await key('Enter');
|
||||
report.smsRecipient=await until(()=>evaluate('sms',"return ConversationView.recipients.numbers.includes('5550100');"));
|
||||
await evaluate('sms',"Compose.focus();return true;");
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
report.smsBody=await until(()=>evaluate('sms',"return document.getElementById('messages-input').textContent.trim()==='bob';"));
|
||||
// Never send the synthetic message or retain it as a draft.
|
||||
await evaluate('sms',"ConversationView.cleanFields();return true;");
|
||||
await key('Escape');
|
||||
await until(()=>evaluate('sms',"return !Navigation.isCurrentPanel('composer');"));
|
||||
await key('Escape');
|
||||
report.returnHome=await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('homescreen.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
||||
}
|
||||
if(report.returnHome) {
|
||||
await evaluate('homescreen',"var icon=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.dataset.identifier.includes('communications'));icon.focus();return true;");
|
||||
await key('Enter');
|
||||
report.dialerOpened=await until(()=>Promise.resolve(!!find(system,'communications')));
|
||||
if(report.dialerOpened) {
|
||||
if(!await until(()=>evaluate('communications',"return performance.getEntriesByName('contentInteractive').length>0 && document.getElementById('phone-number-view')?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});")))throw Error('Dialer keypad did not become interactive');
|
||||
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('communications.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
|
||||
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
|
||||
report.dialerDigits=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='5550100';"));
|
||||
report.dialerObserved=await evaluate('communications',"return document.getElementById('phone-number-view')?.value;");
|
||||
for(let i=0;i<7;i++){await key('Backspace');await sleep(150);}
|
||||
report.dialerClear=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='';"));
|
||||
await key('Escape');
|
||||
}
|
||||
}
|
||||
}catch(error){report.error=String(error);report.stack=error.stack;}
|
||||
await save();
|
||||
}
|
||||
137
tundra/devices/beatles/gaia-session.py
Normal file
137
tundra/devices/beatles/gaia-session.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
os.umask(0o022)
|
||||
for name in ('gaia-audit.json', 'gaia-native.png', 'gecko.log', 'gaia-server.log', 'memory-samples.jsonl', 'keypad-request.json', 'keypad-response.json', 'manual-request.json', 'manual-response.json'):
|
||||
Path('/work',name).unlink(missing_ok=True)
|
||||
ROOT = Path('/opt/vulpes')
|
||||
PROFILE = Path('/work/gaia-profile')
|
||||
PROFILE.mkdir(exist_ok=True)
|
||||
os.chown(PROFILE,2000,2000)
|
||||
prefs = {
|
||||
'browser.shell.checkDefaultBrowser': False,
|
||||
'browser.aboutwelcome.enabled': False,
|
||||
'browser.startup.homepage_override.mstone': 'ignore',
|
||||
'browser.dom.window.dump.enabled': True,
|
||||
'datareporting.policy.dataSubmissionEnabled': False,
|
||||
'app.update.auto': False,
|
||||
'fission.autostart': False,
|
||||
'dom.ipc.processCount': 1,
|
||||
'dom.ipc.processPrelaunch.enabled': False,
|
||||
'gfx.webrender.software': True,
|
||||
'layers.acceleration.disabled': True,
|
||||
'layout.css.devPixelsPerPx': '1.0',
|
||||
'browser.cache.disk.enable': False,
|
||||
'browser.cache.memory.capacity': 8192,
|
||||
}
|
||||
(PROFILE/'user.js').write_text('\n'.join('user_pref(%s,%s);'%(json.dumps(k),json.dumps(v)) for k,v in prefs.items()))
|
||||
os.chown(PROFILE/'user.js',2000,2000)
|
||||
os.chmod(PROFILE/'user.js',0o600)
|
||||
saved = PROFILE/'vulpes-settings-v1.json'
|
||||
if not saved.exists():
|
||||
saved.write_text(json.dumps({'lockscreen.enabled':False,'vulpes.gaia-restoration.v1':True,'language.current':'fr'}))
|
||||
os.chown(saved,2000,2000)
|
||||
shutil.copy2(ROOT/'host/bootstrap/vulpes-autoconfig.js','/opt/firefox/defaults/pref/vulpes-autoconfig.js')
|
||||
config=(ROOT/'host/bootstrap/vulpes.cfg').read_text()
|
||||
config+="\nChromeUtils.importESModule('resource://vulpes/tests/BeatlesAudit.sys.mjs').install();\n"
|
||||
Path('/opt/firefox/vulpes.cfg').write_text(config)
|
||||
os.chmod('/opt/firefox/vulpes.cfg',0o644)
|
||||
for directory in ('logs','profiles'):
|
||||
(ROOT/directory).mkdir(exist_ok=True)
|
||||
os.chown(ROOT/directory,2000,2000)
|
||||
for path in (ROOT/'assets').iterdir():
|
||||
if path.is_file():os.chown(path,2000,2000)
|
||||
|
||||
|
||||
def unprivileged():
|
||||
Path('/proc/self/oom_score_adj').write_text('800')
|
||||
# Android's paranoid-network kernel requires inet even for private loopback.
|
||||
os.setgroups([3003])
|
||||
os.setgid(2000)
|
||||
os.setuid(2000)
|
||||
|
||||
|
||||
env=os.environ.copy()
|
||||
env.update(HOME='/work',TMPDIR='/tmp',DISPLAY=':0',MOZ_ENABLE_WAYLAND='0',
|
||||
VULPES_HOST_ROOT='/opt/firefox/vulpes/host',VULPES_WORKSPACE_ROOT='/opt/vulpes',
|
||||
VULPES_FULLSCREEN='1',VULPES_KEYPAD='1',VULPES_NATIVE_TOUCH='1',
|
||||
VULPES_DEVICE='beatles',VULPES_TUNDRA_STATUS='1',
|
||||
MOZ_CRASHREPORTER_DISABLE='1',FONTCONFIG_PATH='/etc/fonts')
|
||||
duration=1780 if os.environ.get("VULPES_TRIAL_MANUAL")=="1" else 160
|
||||
processes=[]
|
||||
handles=[]
|
||||
try:
|
||||
def start(command,log,drop=False):
|
||||
path=Path('/work/'+log)
|
||||
output=path.open('w');os.chmod(path,0o644);handles.append(output)
|
||||
proc=subprocess.Popen(command,env=env,stdout=output,stderr=subprocess.STDOUT,
|
||||
preexec_fn=unprivileged if drop else None)
|
||||
processes.append(proc)
|
||||
return proc
|
||||
xorg=start(['/usr/lib/xorg/Xorg',':0','vt1','-config','/etc/X11/xorg.conf',
|
||||
'-logfile','/work/Xorg.log','-nolisten','tcp','-ac','-noreset','-novtswitch','-sharevts','-nocursor'],'xorg-console.log')
|
||||
for _ in range(100):
|
||||
if xorg.poll() is not None:raise RuntimeError('Xorg failed')
|
||||
if Path('/tmp/.X11-unix/X0').exists():break
|
||||
time.sleep(.1)
|
||||
keypad=start(['/work/keypad'],'keypad.log')
|
||||
for _ in range(120):
|
||||
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge failed; inspect keypad.log')
|
||||
if 'Nokia keypad bridge ready' in Path('/work/keypad.log').read_text():break
|
||||
time.sleep(.1)
|
||||
else:raise RuntimeError('Physical keypad bridge startup timed out')
|
||||
cover=start(['/usr/bin/python3.11','/work/cover-display.py'],'cover-display.log')
|
||||
for _ in range(50):
|
||||
if cover.poll() is not None:raise RuntimeError('External display failed; inspect cover-display.log')
|
||||
if Path('/work/cover-ready').exists():break
|
||||
time.sleep(.1)
|
||||
else:raise RuntimeError('External display startup timed out')
|
||||
server=start(['/usr/bin/python3.11',str(ROOT/'tools/serve-gaia.py')],'gaia-server.log',True)
|
||||
import socket
|
||||
for _ in range(100):
|
||||
if server.poll() is not None:raise RuntimeError('Gaia server failed')
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1',8765),timeout=.2):break
|
||||
except OSError:time.sleep(.1)
|
||||
gecko=start(['/opt/firefox/firefox','-no-remote','-purgecaches','-profile',str(PROFILE),
|
||||
'-chrome','chrome://vulpes/content/shell.xhtml'],'gecko.log',True)
|
||||
begin=time.monotonic()
|
||||
last_key_sequence=0
|
||||
last_memory_sample=0
|
||||
with Path('/work/memory-samples.jsonl').open('w') as memory:
|
||||
while gecko.poll() is None and time.monotonic()-begin<duration:
|
||||
if cover.poll() is not None:raise RuntimeError('External display stopped')
|
||||
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge stopped')
|
||||
request=Path('/work/keypad-request.json')
|
||||
if request.exists():
|
||||
command=json.loads(request.read_text())
|
||||
sequence=command.get('sequence');processed=False
|
||||
if (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
|
||||
command.get('key') in ('ArrowRight','ArrowLeft','ArrowUp','ArrowDown','Backspace','Enter','2','Escape','EndCall','Notifications','AudioVolumeUp','AudioVolumeDown')):
|
||||
subprocess.run(['/work/keypad','--inject-key',command['key']],env=env,check=True,timeout=5)
|
||||
last_key_sequence=sequence;processed=True
|
||||
elif (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
|
||||
isinstance(command.get('keys'),str) and re.fullmatch(r'[0-9*#]{1,40}',command['keys'])):
|
||||
subprocess.run(['/work/keypad','--inject-keys',command['keys']],env=env,check=True,timeout=10)
|
||||
last_key_sequence=sequence;processed=True
|
||||
if processed:
|
||||
reply=Path('/work/keypad-response.tmp');reply.write_text(json.dumps({'sequence':sequence}));reply.replace('/work/keypad-response.json')
|
||||
sample={'seconds':round(time.monotonic()-begin,1),'meminfo':Path('/proc/meminfo').read_text()}
|
||||
if time.monotonic()-last_memory_sample>=2:
|
||||
memory.write(json.dumps(sample)+'\n');memory.flush();last_memory_sample=time.monotonic()
|
||||
time.sleep(.1)
|
||||
print('Gecko exit:',gecko.poll(),flush=True)
|
||||
finally:
|
||||
for proc in reversed(processes):
|
||||
if proc.poll() is None:proc.terminate()
|
||||
for proc in reversed(processes):
|
||||
try:proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:proc.kill();proc.wait()
|
||||
for handle in handles:handle.close()
|
||||
1
tundra/devices/beatles/gecko-test.html
Normal file
1
tundra/devices/beatles/gecko-test.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Vulpes ARMv7 Gecko test</title><style>*{box-sizing:border-box}body{margin:0;background:#071b2c;color:#edf6ff;font:16px sans-serif;padding:18px;width:240px}h1{font-size:24px;margin:4px 0 20px}small{color:#8ac9ff}.cards{display:grid;gap:12px}.card{padding:12px;background:#153347;border-left:3px solid #57db9d}p{font-size:13px;line-height:1.4}</style><small>VULPES · LABORATOIRE</small><h1>Gecko ARMv7</h1><div class="cards"><div class="card" id="engine">Chargement…</div><div class="card" id="js">JavaScript…</div></div><p>Page calculée sur le Nokia.<br>Essai hors écran, pas encore une interface interactive.</p><script>document.getElementById('engine').textContent='Gecko '+navigator.userAgent.match(/rv:([\d.]+)/)[1];const value={test:{ok:true}};document.getElementById('js').textContent=value?.test?.ok && 1n+2n===3n?'JavaScript moderne : OK':'Échec';</script>
|
||||
51
tundra/devices/beatles/hardware_control.py
Normal file
51
tundra/devices/beatles/hardware_control.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Fixed Nokia controls mounted writable only in the trusted broker namespace."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
|
||||
class HardwareControl:
|
||||
def __init__(self,root=Path('/work/hardware-controls')):
|
||||
self.root=root;self.original=None;self.pattern=[];self.next_at=0;self.on=False
|
||||
|
||||
def write(self,name,value):
|
||||
(self.root/name).write_text(str(value)+'\n')
|
||||
|
||||
def request(self,value):
|
||||
if not isinstance(value,dict):raise ValueError('INVALID_REQUEST')
|
||||
op=value.get('operation')
|
||||
if op=='screen':
|
||||
level=value.get('level');enabled=value.get('enabled')
|
||||
if type(enabled) is not bool or type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
maximum=int((self.root/'backlight-max').read_text())
|
||||
if not 1<=maximum<=65535:raise RuntimeError('NOT_SUPPORTED')
|
||||
if self.original is None:self.original=int((self.root/'backlight').read_text())
|
||||
brightness=max(1,int(level*maximum+.5)) if enabled else 0
|
||||
self.write('backlight',brightness)
|
||||
actual=int((self.root/'backlight').read_text())
|
||||
if actual!=brightness:raise RuntimeError('HARDWARE_READBACK_FAILED')
|
||||
return {'brightness':actual,'maximum':maximum}
|
||||
if op=='vibrate':
|
||||
pattern=value.get('pattern')
|
||||
if not isinstance(pattern,list) or len(pattern)>32 or any(type(n) is not int or not 0<=n<=10000 for n in pattern) or sum(pattern)>10000:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
self.write('vibrator',0)
|
||||
self.pattern=list(pattern);self.on=False;self.next_at=0;self.tick()
|
||||
return True
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
def tick(self):
|
||||
now=time.monotonic()
|
||||
while self.pattern and now>=self.next_at:
|
||||
duration=self.pattern.pop(0);self.on=not self.on
|
||||
self.write('vibrator',duration if self.on else 0)
|
||||
self.next_at=now+duration/1000
|
||||
|
||||
def restore(self):
|
||||
self.pattern=[]
|
||||
for name,value in [('vibrator',0),('backlight',self.original)]:
|
||||
if value is not None:
|
||||
try:self.write(name,value)
|
||||
except OSError:pass
|
||||
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')
|
||||
201
tundra/devices/beatles/radio_status.py
Normal file
201
tundra/devices/beatles/radio_status.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# 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()
|
||||
16
tundra/devices/beatles/run-gecko-test.sh
Normal file
16
tundra/devices/beatles/run-gecko-test.sh
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/system/bin/sh
|
||||
BASE=/data/local/tmp/vulpes-gecko-armhf
|
||||
case "$2" in 156.0.1) BASE=/data/local/vulpes-beatles;; ""|153.5.0esr) ;; *) exit 2;; esac
|
||||
MODE="$1"
|
||||
case "$MODE" in version|render) ;; *) exit 2;; esac
|
||||
[ "$MODE" != render ] || /system/bin/busybox rm -f "$BASE/rootfs/work/gecko.png"
|
||||
/system/bin/busybox mkdir -p "$BASE/rootfs/proc" "$BASE/rootfs/dev/shm" "$BASE/rootfs/work/profile" "$BASE/rootfs/tmp"
|
||||
/system/bin/busybox chown -R 2000:2000 "$BASE/rootfs/work" "$BASE/rootfs/tmp"
|
||||
/system/bin/busybox timeout -t 60 -s KILL "$BASE/gecko-probe-launcher" "$MODE" > "$BASE/$MODE.log" 2>&1
|
||||
CODE=$?
|
||||
# Only the disposable runtime's processes; KaiOS and ADB use another root.
|
||||
for PROC in /proc/[0-9]*; do
|
||||
[ "$(/system/bin/busybox readlink "$PROC/root")" != "$BASE/rootfs" ] || kill -KILL "${PROC##*/}"
|
||||
done
|
||||
echo "$CODE" > "$BASE/$MODE.exit"
|
||||
/system/bin/busybox chmod 644 "$BASE/$MODE.log" "$BASE/$MODE.exit"
|
||||
116
tundra/devices/beatles/sms_pdu.py
Normal file
116
tundra/devices/beatles/sms_pdu.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# 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}
|
||||
71
tundra/devices/beatles/stream_volume.py
Normal file
71
tundra/devices/beatles/stream_volume.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Stream volume for the Nokia Android 6 audio policy service.
|
||||
|
||||
AOSP android-6.0.1_r1 IAudioPolicyService: setStreamVolumeIndex=16,
|
||||
getStreamVolumeIndex=17 (index, status), getDevicesForStream=24.
|
||||
The firmware reports 0..5 for voice, 0..15 for the other streams below.
|
||||
Only the currently routed outputs are changed; trial shutdown restores them.
|
||||
"""
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
STREAMS={'telephony':(0,5),'notification':(5,15),'content':(3,15),'alarm':(4,15),'bt_sco':(6,15)}
|
||||
|
||||
|
||||
class StreamVolume:
|
||||
def __init__(self):
|
||||
self.original={}
|
||||
|
||||
def transact(self,method,*values):
|
||||
command=['/system/bin/service','call','media.audio_policy',str(method)]
|
||||
for value in values:command.extend(('i32',str(value)))
|
||||
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
|
||||
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
|
||||
if result.returncode:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
# service prints either one line or address-prefixed Parcel rows.
|
||||
text=result.stdout
|
||||
if not text.startswith('Result: Parcel('):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
text=re.sub(r"'[^']*'",'',text)
|
||||
text=re.sub(r'0x[0-9a-fA-F]+:', '',text)
|
||||
return [int(word,16) for word in re.findall(r'\b[0-9a-fA-F]{8}\b',text)]
|
||||
|
||||
def get(self,stream,device,maximum):
|
||||
reply=self.transact(17,stream,device)
|
||||
if len(reply)!=2 or reply[1]!=0 or not 0<=reply[0]<=maximum:
|
||||
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return reply[0]
|
||||
|
||||
def state(self):
|
||||
result={}
|
||||
for name,(stream,maximum) in STREAMS.items():
|
||||
reply=self.transact(24,stream)
|
||||
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
mask=reply[0] or 0x40000000
|
||||
device=mask&-mask
|
||||
result[name]=self.get(stream,device,maximum)/maximum
|
||||
return result
|
||||
|
||||
def request(self,value):
|
||||
if not isinstance(value,dict) or value.get('stream') not in STREAMS:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
level=value.get('level')
|
||||
if type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
stream,maximum=STREAMS[value['stream']]
|
||||
reply=self.transact(24,stream)
|
||||
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
mask=reply[0] or 0x40000000
|
||||
devices=[1<<bit for bit in range(31) if mask&(1<<bit)]
|
||||
index=int(level*maximum+.5)
|
||||
for device in devices:
|
||||
old=self.get(stream,device,maximum)
|
||||
self.original.setdefault((stream,device),old)
|
||||
if self.transact(16,stream,index,device)!=[0] or self.get(stream,device,maximum)!=index:
|
||||
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
|
||||
return {'stream':value['stream'],'index':index,'maximum':maximum,'devices':devices}
|
||||
|
||||
def restore(self):
|
||||
for (stream,device),index in self.original.items():
|
||||
try:self.transact(16,stream,index,device)
|
||||
except (OSError,RuntimeError,subprocess.SubprocessError):pass
|
||||
11994
tundra/devices/beatles/t9/af.tsv
Normal file
11994
tundra/devices/beatles/t9/af.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/bg.tsv
Normal file
12000
tundra/devices/beatles/t9/bg.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/bs.tsv
Normal file
12000
tundra/devices/beatles/t9/bs.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/ca.tsv
Normal file
12000
tundra/devices/beatles/t9/ca.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/cs.tsv
Normal file
12000
tundra/devices/beatles/t9/cs.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/cy.tsv
Normal file
12000
tundra/devices/beatles/t9/cy.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/da.tsv
Normal file
12000
tundra/devices/beatles/t9/da.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/de.tsv
Normal file
12000
tundra/devices/beatles/t9/de.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/el.tsv
Normal file
12000
tundra/devices/beatles/t9/el.tsv
Normal file
File diff suppressed because it is too large
Load diff
11999
tundra/devices/beatles/t9/en.tsv
Normal file
11999
tundra/devices/beatles/t9/en.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/en_gb.tsv
Normal file
12000
tundra/devices/beatles/t9/en_gb.tsv
Normal file
File diff suppressed because it is too large
Load diff
11999
tundra/devices/beatles/t9/en_us.tsv
Normal file
11999
tundra/devices/beatles/t9/en_us.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/es.tsv
Normal file
12000
tundra/devices/beatles/t9/es.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/eu.tsv
Normal file
12000
tundra/devices/beatles/t9/eu.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/ff.tsv
Normal file
12000
tundra/devices/beatles/t9/ff.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/fi.tsv
Normal file
12000
tundra/devices/beatles/t9/fi.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/fr.tsv
Normal file
12000
tundra/devices/beatles/t9/fr.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/fy.tsv
Normal file
12000
tundra/devices/beatles/t9/fy.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/ga.tsv
Normal file
12000
tundra/devices/beatles/t9/ga.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/gd.tsv
Normal file
12000
tundra/devices/beatles/t9/gd.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/gl.tsv
Normal file
12000
tundra/devices/beatles/t9/gl.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/gv.tsv
Normal file
12000
tundra/devices/beatles/t9/gv.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/hr.tsv
Normal file
12000
tundra/devices/beatles/t9/hr.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/hu.tsv
Normal file
12000
tundra/devices/beatles/t9/hu.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/it.tsv
Normal file
12000
tundra/devices/beatles/t9/it.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/lt.tsv
Normal file
12000
tundra/devices/beatles/t9/lt.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/lv.tsv
Normal file
12000
tundra/devices/beatles/t9/lv.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/nb.tsv
Normal file
12000
tundra/devices/beatles/t9/nb.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/nl.tsv
Normal file
12000
tundra/devices/beatles/t9/nl.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/pl.tsv
Normal file
12000
tundra/devices/beatles/t9/pl.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/pt_br.tsv
Normal file
12000
tundra/devices/beatles/t9/pt_br.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/pt_pt.tsv
Normal file
12000
tundra/devices/beatles/t9/pt_pt.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/ro.tsv
Normal file
12000
tundra/devices/beatles/t9/ro.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/ru.tsv
Normal file
12000
tundra/devices/beatles/t9/ru.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sk.tsv
Normal file
12000
tundra/devices/beatles/t9/sk.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sl.tsv
Normal file
12000
tundra/devices/beatles/t9/sl.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sq.tsv
Normal file
12000
tundra/devices/beatles/t9/sq.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sr-cyrl.tsv
Normal file
12000
tundra/devices/beatles/t9/sr-cyrl.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sr-latn.tsv
Normal file
12000
tundra/devices/beatles/t9/sr-latn.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sr.tsv
Normal file
12000
tundra/devices/beatles/t9/sr.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/sv.tsv
Normal file
12000
tundra/devices/beatles/t9/sv.tsv
Normal file
File diff suppressed because it is too large
Load diff
12000
tundra/devices/beatles/t9/tr.tsv
Normal file
12000
tundra/devices/beatles/t9/tr.tsv
Normal file
File diff suppressed because it is too large
Load diff
334
tundra/devices/beatles/wifi_control.py
Normal file
334
tundra/devices/beatles/wifi_control.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Nokia Wi-Fi adapter: vendor supplicant, private client socket, no credentials in argv."""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
SOCKET = '/work/wifi-control.sock'
|
||||
STATE = Path('/run/tundra-wifi/state.json')
|
||||
WPA = '/work/radio-sockets/wpa_wlan0'
|
||||
ERRORS = {'WIFI_UNAVAILABLE', 'WIFI_DISABLED', 'WIFI_INVALID_REQUEST', 'WIFI_INVALID_PASSWORD',
|
||||
'WIFI_UNSUPPORTED_SECURITY', 'WIFI_CONNECTION_FAILED', 'WIFI_DHCP_FAILED', 'WIFI_SAVE_FAILED', 'NOT_SUPPORTED'}
|
||||
|
||||
|
||||
def decode_ssid(text):
|
||||
raw = bytearray()
|
||||
for part in re.findall(r'\\x[0-9a-fA-F]{2}|\\.|[^\\]+|\\$', text):
|
||||
if re.fullmatch(r'\\x[0-9a-fA-F]{2}', part): raw.append(int(part[2:], 16))
|
||||
elif part.startswith('\\'): raw.extend({'\\n':b'\n','\\r':b'\r','\\t':b'\t','\\\\':b'\\','\\"':b'"'}.get(part,part.encode()))
|
||||
else: raw.extend(part.encode())
|
||||
return raw.decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def security(flags):
|
||||
if 'EAP' in flags or 'WEP' in flags: return 'unsupported'
|
||||
if 'PSK' in flags: return 'wpa-psk'
|
||||
if 'SAE' in flags: return 'unsupported'
|
||||
return 'open'
|
||||
|
||||
|
||||
def parse_scan(text):
|
||||
rows=[]
|
||||
for line in text.splitlines()[1:]:
|
||||
parts=line.split('\t',4)
|
||||
if len(parts)!=5 or not re.fullmatch(r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',parts[0]): continue
|
||||
try: signal_level=max(0,min(100,2*(int(parts[2])+100)))
|
||||
except ValueError: continue
|
||||
ssid=decode_ssid(parts[4])
|
||||
if ssid: rows.append({'id':parts[0].lower(),'ssid':ssid,'signal':signal_level,'security':security(parts[3])})
|
||||
return sorted(rows,key=lambda row:-row['signal'])[:128]
|
||||
|
||||
|
||||
def android(*args, check=True):
|
||||
result=subprocess.run(['/system/bin/'+args[0],*args[1:]],capture_output=True,text=True,timeout=5,
|
||||
env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
|
||||
if check and result.returncode: raise RuntimeError('WIFI_UNAVAILABLE')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def command(value, required=True):
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as sock:
|
||||
# An abstract return address is reachable by the host supplicant outside our chroot.
|
||||
sock.bind('\0vulpes-wifi-'+uuid.uuid4().hex)
|
||||
sock.settimeout(3);sock.connect(WPA)
|
||||
sock.send(('IFNAME=wlan0 '+value).encode())
|
||||
result=sock.recv(65536).decode('utf-8',errors='replace').strip()
|
||||
if required and (result.startswith('FAIL') or result=='UNKNOWN COMMAND'):
|
||||
raise RuntimeError('WIFI_UNAVAILABLE')
|
||||
return result
|
||||
|
||||
|
||||
class Wifi:
|
||||
def __init__(self):
|
||||
self.last_scan=[];self.original_enabled=None;self.route=None;self.last_lease=0
|
||||
self.initial_resolver=Path('/etc/resolv.conf').read_bytes() if Path('/etc/resolv.conf').exists() else None
|
||||
self.rule_owned=False;self.stopping=False
|
||||
self.lease_network=None
|
||||
|
||||
def enabled(self):
|
||||
try: return bool(int(Path('/sys/class/net/wlan0/flags').read_text(),16)&1)
|
||||
except OSError: return False
|
||||
|
||||
def state(self):
|
||||
enabled=self.enabled()
|
||||
result={'available':True,'enabled':enabled,'state':'disconnected','network':None,'connectionInformation':{}}
|
||||
if not enabled: result['state']='disabled';return result
|
||||
status=dict(line.split('=',1) for line in command('STATUS').splitlines() if '=' in line)
|
||||
result['macAddress']=status.get('address','')
|
||||
if status.get('wpa_state')=='COMPLETED':
|
||||
result['state']='connected'
|
||||
result['network']={'ssid':decode_ssid(status.get('ssid','')),'savedId':status.get('id'),
|
||||
'security':security(status.get('key_mgmt','')),'signal':50}
|
||||
try: result['connectionInformation']['ipAddress']=str(ipaddress.IPv4Address(status.get('ip_address','')))
|
||||
except ValueError: pass
|
||||
poll=command('SIGNAL_POLL',False)
|
||||
for line in poll.splitlines():
|
||||
try:
|
||||
name,value=line.split('=',1);value=int(value)
|
||||
if name=='RSSI': result['network']['signal']=max(0,min(100,2*(value+100)))
|
||||
if name=='LINKSPEED': result['connectionInformation']['linkSpeed']=value
|
||||
except ValueError: pass
|
||||
return result
|
||||
|
||||
def known(self):
|
||||
rows=[]
|
||||
for line in command('LIST_NETWORKS').splitlines()[1:]:
|
||||
parts=line.split('\t',3)
|
||||
if len(parts)!=4 or not parts[0].isdigit(): continue
|
||||
flags=command('GET_NETWORK '+parts[0]+' key_mgmt',False)
|
||||
rows.append({'id':parts[0],'ssid':decode_ssid(parts[1]),'security':security(flags)})
|
||||
return rows
|
||||
|
||||
def scan(self):
|
||||
state=self.state()
|
||||
if not state['enabled']:return {**state,'networks':[]}
|
||||
# Subscribe before requesting a scan so fast completions are not lost.
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as events:
|
||||
events.bind('\0vulpes-wifi-scan-'+uuid.uuid4().hex);events.settimeout(3);events.connect(WPA)
|
||||
events.send(b'IFNAME=wlan0 ATTACH');events.recv(4096)
|
||||
command('SCAN',False)
|
||||
deadline=time.monotonic()+4
|
||||
while time.monotonic()<deadline:
|
||||
try:
|
||||
if b'CTRL-EVENT-SCAN-RESULTS' in events.recv(8192):break
|
||||
except socket.timeout:break
|
||||
rows=parse_scan(command('SCAN_RESULTS'));known=self.known();state=self.state()
|
||||
for row in rows:
|
||||
saved=next((n for n in known if n['ssid']==row['ssid'] and n['security']==row['security']),None)
|
||||
row.update(known=bool(saved),savedId=saved['id'] if saved else None,
|
||||
connected=bool(state['network'] and state['network']['ssid']==row['ssid']))
|
||||
self.last_scan=rows
|
||||
return {**state,'networks':rows}
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
if type(enabled) is not bool:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
if self.original_enabled is None:self.original_enabled=self.enabled()
|
||||
self.route=None;self.lease_network=None;self.last_lease=0
|
||||
if enabled:
|
||||
android('ip','link','set','wlan0','up')
|
||||
if not Path(WPA).exists():
|
||||
android('setprop','ctl.start','wpa_supplicant')
|
||||
for _ in range(30):
|
||||
if Path(WPA).exists():break
|
||||
time.sleep(.1)
|
||||
command('RECONNECT')
|
||||
else:
|
||||
command('DISCONNECT',False)
|
||||
android('ip','link','set','wlan0','down')
|
||||
return self.state()
|
||||
|
||||
def connect(self,value):
|
||||
if not self.enabled():raise RuntimeError('WIFI_DISABLED')
|
||||
if value['operation']=='connectHidden':
|
||||
ssid=value.get('ssid');mode=value.get('security')
|
||||
else:
|
||||
target=next((n for n in self.last_scan if n['id']==value.get('id')),None)
|
||||
if target is None:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
ssid,mode=target['ssid'],target['security']
|
||||
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
if mode not in ('open','wpa-psk'):raise ValueError('WIFI_UNSUPPORTED_SECURITY')
|
||||
password=value.get('password','')
|
||||
if not isinstance(password,str):raise ValueError('WIFI_INVALID_PASSWORD')
|
||||
saved=next((n for n in self.known() if n['ssid']==ssid and n['security']==mode),None)
|
||||
created=False
|
||||
if saved and not password: ident=saved['id']
|
||||
else:
|
||||
if mode=='wpa-psk':
|
||||
if re.fullmatch('[a-fA-F0-9]{64}',password):psk=password.lower()
|
||||
elif 8<=len(password.encode())<=63:psk=hashlib.pbkdf2_hmac('sha1',password.encode(),ssid.encode(),4096,32).hex()
|
||||
else:raise ValueError('WIFI_INVALID_PASSWORD')
|
||||
ident=command('ADD_NETWORK')
|
||||
if not ident.isdigit():raise RuntimeError('WIFI_UNAVAILABLE')
|
||||
created=True
|
||||
try:
|
||||
if created:
|
||||
command('SET_NETWORK '+ident+' ssid '+ssid.encode().hex())
|
||||
command('SET_NETWORK '+ident+' key_mgmt '+('WPA-PSK' if mode=='wpa-psk' else 'NONE'))
|
||||
if mode=='wpa-psk':command('SET_NETWORK '+ident+' psk '+psk)
|
||||
if value['operation']=='connectHidden':command('SET_NETWORK '+ident+' scan_ssid 1')
|
||||
command('SELECT_NETWORK '+ident)
|
||||
deadline=time.monotonic()+20
|
||||
while time.monotonic()<deadline:
|
||||
if self.stopping:raise RuntimeError('WIFI_UNAVAILABLE')
|
||||
state=self.state()
|
||||
if state['state']=='connected' and state['network']['ssid']==ssid:
|
||||
self.lease_network=None;self.last_lease=0
|
||||
self.lease(state)
|
||||
while time.monotonic()<deadline and not self.stopping:
|
||||
if android('getprop','dhcp.wlan0.result')=='ok':break
|
||||
time.sleep(.25)
|
||||
else:raise RuntimeError('WIFI_DHCP_FAILED')
|
||||
self.last_lease=0;self.lease(state)
|
||||
if created and command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
|
||||
return self.state()
|
||||
time.sleep(.25)
|
||||
raise RuntimeError('WIFI_CONNECTION_FAILED')
|
||||
except Exception:
|
||||
if created:command('REMOVE_NETWORK '+ident,False)
|
||||
command('ENABLE_NETWORK all',False);command('RECONNECT',False)
|
||||
raise
|
||||
|
||||
def lease(self,state):
|
||||
if time.monotonic()-self.last_lease<3:return
|
||||
self.last_lease=time.monotonic()
|
||||
if state['state']!='connected':
|
||||
self.lease_network=None;self.route=None
|
||||
return
|
||||
network_id=(state['network']['ssid'],state['network'].get('savedId'))
|
||||
if network_id!=self.lease_network:
|
||||
self.lease_network=network_id;self.route=None
|
||||
# init.qcom.rc supplies -ABKLG but no interface. ctl.restart drops
|
||||
# the dynamic argument; ctl.start must explicitly append wlan0.
|
||||
android('setprop','ctl.stop','dhcpcd_wlan0')
|
||||
for _ in range(20):
|
||||
if android('getprop','init.svc.dhcpcd_wlan0')=='stopped':break
|
||||
time.sleep(.1)
|
||||
android('setprop','dhcp.wlan0.result','')
|
||||
android('setprop','ctl.start','dhcpcd_wlan0:wlan0')
|
||||
return
|
||||
if android('getprop','dhcp.wlan0.result')!='ok':return
|
||||
servers=[]
|
||||
for name in ('dns1','dns2'):
|
||||
try: servers.append(str(ipaddress.ip_address(android('getprop','dhcp.wlan0.'+name))))
|
||||
except ValueError: pass
|
||||
if servers:
|
||||
content=''.join('nameserver '+ip+'\n' for ip in dict.fromkeys(servers))+'options timeout:2 attempts:2\n'
|
||||
path=Path('/etc/resolv.conf')
|
||||
if not path.exists() or path.read_text()!=content:
|
||||
temporary=path.with_suffix('.tmp');temporary.write_text(content);temporary.chmod(0o644);temporary.replace(path)
|
||||
# glibc does not use Android netd's per-socket routing marks. Install a
|
||||
# dedicated table only for unmarked browser uid 2000; retain vendor tables.
|
||||
try:
|
||||
addr=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.ipaddress'))
|
||||
gateway=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.gateway'))
|
||||
mask=android('getprop','dhcp.wlan0.mask')
|
||||
network=ipaddress.IPv4Network(str(addr)+'/'+mask,strict=False)
|
||||
if gateway not in network:return
|
||||
except ValueError:return
|
||||
route=(str(network),str(gateway))
|
||||
if route==self.route and android('ip','route','show','table','21052'):return
|
||||
if not self.rule_owned:
|
||||
if android('ip','route','show','table','21052') or '21900:' in android('ip','rule'):return
|
||||
android('ip','rule','add','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052')
|
||||
self.rule_owned=True
|
||||
android('ip','route','flush','table','21052')
|
||||
android('ip','route','add',route[0],'dev','wlan0','table','21052')
|
||||
android('ip','route','add','default','via',route[1],'dev','wlan0','table','21052')
|
||||
self.route=route
|
||||
|
||||
def publish(self):
|
||||
try:
|
||||
state=self.state();self.lease(state)
|
||||
except (OSError,RuntimeError,subprocess.SubprocessError):
|
||||
state={'available':False,'enabled':False,'state':'unavailable','network':None}
|
||||
STATE.parent.mkdir(parents=True,exist_ok=True)
|
||||
temporary=STATE.with_suffix('.tmp');temporary.write_text(json.dumps(state));temporary.chmod(0o644);temporary.replace(STATE)
|
||||
|
||||
def request(self,value):
|
||||
if not isinstance(value,dict):raise ValueError('WIFI_INVALID_REQUEST')
|
||||
op=value.get('operation')
|
||||
if op=='status':return self.state()
|
||||
if op=='scan':return self.scan()
|
||||
if op=='known':return {'networks':self.known()}
|
||||
if op=='setEnabled':return self.set_enabled(value.get('enabled'))
|
||||
if op in ('connect','connectHidden'):return self.connect(value)
|
||||
if op=='disconnect':command('DISCONNECT');return self.state()
|
||||
if op=='forget':
|
||||
ident=value.get('id')
|
||||
if not isinstance(ident,str) or not ident.isdigit() or len(ident)>6:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
command('REMOVE_NETWORK '+ident)
|
||||
if command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
|
||||
return True
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
def restore(self):
|
||||
if self.original_enabled is not None:
|
||||
try:self.set_enabled(self.original_enabled)
|
||||
except Exception:pass
|
||||
if self.rule_owned:
|
||||
android('ip','rule','del','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052',check=False)
|
||||
android('ip','route','flush','table','21052',check=False)
|
||||
resolver=Path('/etc/resolv.conf')
|
||||
if self.initial_resolver is None:resolver.unlink(missing_ok=True)
|
||||
else:resolver.write_bytes(self.initial_resolver)
|
||||
|
||||
|
||||
def receive(conn, limit=8192):
|
||||
data=b''
|
||||
while b'\n' not in data:
|
||||
part=conn.recv(4096)
|
||||
if not part:break
|
||||
data+=part
|
||||
if len(data)>limit:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:]==['--client']:
|
||||
raw=sys.stdin.buffer.read(8193)
|
||||
if len(raw)>8192:raise ValueError('WIFI_INVALID_REQUEST')
|
||||
value=json.loads(raw)
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
|
||||
conn.settimeout(40);conn.connect(SOCKET);conn.sendall(json.dumps(value).encode()+b'\n')
|
||||
print(json.dumps(receive(conn,65536)))
|
||||
return
|
||||
if sys.argv[1:] or os.getuid()!=0:raise SystemExit('Root broker or --client required')
|
||||
os.umask(0o022);wifi=Wifi();running=True
|
||||
def stop(*_):
|
||||
nonlocal running
|
||||
running=False;wifi.stopping=True
|
||||
signal.signal(signal.SIGTERM,stop)
|
||||
Path(SOCKET).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.settimeout(1)
|
||||
print('Nokia Wi-Fi broker ready',flush=True)
|
||||
try:
|
||||
while running:
|
||||
wifi.publish()
|
||||
try:conn,_=server.accept()
|
||||
except socket.timeout:continue
|
||||
with conn:
|
||||
conn.settimeout(2)
|
||||
_,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
||||
if uid not in (0,2000):continue
|
||||
try:reply={'result':wifi.request(receive(conn))}
|
||||
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
|
||||
reply={'error':str(error) if str(error) in ERRORS else 'WIFI_UNAVAILABLE'}
|
||||
try:conn.sendall(json.dumps(reply).encode()+b'\n')
|
||||
except OSError:pass
|
||||
finally:
|
||||
wifi.restore();Path(SOCKET).unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
40
tundra/devices/beatles/xorg.conf
Normal file
40
tundra/devices/beatles/xorg.conf
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
Section "ServerFlags"
|
||||
Option "AutoAddDevices" "false"
|
||||
Option "AutoEnableDevices" "false"
|
||||
Option "DontVTSwitch" "true"
|
||||
Option "BlankTime" "0"
|
||||
Option "StandbyTime" "0"
|
||||
Option "SuspendTime" "0"
|
||||
Option "OffTime" "0"
|
||||
EndSection
|
||||
|
||||
Section "Module"
|
||||
Disable "glx"
|
||||
EndSection
|
||||
|
||||
Section "Device"
|
||||
Identifier "NokiaFramebuffer"
|
||||
Driver "fbdev"
|
||||
Option "fbdev" "/dev/fb0"
|
||||
Option "ShadowFB" "true"
|
||||
EndSection
|
||||
|
||||
Section "Monitor"
|
||||
Identifier "InternalDisplay"
|
||||
EndSection
|
||||
|
||||
Section "Screen"
|
||||
Identifier "InternalScreen"
|
||||
Device "NokiaFramebuffer"
|
||||
Monitor "InternalDisplay"
|
||||
DefaultDepth 16
|
||||
SubSectionSub "Display"
|
||||
Depth 16
|
||||
Virtual 240 320
|
||||
EndSubSection
|
||||
EndSection
|
||||
|
||||
Section "ServerLayout"
|
||||
Identifier "Nokia"
|
||||
Screen "InternalScreen"
|
||||
EndSection
|
||||
Loading…
Reference in a new issue