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
|
|
@ -45,7 +45,7 @@ if [ -z "${SYSTEM_IMAGE:-}" ] || [ "${APPLY_NATIVE_CONFIG:-0}" = 1 ]; then
|
|||
done
|
||||
fi
|
||||
rm -f /newroot/etc/tundra/hardware-enabled /newroot/etc/systemd/system/tundra.target.wants/ofono.service /newroot/etc/systemd/system/tundra.target.wants/tundra-audio.service /newroot/etc/tundra/network-enabled /newroot/etc/systemd/system/tundra.target.wants/NetworkManager.service /newroot/etc/systemd/system/tundra.target.wants/tundra-wifi.service
|
||||
tar -xf /native-config.tar -C /newroot
|
||||
tar -xJf /native-config.tar -C /newroot
|
||||
if [ -d /run/tundra-identity ]; then
|
||||
for identity in /run/tundra-identity/*; do
|
||||
[ -f "$identity" ] && cp -p "$identity" /newroot/etc/
|
||||
|
|
@ -53,7 +53,7 @@ if [ -z "${SYSTEM_IMAGE:-}" ] || [ "${APPLY_NATIVE_CONFIG:-0}" = 1 ]; then
|
|||
chmod 644 /newroot/etc/machine-id
|
||||
fi
|
||||
fi
|
||||
if [ -f /shared-ui.tar ]; then tar -xf /shared-ui.tar -C /newroot; fi
|
||||
if [ -f /shared-ui.tar ]; then tar -xJf /shared-ui.tar -C /newroot; fi
|
||||
# A previous successful session must never count as proof of this boot.
|
||||
for log in test.json home.png session.pid gecko.log server.log compositor.log network-gecko.json wifi-ui.json; do
|
||||
rm -f "/newroot/opt/vulpes/logs/$log"
|
||||
|
|
|
|||
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
|
||||
|
|
@ -341,6 +341,18 @@ export async function run(win) {
|
|||
}
|
||||
}
|
||||
async function requestedUiChecks() {
|
||||
const regressionMarker=`${root}/logs/test-ticket-regressions`;
|
||||
if(await IOUtils.exists(regressionMarker)) {
|
||||
await IOUtils.remove(regressionMarker);
|
||||
const {auditTickets}=ChromeUtils.importESModule(`resource://vulpes/host/TicketRegressionAudit.sys.mjs?run=${Date.now()}`);
|
||||
await auditTickets({win,evaluate,wait,tap,sleep,root});
|
||||
}
|
||||
const browserMarker=`${root}/logs/test-browser-visual`;
|
||||
if(await IOUtils.exists(browserMarker)) {
|
||||
await IOUtils.remove(browserMarker);
|
||||
const {auditBrowserVisual}=ChromeUtils.importESModule(`resource://vulpes/host/BrowserVisualAudit.sys.mjs?run=${Date.now()}`);
|
||||
await auditBrowserVisual({win,evaluate,sleep,root});
|
||||
}
|
||||
const appearanceMarker = `${root}/logs/test-appearance`;
|
||||
if (await IOUtils.exists(appearanceMarker)) {
|
||||
await IOUtils.remove(appearanceMarker);
|
||||
|
|
@ -382,6 +394,14 @@ export async function run(win) {
|
|||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/appearance.json`,result);
|
||||
}
|
||||
const mediaMarker=`${root}/logs/test-media-feedback`;
|
||||
if(await IOUtils.exists(mediaMarker)) {
|
||||
await IOUtils.remove(mediaMarker);
|
||||
const {auditMediaFeedback}=ChromeUtils.importESModule('resource://vulpes/host/MediaFeedbackAudit.sys.mjs');
|
||||
const save=data=>IOUtils.writeJSON(`${root}/logs/media-feedback.json`,{bootId:report.bootId,...data});
|
||||
try {await auditMediaFeedback({win,evaluate,wait,tap,sleep,save});}
|
||||
catch(error){await save({passed:false,error:String(error)});}
|
||||
}
|
||||
const followupMarker=`${root}/logs/test-settings-followup`;
|
||||
if(await IOUtils.exists(followupMarker)) {
|
||||
await IOUtils.remove(followupMarker);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ def main():
|
|||
MOZ_ENABLE_WAYLAND='1', EGL_PLATFORM='wayland', GDK_GL='gles',
|
||||
VULPES_FULLSCREEN='1', VULPES_NATIVE_TOUCH='1',
|
||||
VULPES_WORKSPACE_ROOT=str(ROOT))
|
||||
if env.get('VULPES_TUNDRA') == '1':
|
||||
env['VULPES_SOCKET_CONTROL'] = '1'
|
||||
if args.hybris_egl:
|
||||
import hashlib
|
||||
graphics = ROOT/'trial/graphics'
|
||||
|
|
@ -174,8 +176,8 @@ Services.obs.addObserver({observe(win) {
|
|||
raise RuntimeError('Gaia server startup timeout')
|
||||
with (logs/'gecko.log').open('w') as output:
|
||||
debug_args=['--remote-allow-system-access','-marionette','-new-window','about:blank'] if env.get('TUNDRA_LOCAL_DEBUGGER')=='1' else []
|
||||
firefox = subprocess.Popen([str(runtime/'firefox'), *debug_args, '-purgecaches', '-no-remote',
|
||||
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml'],
|
||||
firefox = subprocess.Popen([str(runtime/'firefox'), '-purgecaches', '-no-remote',
|
||||
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml', *debug_args],
|
||||
env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
processes.append(firefox)
|
||||
result = firefox.wait()
|
||||
|
|
|
|||
71
tundra/probes/armv7-gecko-launch.c
Normal file
71
tundra/probes/armv7-gecko-launch.c
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
/* Temporary ARM EABI runtime probe. Private mount namespace; Gecko runs as shell. */
|
||||
#ifndef ROOT
|
||||
#define ROOT "/data/local/tmp/vulpes-gecko-armhf/rootfs"
|
||||
#endif
|
||||
#ifndef GECKO
|
||||
#define GECKO "/usr/lib/firefox-esr/firefox-esr"
|
||||
#endif
|
||||
static long call(long n, long a, long b, long c, long d, long e) {
|
||||
register long r0 __asm__("r0") = a;
|
||||
register long r1 __asm__("r1") = b;
|
||||
register long r2 __asm__("r2") = c;
|
||||
register long r3 __asm__("r3") = d;
|
||||
register long r4 __asm__("r4") = e;
|
||||
register long r7 __asm__("r7") = n;
|
||||
__asm__ volatile("svc 0" : "+r"(r0) : "r"(r1), "r"(r2), "r"(r3), "r"(r4), "r"(r7) : "memory", "cc");
|
||||
return r0;
|
||||
}
|
||||
static void output(const char *s) {
|
||||
long len=0; while(s[len])len++;
|
||||
call(4,2,(long)s,len,0,0);
|
||||
}
|
||||
static void check(long result, const char *operation) {
|
||||
if(result>=0)return;
|
||||
output(operation);output(" failed, errno=");
|
||||
char buf[16];long i=15;buf[i]=0;result=-result;
|
||||
do {buf[--i]='0'+result%10;result/=10;}while(result);
|
||||
output(buf+i);output("\n");call(1,1,0,0,0,0);for(;;){}
|
||||
}
|
||||
static void device(const char *path, long minor) {
|
||||
check(call(14,(long)path,8192|0666,256|minor,0,0),"mknod");
|
||||
check(call(15,(long)path,0666,0,0,0),"device permissions");
|
||||
}
|
||||
__attribute__((noreturn)) void run(long *stack) {
|
||||
long argc=stack[0]; char **argv=(char **)(stack+1);
|
||||
int render=argc==2 && argv[1][0]=='r';
|
||||
if(argc!=2 || (!render && argv[1][0]!='v')) {
|
||||
output("Use version or render\n");call(1,2,0,0,0,0);for(;;){}
|
||||
}
|
||||
long fd=call(5,(long)"/proc/self/oom_score_adj",1,0,0,0);
|
||||
check(fd,"oom_score_adj open");check(call(4,fd,(long)"1000",4,0,0),"oom_score_adj write");call(6,fd,0,0,0,0);
|
||||
check(call(337,0x20000|0x40000000,0,0,0,0),"unshare mount and network namespaces");
|
||||
check(call(21,0,(long)"/",0,16384|262144,0),"private mount propagation");
|
||||
check(call(21,(long)"/proc",(long)ROOT "/proc",0,4096,0),"bind proc");
|
||||
/* /data is nodev: character devices need a private tmpfs mount. */
|
||||
check(call(21,(long)"tmpfs",(long)ROOT "/dev",(long)"tmpfs",2,(long)"size=1m,mode=0755"),"private devices");
|
||||
device(ROOT "/dev/null",3);device(ROOT "/dev/zero",5);
|
||||
device(ROOT "/dev/random",8);device(ROOT "/dev/urandom",9);
|
||||
check(call(39,(long)ROOT "/dev/shm",01777,0,0,0),"shared memory directory");
|
||||
check(call(21,(long)"tmpfs",(long)ROOT "/dev/shm",(long)"tmpfs",2|4,(long)"size=64m,mode=1777"),"private shared memory");
|
||||
check(call(61,(long)ROOT,0,0,0,0),"chroot");
|
||||
check(call(12,(long)"/",0,0,0,0),"chdir");
|
||||
check(call(206,0,0,0,0,0),"drop supplementary groups");
|
||||
check(call(214,2000,0,0,0,0),"setgid");
|
||||
check(call(213,2000,0,0,0,0),"setuid");
|
||||
check(call(172,38,1,0,0,0),"no new privileges");
|
||||
char *env[]={"HOME=/work", "TMPDIR=/tmp", "MOZ_HEADLESS=1",
|
||||
"MOZ_HEADLESS_WIDTH=240", "MOZ_HEADLESS_HEIGHT=320", "LANG=C",
|
||||
"FONTCONFIG_PATH=/etc/fonts", "MOZ_CRASHREPORTER_DISABLE=1",
|
||||
"LD_LIBRARY_PATH=/opt/firefox:/usr/lib/firefox-esr:/lib/arm-linux-gnueabihf:/usr/lib/arm-linux-gnueabihf",0};
|
||||
char *versionArgs[]={GECKO,"--version",0};
|
||||
char *renderArgs[]={GECKO,"--headless","--no-remote",
|
||||
"--profile","/work/profile","--window-size","240,320",
|
||||
"--screenshot","/work/gecko.png","file:///work/test.html",0};
|
||||
output("VULPES ARMv7 Gecko probe: isolated mounts/network, uid=2000\n");
|
||||
check(call(11,(long)versionArgs[0],(long)(render?renderArgs:versionArgs),(long)env,0,0),"exec Gecko");
|
||||
call(1,1,0,0,0,0);for(;;){}
|
||||
}
|
||||
__attribute__((naked,noreturn)) void _start(void) {
|
||||
__asm__ volatile("mov r0, sp\nb run");
|
||||
}
|
||||
48
tundra/probes/armv7-smoke.c
Normal file
48
tundra/probes/armv7-smoke.c
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
/* Linux EABI smoke test: no libc, filesystem writes, or privileged operations. */
|
||||
static long syscall3(long number, long a, long b, long c) {
|
||||
register long r0 __asm__("r0") = a;
|
||||
register long r1 __asm__("r1") = b;
|
||||
register long r2 __asm__("r2") = c;
|
||||
register long r7 __asm__("r7") = number;
|
||||
__asm__ volatile("svc 0" : "+r"(r0) : "r"(r1), "r"(r2), "r"(r7) : "memory", "cc");
|
||||
return r0;
|
||||
}
|
||||
|
||||
static void print(const char *text) {
|
||||
long length = 0;
|
||||
while (text[length]) ++length;
|
||||
while (length > 0) {
|
||||
long count = syscall3(4, 1, (long)text, length);
|
||||
if (count <= 0) return;
|
||||
text += count;
|
||||
length -= count;
|
||||
}
|
||||
}
|
||||
|
||||
static void number(unsigned long value) {
|
||||
char buffer[16];
|
||||
int end = 15;
|
||||
buffer[end] = 0;
|
||||
do {
|
||||
buffer[--end] = '0' + value % 10;
|
||||
value /= 10;
|
||||
} while (value);
|
||||
print(buffer + end);
|
||||
}
|
||||
|
||||
__attribute__((noreturn)) void _start(void) {
|
||||
struct { char system[65], node[65], release[65], version[65], machine[65], domain[65]; } uts;
|
||||
long status = syscall3(122, (long)&uts, 0, 0);
|
||||
print("VULPES_ARMV7_PROBE=1\n");
|
||||
print("This is a native diagnostic, not Gecko or a replacement OS.\nuid=");
|
||||
number((unsigned long)syscall3(24, 0, 0, 0));
|
||||
print("\n");
|
||||
if (status == 0) {
|
||||
print("kernel="); print(uts.release);
|
||||
print("\narchitecture="); print(uts.machine); print("\n");
|
||||
}
|
||||
print(status == 0 ? "result=PASS\n" : "result=FAIL\n");
|
||||
syscall3(1, status == 0 ? 0 : 1, 0, 0);
|
||||
for (;;) {}
|
||||
}
|
||||
63
tundra/probes/beatles-control-client.c
Normal file
63
tundra/probes/beatles-control-client.c
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
/* Unprivileged one-request relay. The root broker still validates every command. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define REQUEST_LIMIT 8192
|
||||
#define RESPONSE_LIMIT 65536
|
||||
|
||||
static int write_all(int fd, const char *data, size_t size) {
|
||||
while (size) {
|
||||
ssize_t count=write(fd,data,size);
|
||||
if (count<0 && errno==EINTR) continue;
|
||||
if (count<=0) return -1;
|
||||
data+=count;size-=(size_t)count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc,char **argv) {
|
||||
const char *path;
|
||||
if (argc!=2) return 2;
|
||||
if (!strcmp(argv[1],"--radio")) path="/work/phone-control.sock";
|
||||
else if (!strcmp(argv[1],"--wifi")) path="/work/wifi-control.sock";
|
||||
else return 2;
|
||||
signal(SIGPIPE,SIG_IGN);
|
||||
/* Bound the whole exchange, including a truncated stdin or an unresponsive broker. */
|
||||
alarm(40);
|
||||
char request[REQUEST_LIMIT+1];size_t used=0;
|
||||
for (;;) {
|
||||
ssize_t count=read(STDIN_FILENO,request+used,sizeof(request)-used);
|
||||
if (count<0 && errno==EINTR) continue;
|
||||
if (count<0) return 1;
|
||||
if (!count) break;
|
||||
used+=(size_t)count;
|
||||
if (used>REQUEST_LIMIT) return 2;
|
||||
}
|
||||
if (!used || memchr(request,'\n',used) || memchr(request,'\r',used)) return 2;
|
||||
int fd=socket(AF_UNIX,SOCK_STREAM,0);
|
||||
if (fd<0) return 1;
|
||||
struct sockaddr_un address={.sun_family=AF_UNIX};
|
||||
memcpy(address.sun_path,path,strlen(path)+1);
|
||||
if (connect(fd,(struct sockaddr *)&address,sizeof(address)) ||
|
||||
write_all(fd,request,used) || write_all(fd,"\n",1)) {close(fd);return 1;}
|
||||
char response[RESPONSE_LIMIT+1];used=0;
|
||||
for (;;) {
|
||||
ssize_t count=read(fd,response+used,sizeof(response)-used);
|
||||
if (count<0 && errno==EINTR) continue;
|
||||
if (count<=0) {close(fd);return 1;}
|
||||
used+=(size_t)count;
|
||||
if (used>RESPONSE_LIMIT) {close(fd);return 1;}
|
||||
char *end=memchr(response,'\n',used);
|
||||
if (end) {
|
||||
int result=write_all(STDOUT_FILENO,response,(size_t)(end-response)+1);
|
||||
close(fd);return result?1:0;
|
||||
}
|
||||
}
|
||||
}
|
||||
463
tundra/probes/beatles-display-session.c
Normal file
463
tundra/probes/beatles-display-session.c
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#define _GNU_SOURCE
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <linux/fb.h>
|
||||
#include <linux/videodev2.h>
|
||||
#include <net/if.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/wait.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define BASE "/data/local/vulpes-beatles"
|
||||
#define ROOT BASE "/rootfs"
|
||||
#include "beatles-lid.h"
|
||||
static volatile sig_atomic_t interrupted;
|
||||
static int phone_controls;
|
||||
static void interrupt(int sig) { interrupted = sig; }
|
||||
static void inventory_camera_nodes(void) {
|
||||
static const int minors[] = {0, 1, 32, 33};
|
||||
for (unsigned i = 0; i < sizeof(minors)/sizeof(minors[0]); i++) {
|
||||
char path[32];
|
||||
snprintf(path, sizeof(path), "/dev/video%d", minors[i]);
|
||||
int fd = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC);
|
||||
if (fd < 0) { printf("Camera probe %s: open failed (%d)\n", path, errno); continue; }
|
||||
struct v4l2_capability caps = {0};
|
||||
if (ioctl(fd, VIDIOC_QUERYCAP, &caps)) {
|
||||
printf("Camera probe %s: VIDIOC_QUERYCAP failed (%d)\n", path, errno);
|
||||
close(fd); continue;
|
||||
}
|
||||
uint32_t device_caps = (caps.capabilities & V4L2_CAP_DEVICE_CAPS) ? caps.device_caps : caps.capabilities;
|
||||
printf("Camera probe %s: driver=%s card=%s caps=%08x\n", path, caps.driver, caps.card, device_caps);
|
||||
for (unsigned type_index = 0; type_index < 2; type_index++) {
|
||||
uint32_t type = type_index ? V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE : V4L2_BUF_TYPE_VIDEO_CAPTURE;
|
||||
if (!(device_caps & (type_index ? V4L2_CAP_VIDEO_CAPTURE_MPLANE : V4L2_CAP_VIDEO_CAPTURE))) continue;
|
||||
for (uint32_t index = 0; index < 16; index++) {
|
||||
struct v4l2_fmtdesc format = {.index = index, .type = type};
|
||||
if (ioctl(fd, VIDIOC_ENUM_FMT, &format)) break;
|
||||
printf("Camera probe %s: capture format=%c%c%c%c flags=%08x\n", path,
|
||||
format.pixelformat & 0xff, (format.pixelformat >> 8) & 0xff,
|
||||
(format.pixelformat >> 16) & 0xff, (format.pixelformat >> 24) & 0xff, format.flags);
|
||||
}
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
}
|
||||
static int copy_camera_dump(const char *directory, const char *extension,
|
||||
const char *destination, const char *label) {
|
||||
DIR *camera_dir = opendir(directory);
|
||||
if (!camera_dir) return 0;
|
||||
struct dirent *entry;
|
||||
size_t extension_length = strlen(extension);
|
||||
int copied = 0;
|
||||
while ((entry = readdir(camera_dir))) {
|
||||
size_t name_length = strlen(entry->d_name);
|
||||
if (name_length <= extension_length ||
|
||||
strcmp(entry->d_name + name_length - extension_length, extension)) continue;
|
||||
char source[256];
|
||||
snprintf(source, sizeof(source), "%s/%s", directory, entry->d_name);
|
||||
int in = open(source, O_RDONLY | O_CLOEXEC);
|
||||
int out = open(destination, O_CREAT | O_TRUNC | O_WRONLY | O_CLOEXEC, 0644);
|
||||
if (in >= 0 && out >= 0) {
|
||||
char buffer[65536]; ssize_t bytes;
|
||||
while ((bytes = read(in, buffer, sizeof(buffer))) > 0) {
|
||||
ssize_t offset = 0;
|
||||
while (offset < bytes) {
|
||||
ssize_t written = write(out, buffer + offset, (size_t)(bytes - offset));
|
||||
if (written <= 0) break;
|
||||
offset += written;
|
||||
}
|
||||
if (offset != bytes) break;
|
||||
}
|
||||
struct stat copied_stat;
|
||||
if (!fstat(out, &copied_stat) && copied_stat.st_size > 0) {
|
||||
printf("QCamera captured %s %s (%ld bytes)\n", label, entry->d_name,
|
||||
(long)copied_stat.st_size);
|
||||
copied = 1;
|
||||
}
|
||||
fchmod(out, 0644); close(out);
|
||||
} else {
|
||||
if (out >= 0) close(out);
|
||||
if (in >= 0) close(in);
|
||||
}
|
||||
if (in >= 0) close(in);
|
||||
if (copied) break;
|
||||
}
|
||||
closedir(camera_dir);
|
||||
return copied;
|
||||
}
|
||||
static void require(int result, const char *what) {
|
||||
if (result < 0) { perror(what); _exit(1); }
|
||||
}
|
||||
static void node(const char *path, int major, int minor, mode_t mode) {
|
||||
require(mknod(path, S_IFCHR | mode, makedev(major, minor)), path);
|
||||
require(chmod(path, mode), "chmod device");
|
||||
}
|
||||
static int android_service(const char *operation) {
|
||||
pid_t child = fork();
|
||||
if (!child) { execl(operation, operation, "b2g", NULL); _exit(127); }
|
||||
int status;
|
||||
return child > 0 && waitpid(child, &status, 0) == child && WIFEXITED(status)
|
||||
? WEXITSTATUS(status) : -1;
|
||||
}
|
||||
/* nsIProcess may pass open descriptors from B2G. Keeping a RIL socket alive
|
||||
* after stopping B2G prevents the vendor daemon from accepting our reader. */
|
||||
static int close_inherited(int *sockets) {
|
||||
DIR *dir = opendir("/proc/self/fd");
|
||||
if (!dir) return -1;
|
||||
int count = 0;
|
||||
struct dirent *entry;
|
||||
while ((entry = readdir(dir))) {
|
||||
char *end, path[64], target[128];
|
||||
long fd = strtol(entry->d_name, &end, 10);
|
||||
if (*end || fd < 3 || fd == dirfd(dir)) continue;
|
||||
snprintf(path,sizeof(path),"/proc/self/fd/%ld",fd);
|
||||
ssize_t n = readlink(path,target,sizeof(target)-1);
|
||||
if (n > 0) {target[n] = 0; if (!strncmp(target,"socket:[",8)) (*sockets)++;}
|
||||
if (!close((int)fd)) count++;
|
||||
}
|
||||
closedir(dir);
|
||||
return count;
|
||||
}
|
||||
static void clean_runtime(void) {
|
||||
DIR *dir = opendir("/proc");
|
||||
if (!dir) return;
|
||||
struct dirent *entry;
|
||||
while ((entry = readdir(dir))) {
|
||||
char *end, path[128], target[512];
|
||||
long pid = strtol(entry->d_name, &end, 10);
|
||||
if (*end || pid <= 1 || pid == getpid()) continue;
|
||||
snprintf(path, sizeof(path), "/proc/%ld/root", pid);
|
||||
ssize_t n = readlink(path, target, sizeof(target) - 1);
|
||||
if (n > 0) {
|
||||
target[n] = 0;
|
||||
if (!strcmp(target, ROOT)) kill((pid_t)pid, SIGKILL);
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
}
|
||||
static void capture_framebuffer(int fd) {
|
||||
struct fb_var_screeninfo v;
|
||||
struct fb_fix_screeninfo f;
|
||||
if (ioctl(fd,FBIOGET_VSCREENINFO,&v) || ioctl(fd,FBIOGET_FSCREENINFO,&f)) return;
|
||||
if (v.xres != 240 || v.yres != 320 || v.bits_per_pixel != 16) return;
|
||||
size_t length=(size_t)(v.yoffset+v.yres)*f.line_length;
|
||||
if (length>f.smem_len || (v.xoffset+v.xres)*2>f.line_length) return;
|
||||
unsigned char *pixels=mmap(NULL,length,PROT_READ,MAP_SHARED,fd,0);
|
||||
if (pixels==MAP_FAILED) { perror("framebuffer capture mmap");return; }
|
||||
FILE *image=fopen(BASE "/framebuffer.ppm","wb");
|
||||
if (image) {
|
||||
fchmod(fileno(image),0644);fprintf(image,"P6\n%u %u\n255\n",v.xres,v.yres);
|
||||
unsigned char row[240*3];
|
||||
for (unsigned y=0;y<v.yres;y++) {
|
||||
for (unsigned x=0;x<v.xres;x++) {
|
||||
uint16_t pixel;
|
||||
memcpy(&pixel,pixels+(y+v.yoffset)*f.line_length+(x+v.xoffset)*2,2);
|
||||
const struct fb_bitfield channels[]={v.red,v.green,v.blue};
|
||||
for (unsigned c=0;c<3;c++) {
|
||||
unsigned mask=(1u<<channels[c].length)-1;
|
||||
row[x*3+c]=mask?((pixel>>channels[c].offset)&mask)*255/mask:0;
|
||||
}
|
||||
}
|
||||
fwrite(row,1,sizeof(row),image);
|
||||
}
|
||||
fclose(image);puts("Framebuffer captured after Gaia screenshot");
|
||||
}
|
||||
munmap(pixels,length);
|
||||
}
|
||||
/* Hardware brokers own the vendor sockets; Gecko never receives their mounts. */
|
||||
static void radio_inside(void) {
|
||||
require(setsid(), "radio setsid");
|
||||
require(unshare(CLONE_NEWNS), "radio mount namespace");
|
||||
require(mount(NULL,"/",NULL,MS_REC | MS_PRIVATE,NULL),"radio private mounts");
|
||||
require(mount("tmpfs",ROOT "/dev","tmpfs",MS_NOSUID,"size=1m,mode=0755"),"radio devices");
|
||||
node(ROOT "/dev/null",1,3,0666);node(ROOT "/dev/urandom",1,9,0666);
|
||||
require(mount("/sys",ROOT "/sys",NULL,MS_BIND,NULL),"radio sys");
|
||||
require(mount(NULL,ROOT "/sys",NULL,MS_BIND | MS_REMOUNT | MS_RDONLY,NULL),"radio readonly sys");
|
||||
if (mkdir(ROOT "/work/radio-sockets",0700) && errno != EEXIST) _exit(1);
|
||||
require(mount("/dev/socket",ROOT "/work/radio-sockets",NULL,MS_BIND,NULL),"radio socket mount");
|
||||
if(phone_controls) {
|
||||
/* Keep /sys read-only. Expose only these controls to the root broker,
|
||||
* never to the browser's separate mount namespace. */
|
||||
if(mkdir(ROOT "/work/hardware-controls",0700) && errno!=EEXIST)_exit(1);
|
||||
require(chmod(ROOT "/work/hardware-controls",0700),"hardware permissions");
|
||||
const char *sources[]={"/sys/class/leds/lcd-backlight/brightness",
|
||||
"/sys/class/leds/lcd-backlight/max_brightness","/sys/class/timed_output/vibrator/enable"};
|
||||
const char *targets[]={ROOT "/work/hardware-controls/backlight",
|
||||
ROOT "/work/hardware-controls/backlight-max",ROOT "/work/hardware-controls/vibrator"};
|
||||
for(unsigned i=0;i<3;i++) {
|
||||
int control=open(targets[i],O_CREAT|O_WRONLY|O_CLOEXEC,0600);
|
||||
require(control,"hardware control file");close(control);
|
||||
require(mount(sources[i],targets[i],NULL,MS_BIND,NULL),"hardware control mount");
|
||||
}
|
||||
if(mkdir(ROOT "/dev/socket",0700) && errno!=EEXIST)_exit(1);
|
||||
const char *property_sources[]={"/dev/__properties__","/dev/socket/property_service"};
|
||||
const char *property_targets[]={ROOT "/dev/__properties__",ROOT "/dev/socket/property_service"};
|
||||
for(unsigned i=0;i<2;i++) {
|
||||
int target=open(property_targets[i],O_CREAT|O_WRONLY|O_CLOEXEC,0600);
|
||||
require(target,"property endpoint");close(target);
|
||||
require(mount(property_sources[i],property_targets[i],NULL,MS_BIND,NULL),"property endpoint mount");
|
||||
}
|
||||
struct stat binder;
|
||||
require(stat("/dev/binder",&binder),"binder device");
|
||||
node(ROOT "/dev/binder",major(binder.st_rdev),minor(binder.st_rdev),0600);
|
||||
if(mkdir(ROOT "/system",0755) && errno!=EEXIST)_exit(1);
|
||||
require(mount("/system",ROOT "/system",NULL,MS_BIND,NULL),"radio system");
|
||||
require(mount(NULL,ROOT "/system",NULL,MS_BIND|MS_REMOUNT|MS_RDONLY,NULL),"radio readonly system");
|
||||
}
|
||||
require(chroot(ROOT),"radio chroot");require(chdir("/"),"radio chdir");
|
||||
clearenv();setenv("PATH","/usr/bin:/bin",1);setenv("HOME","/work",1);
|
||||
setenv("VULPES_RIL_SOCKET_DIR","/work/radio-sockets",1);
|
||||
setenv("LD_LIBRARY_PATH","/usr/lib/arm-linux-gnueabihf:/lib/arm-linux-gnueabihf",1);
|
||||
int log=open("/work/radio-status.log",O_CREAT | O_TRUNC | O_WRONLY,0644);
|
||||
require(log,"radio log");require(fchmod(log,0644),"radio log mode");dup2(log,1);dup2(log,2);close(log);
|
||||
execl("/usr/bin/python3.11","python3.11",phone_controls?"/work/radio_control.py":"/work/radio_status.py",NULL);
|
||||
_exit(127);
|
||||
}
|
||||
static void inside(int seconds) {
|
||||
require(setsid(), "worker setsid");
|
||||
require(unshare(CLONE_NEWNS | (phone_controls ? 0 : CLONE_NEWNET)), "unshare");
|
||||
require(mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL), "private mounts");
|
||||
require(mount("/proc", ROOT "/proc", NULL, MS_BIND, NULL), "bind proc");
|
||||
require(mount("/sys", ROOT "/sys", NULL, MS_BIND, NULL), "bind sys");
|
||||
require(mount(NULL, ROOT "/sys", NULL, MS_BIND | MS_REMOUNT | MS_RDONLY, NULL), "readonly sys");
|
||||
require(mount("tmpfs", ROOT "/dev", "tmpfs", MS_NOSUID, "size=1m,mode=0755"), "devices");
|
||||
node(ROOT "/dev/null",1,3,0666); node(ROOT "/dev/zero",1,5,0666);
|
||||
node(ROOT "/dev/random",1,8,0666); node(ROOT "/dev/urandom",1,9,0666);
|
||||
node(ROOT "/dev/tty",5,0,0600); node(ROOT "/dev/tty0",4,0,0600);
|
||||
node(ROOT "/dev/tty1",4,1,0600); node(ROOT "/dev/fb0",29,0,0600);
|
||||
node(ROOT "/dev/fb1",29,1,0600);
|
||||
require(mkdir(ROOT "/dev/input",0755), "input directory");
|
||||
node(ROOT "/dev/input/event0",13,64,0600);
|
||||
node(ROOT "/dev/input/event2",13,66,0600);
|
||||
node(ROOT "/dev/input/event3",13,67,0600);
|
||||
require(mkdir(ROOT "/dev/shm",01777), "shm directory");
|
||||
require(mount("tmpfs", ROOT "/dev/shm", "tmpfs", MS_NOSUID | MS_NODEV,
|
||||
"size=64m,mode=1777"), "shared memory");
|
||||
int sock = socket(AF_INET,SOCK_DGRAM,0);
|
||||
require(sock,"loopback socket");
|
||||
struct ifreq request = {0}; strcpy(request.ifr_name,"lo");
|
||||
request.ifr_flags = IFF_UP | IFF_RUNNING;
|
||||
require(ioctl(sock,SIOCSIFFLAGS,&request),"loopback up"); close(sock);
|
||||
require(chroot(ROOT),"chroot"); require(chdir("/"),"chdir");
|
||||
clearenv(); setenv("PATH","/usr/bin:/bin",1); setenv("HOME","/work",1);
|
||||
setenv("VULPES_TRIAL_MANUAL",seconds >= 900 ? "1" : "0",1);
|
||||
setenv("VULPES_TUNDRA_RADIO",phone_controls?"1":"0",1);
|
||||
setenv("VULPES_TUNDRA_WIFI",phone_controls?"1":"0",1);
|
||||
setenv("LANG","C",1); setenv("DISPLAY",":0",1);
|
||||
setenv("LD_LIBRARY_PATH","/usr/lib/arm-linux-gnueabihf:/lib/arm-linux-gnueabihf",1);
|
||||
execl("/bin/sh","sh","/work/display-test.sh",NULL);
|
||||
perror("session script"); _exit(1);
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
if (getuid() || argc != 2) return 2;
|
||||
phone_controls=!strcmp(argv[1],"--phone-1800s") || !strcmp(argv[1],"--phone-900s") || !strcmp(argv[1],"--phone-180s");
|
||||
int seconds = !strcmp(argv[1],"--temporary-45s") ? 45 :
|
||||
!strcmp(argv[1],"--temporary-90s") ? 90 :
|
||||
(!strcmp(argv[1],"--temporary-180s") || !strcmp(argv[1],"--phone-180s")) ? 180 :
|
||||
(!strcmp(argv[1],"--interactive-900s") || !strcmp(argv[1],"--phone-900s")) ? 900 :
|
||||
(!strcmp(argv[1],"--interactive-1800s") || !strcmp(argv[1],"--phone-1800s")) ? 1800 : 0;
|
||||
if (!seconds) return 2;
|
||||
int inherited_sockets = 0;
|
||||
int inherited = close_inherited(&inherited_sockets);
|
||||
if (inherited < 0) return 6;
|
||||
int lock = open(BASE "/session.lock",O_CREAT | O_RDWR,0600);
|
||||
if (lock < 0 || flock(lock,LOCK_EX | LOCK_NB)) return 3;
|
||||
int fb = open("/dev/graphics/fb0",O_RDWR | O_CLOEXEC);
|
||||
struct fb_var_screeninfo original;
|
||||
if (fb < 0 || ioctl(fb,FBIOGET_VSCREENINFO,&original)) return 4;
|
||||
pid_t detached = fork();
|
||||
if (detached < 0) return 5;
|
||||
if (detached) return 0;
|
||||
require(setsid(),"watchdog setsid");
|
||||
const char *groups[] = {"/acct/cgroup.procs", "/dev/cpuctl/tasks"};
|
||||
char pidtext[32]; int length=snprintf(pidtext,sizeof(pidtext),"%d",getpid());
|
||||
for(unsigned i=0;i<sizeof(groups)/sizeof(groups[0]);i++) {
|
||||
int group=open(groups[i],O_WRONLY);
|
||||
require(group,"watchdog cgroup");
|
||||
require(write(group,pidtext,length),"detach watchdog cgroup"); close(group);
|
||||
}
|
||||
signal(SIGTERM,interrupt); signal(SIGINT,interrupt); signal(SIGHUP,SIG_IGN);
|
||||
int log = open(BASE "/display-session.log",O_CREAT | O_TRUNC | O_WRONLY,0644);
|
||||
require(log,"log"); fchmod(log,0644); dup2(log,1); dup2(log,2); close(log);
|
||||
setvbuf(stdout,NULL,_IONBF,0); setvbuf(stderr,NULL,_IONBF,0);
|
||||
printf("Closed inherited descriptors: %d, sockets: %d\n",inherited,inherited_sockets);
|
||||
inventory_camera_nodes();
|
||||
printf("Temporary display session: automatic KaiOS return in %d seconds\n",seconds);
|
||||
unlink(BASE "/stop-session");
|
||||
unlink(BASE "/framebuffer.ppm");
|
||||
unlink(ROOT "/work/gaia-native.png");
|
||||
unlink(ROOT "/work/gaia-audit.json");
|
||||
/* Camera output is also stored in the RAM-backed Gaia root. Never let a
|
||||
* previous run's image be mistaken for a frame from this session. */
|
||||
unlink(ROOT "/work/camera-frame.yuv");
|
||||
unlink(ROOT "/work/camera-photo.jpg");
|
||||
sleep(2);
|
||||
if (!access(BASE "/camera-probe-once", F_OK)) {
|
||||
unlink(BASE "/camera-probe-once");
|
||||
pid_t probe = fork();
|
||||
if (!probe) { execl("/data/local/tmp/vulpes-camera-stream", "vulpes-camera-stream", NULL); _exit(127); }
|
||||
if (probe > 0) {
|
||||
int probe_status = 0, finished = 0;
|
||||
for (int attempt = 0; attempt < 100; attempt++) {
|
||||
if (waitpid(probe, &probe_status, WNOHANG) == probe) { finished = 1; break; }
|
||||
usleep(100000);
|
||||
}
|
||||
if (!finished) { kill(probe, SIGKILL); waitpid(probe, &probe_status, 0); puts("camera probe timed out after 10 seconds"); }
|
||||
else printf("camera probe exit: %d\n", WIFEXITED(probe_status) ? WEXITSTATUS(probe_status) : -1);
|
||||
}
|
||||
}
|
||||
int result = android_service("/system/bin/stop");
|
||||
printf("stop b2g: %d\n",result);
|
||||
pid_t child = -1, radio = -1;
|
||||
struct lid_state lid;lid_init(&lid,fb);
|
||||
if (!result) {
|
||||
sleep(1);
|
||||
puts("Camera node inventory after stopping B2G");
|
||||
inventory_camera_nodes();
|
||||
int preview_dump_probe = !access(BASE "/qcamera-preview-dump-once", F_OK);
|
||||
if (!access(BASE "/qcamera-probe-once", F_OK) || preview_dump_probe) {
|
||||
unlink(BASE "/qcamera-probe-once");
|
||||
unlink(BASE "/qcamera-preview-dump-once");
|
||||
const char *dump_path = "/data/misc/camera";
|
||||
if (mkdir("/data/misc", 0755) && errno != EEXIST) perror("camera misc directory");
|
||||
if (mkdir(dump_path, 0770) && errno != EEXIST) perror("camera dump directory");
|
||||
char snapshot_dir[256];
|
||||
snprintf(snapshot_dir, sizeof(snapshot_dir), "%s/test", dump_path);
|
||||
if (mount("tmpfs", dump_path, "tmpfs", MS_NOSUID, "size=24m,mode=0770,uid=1006,gid=1006")) perror("camera dump tmpfs");
|
||||
else {
|
||||
puts("QCamera snapshot output uses bounded volatile storage");
|
||||
if (mkdir(snapshot_dir, 0770) && errno != EEXIST) perror("camera snapshot directory");
|
||||
if (chown(snapshot_dir, 1006, 1006)) perror("camera snapshot ownership");
|
||||
}
|
||||
pid_t prop = fork();
|
||||
if (!prop) { execl("/system/bin/setprop", "setprop", "persist.camera.dumpimg", "1", NULL); _exit(127); }
|
||||
if (prop > 0) waitpid(prop, NULL, 0);
|
||||
int input[2];
|
||||
if (pipe(input) == 0) {
|
||||
pid_t probe = fork();
|
||||
if (!probe) {
|
||||
close(input[1]);
|
||||
dup2(input[0], STDIN_FILENO);
|
||||
close(input[0]);
|
||||
int output = open(BASE "/qcamera-probe.log", O_CREAT | O_TRUNC | O_WRONLY, 0644);
|
||||
if (output >= 0) { fchmod(output, 0644); dup2(output, STDOUT_FILENO); dup2(output, STDERR_FILENO); close(output); }
|
||||
gid_t groups[] = {1006};
|
||||
if (setgroups(1, groups) || setgid(1006) || setuid(1006)) _exit(126);
|
||||
const char *qcamera = preview_dump_probe ? BASE "/qcamera-preview-dump" : "/system/bin/mm-qcamera-app";
|
||||
execl(qcamera, qcamera, NULL);
|
||||
_exit(127);
|
||||
}
|
||||
close(input[0]);
|
||||
if (probe > 0) {
|
||||
const char *camera_input = preview_dump_probe ? "0\nA\n" : "0\na\nA\n";
|
||||
(void)write(input[1], camera_input, strlen(camera_input));
|
||||
/* Give the sensor's auto-exposure and white balance time to settle. */
|
||||
sleep(10);
|
||||
(void)write(input[1], "P\n", 2);
|
||||
sleep(12);
|
||||
(void)write(input[1], "B\nZ\n", 4);
|
||||
close(input[1]);
|
||||
int probe_status = 0, finished = 0;
|
||||
for (int attempt = 0; attempt < 250; attempt++) {
|
||||
if (waitpid(probe, &probe_status, WNOHANG) == probe) { finished = 1; break; }
|
||||
usleep(100000);
|
||||
}
|
||||
if (!finished) { kill(probe, SIGKILL); waitpid(probe, &probe_status, 0); puts("QCamera menu probe timed out"); }
|
||||
else printf("QCamera menu exit: %d\n", WIFEXITED(probe_status) ? WEXITSTATUS(probe_status) : -1);
|
||||
} else close(input[1]);
|
||||
} else perror("QCamera probe pipe");
|
||||
int frame_copied = copy_camera_dump(dump_path, ".yuv", ROOT "/work/camera-frame.yuv", "YUV frame");
|
||||
if (!frame_copied) frame_copied = copy_camera_dump(snapshot_dir, ".yuv", ROOT "/work/camera-frame.yuv", "snapshot YUV");
|
||||
int jpeg_copied = copy_camera_dump(snapshot_dir, ".jpg", ROOT "/work/camera-photo.jpg", "JPEG photo");
|
||||
if (!frame_copied && !jpeg_copied) puts("QCamera acquired preview buffers but did not export a frame or photo");
|
||||
prop = fork();
|
||||
if (!prop) { execl("/system/bin/setprop", "setprop", "persist.camera.dumpimg", "0", NULL); _exit(127); }
|
||||
if (prop > 0) waitpid(prop, NULL, 0);
|
||||
if (!umount(dump_path)) rmdir(dump_path);
|
||||
}
|
||||
if (seconds >= 180) {
|
||||
radio = fork();
|
||||
if (!radio) radio_inside();
|
||||
}
|
||||
child = fork();
|
||||
if (!child) inside(seconds);
|
||||
struct timespec begin, now;
|
||||
clock_gettime(CLOCK_MONOTONIC,&begin);
|
||||
int frames=0,captured=0;
|
||||
double sample_start=begin.tv_sec+begin.tv_nsec/1e9,pan_seconds=0;
|
||||
int sample_frames=0,pan_attempts=0;
|
||||
for (int tick=0; child > 0 && !interrupted; tick++) {
|
||||
clock_gettime(CLOCK_MONOTONIC,&now);
|
||||
if (now.tv_sec-begin.tv_sec >= seconds) break;
|
||||
int status;
|
||||
if (waitpid(child,&status,WNOHANG)==child) {
|
||||
printf("session ended: %d\n",status); break;
|
||||
}
|
||||
if (!access(BASE "/stop-session",F_OK)) break;
|
||||
if (!captured && !access(ROOT "/work/gaia-native.png",F_OK)) {
|
||||
capture_framebuffer(fb);captured=1;
|
||||
}
|
||||
if(seconds>=180)lid_update(&lid,now.tv_sec+now.tv_nsec/1e9);
|
||||
struct fb_var_screeninfo current;
|
||||
if (lid.opened != 0 && !ioctl(fb,FBIOGET_VSCREENINFO,¤t)) {
|
||||
struct timespec pan_start,pan_end;
|
||||
clock_gettime(CLOCK_MONOTONIC,&pan_start);
|
||||
int pan=ioctl(fb,FBIOPAN_DISPLAY,¤t);
|
||||
clock_gettime(CLOCK_MONOTONIC,&pan_end);
|
||||
pan_seconds+=pan_end.tv_sec-pan_start.tv_sec+(pan_end.tv_nsec-pan_start.tv_nsec)/1e9;
|
||||
pan_attempts++;
|
||||
if (pan && tick==20) perror("display refresh");
|
||||
if (!pan) {frames++;sample_frames++;}
|
||||
}
|
||||
double sample_now=now.tv_sec+now.tv_nsec/1e9;
|
||||
if(sample_now-sample_start>=10) {
|
||||
printf("Display sample: submissions/s=%.2f pan_ms=%.2f failed=%d lid=%d\n",
|
||||
sample_frames/(sample_now-sample_start),pan_attempts?1000*pan_seconds/pan_attempts:0,
|
||||
pan_attempts-sample_frames,lid.opened);
|
||||
sample_start=sample_now;pan_seconds=0;sample_frames=0;pan_attempts=0;
|
||||
}
|
||||
usleep(lid.opened==0?20000:5000);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC,&now);
|
||||
printf("Framebuffer submissions: %d in %.2f seconds\n",frames,
|
||||
now.tv_sec-begin.tv_sec+(now.tv_nsec-begin.tv_nsec)/1e9);
|
||||
}
|
||||
if (child > 0) { kill(-child,SIGTERM); sleep(1); kill(-child,SIGKILL); }
|
||||
if (radio > 0) {
|
||||
kill(-radio,SIGTERM);
|
||||
// Allow both modem slots to restore before returning to KaiOS.
|
||||
for(int i=0;i<600;i++) {
|
||||
if(waitpid(radio,NULL,WNOHANG)==radio) { radio=-1;break; }
|
||||
usleep(100000);
|
||||
}
|
||||
if(radio>0) {kill(-radio,SIGKILL);waitpid(radio,NULL,0);}
|
||||
}
|
||||
clean_runtime();
|
||||
lid_restore(&lid);
|
||||
if (ioctl(fb,FBIOPUT_VSCREENINFO,&original)) perror("restore framebuffer mode");
|
||||
if (ioctl(fb,FBIOPAN_DISPLAY,&original)) perror("restore framebuffer pan");
|
||||
close(fb);
|
||||
unlink("/data/local/debugger-socket");
|
||||
printf("start b2g: %d\n",android_service("/system/bin/start"));
|
||||
int done=open(BASE "/display-session.done",O_CREAT | O_TRUNC | O_WRONLY,0644);
|
||||
if(done>=0){fchmod(done,0644);write(done,"restored\n",9);close(done);}
|
||||
return 0;
|
||||
}
|
||||
43
tundra/probes/beatles-hal-inventory.c
Normal file
43
tundra/probes/beatles-hal-inventory.c
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
/* Read-only Android 6 ARM32 HAL enumeration in an isolated bionic process.
|
||||
* ABI: AOSP android-6.0.1_r1 hardware/libhardware/include/hardware/
|
||||
* hardware.h, sensors.h. No sensor activation or camera device open. */
|
||||
typedef unsigned int u32;
|
||||
typedef unsigned short u16;
|
||||
extern void *dlopen(const char *,int);
|
||||
extern void *dlsym(void *,const char *);
|
||||
extern int printf(const char *,...);
|
||||
extern int putchar(int);
|
||||
extern int fflush(void *);
|
||||
extern unsigned alarm(unsigned);
|
||||
extern void _exit(int) __attribute__((noreturn));
|
||||
struct module {u32 tag;u16 version,hal;const char *id,*name,*author;void *methods,*dso;u32 reserved[25];};
|
||||
struct sensor {const char *name,*vendor;int version,handle,type;float range,resolution,power;int min_delay;u32 fifo_reserved,fifo_max;const char *string_type,*permission;int max_delay;u32 flags;void *reserved[2];};
|
||||
struct sensors {struct module common;int (*list)(struct sensors *,const struct sensor **);};
|
||||
_Static_assert(sizeof(struct module)==128,"Android 6 ARM32 module ABI");
|
||||
_Static_assert(sizeof(struct sensor)==68,"Android 6 ARM32 sensor ABI");
|
||||
static void string(const char *value) {
|
||||
putchar('"');
|
||||
if(value)for(unsigned i=0;value[i] && i<160;i++) {
|
||||
unsigned char c=value[i];
|
||||
if(c=='"'||c=='\\')putchar('\\');
|
||||
if(c<32)printf("\\u%04x",c);else putchar(c);
|
||||
}
|
||||
putchar('"');
|
||||
}
|
||||
__attribute__((constructor)) static void inventory(void) {
|
||||
alarm(8);
|
||||
void *library=dlopen("/system/lib/hw/sensors.msm8909.so",2);
|
||||
struct sensors *module=library?dlsym(library,"HMI"):0;
|
||||
if(!module || !module->list){printf("{\"error\":\"SENSOR_HAL_UNAVAILABLE\"}\n");fflush(0);_exit(1);}
|
||||
const struct sensor *list=0;
|
||||
int count=module->list(module,&list);
|
||||
if(count<0 || count>128 || (count && !list)){printf("{\"error\":\"INVALID_SENSOR_LIST\"}\n");fflush(0);_exit(2);}
|
||||
printf("{\"moduleVersion\":%u,\"count\":%d,\"sensors\":[",module->common.version,count);
|
||||
for(int i=0;i<count;i++) {
|
||||
if(i)putchar(',');
|
||||
printf("{\"type\":%d,\"name\":",list[i].type);string(list[i].name);
|
||||
printf(",\"vendor\":");string(list[i].vendor);putchar('}');
|
||||
}
|
||||
printf("]}\n");fflush(0);_exit(0);
|
||||
}
|
||||
9
tundra/probes/beatles-hall.h
Normal file
9
tundra/probes/beatles-hall.h
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#ifndef VULPES_BEATLES_HALL_H
|
||||
#define VULPES_BEATLES_HALL_H
|
||||
/* Nokia hall_status reports the magnet: 1 = closed, 0 = open.
|
||||
* Keep this conversion shared by panel power and keypad handling. */
|
||||
static inline int beatles_hall_open(int raw) {
|
||||
return raw == 0 ? 1 : raw == 1 ? 0 : -1;
|
||||
}
|
||||
#endif
|
||||
185
tundra/probes/beatles-keypad.c
Normal file
185
tundra/probes/beatles-keypad.c
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/keysym.h>
|
||||
#include <X11/XF86keysym.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <unistd.h>
|
||||
#include "beatles-hall.h"
|
||||
|
||||
static int allow_key(unsigned code, int value, int hall) {
|
||||
return !value || beatles_hall_open(hall)!=0 ||
|
||||
code==KEY_VOLUMEUP || code==KEY_VOLUMEDOWN;
|
||||
}
|
||||
|
||||
static KeySym symbol(unsigned code) {
|
||||
if(code>=KEY_1 && code<=KEY_9)return XK_1+code-KEY_1;
|
||||
switch(code) {
|
||||
case KEY_0:return XK_0;
|
||||
case KEY_VOLUMEUP:return XF86XK_AudioRaiseVolume;
|
||||
case KEY_VOLUMEDOWN:return XF86XK_AudioLowerVolume;
|
||||
case KEY_UP:return XK_Up; case KEY_DOWN:return XK_Down;
|
||||
case KEY_LEFT:return XK_Left; case KEY_RIGHT:return XK_Right;
|
||||
case KEY_OK:case KEY_ENTER:return XK_Return;
|
||||
case KEY_BACK:return XK_Escape; case KEY_BACKSPACE:return XK_BackSpace;
|
||||
case KEY_MENU:return XK_Menu; case KEY_SEND:return XK_F1;
|
||||
case KEY_POWER:return XK_F4; /* qpnp_pon.kl: ENDCALL */
|
||||
case KEY_CAMERA:return XK_F2; /* matrix_keypad.kl: NOTIFICATION */
|
||||
case KEY_NUMERIC_STAR:return XK_8;
|
||||
case KEY_NUMERIC_POUND:return XK_3;
|
||||
default:return NoSymbol;
|
||||
}
|
||||
}
|
||||
/* Exercise the evdev reader as well as X11. These diagnostic commands are
|
||||
* used only while our reader owns the keypad; they do not bypass its mapping. */
|
||||
static unsigned native_code(const char *key) {
|
||||
if(!strcmp(key,"ArrowRight"))return KEY_RIGHT;
|
||||
if(!strcmp(key,"ArrowLeft"))return KEY_LEFT;
|
||||
if(!strcmp(key,"ArrowUp"))return KEY_UP;
|
||||
if(!strcmp(key,"ArrowDown"))return KEY_DOWN;
|
||||
if(!strcmp(key,"Enter"))return KEY_OK;
|
||||
if(!strcmp(key,"Escape"))return KEY_BACK;
|
||||
if(!strcmp(key,"EndCall"))return KEY_POWER;
|
||||
if(!strcmp(key,"Notifications"))return KEY_CAMERA;
|
||||
if(!strcmp(key,"AudioVolumeUp"))return KEY_VOLUMEUP;
|
||||
if(!strcmp(key,"AudioVolumeDown"))return KEY_VOLUMEDOWN;
|
||||
if(!strcmp(key,"Backspace"))return KEY_BACKSPACE;
|
||||
if(!key[0] || key[1])return 0;
|
||||
if(key[0]>='1' && key[0]<='9')return KEY_1+key[0]-'1';
|
||||
if(key[0]=='0')return KEY_0;
|
||||
if(key[0]=='*')return KEY_NUMERIC_STAR;
|
||||
if(key[0]=='#')return KEY_NUMERIC_POUND;
|
||||
return 0;
|
||||
}
|
||||
static int inject(int fd,unsigned code) {
|
||||
struct input_event events[4]={
|
||||
{.type=EV_KEY,.code=code,.value=1}, {.type=EV_SYN,.code=SYN_REPORT},
|
||||
{.type=EV_KEY,.code=code,.value=0}, {.type=EV_SYN,.code=SYN_REPORT},
|
||||
};
|
||||
ssize_t written=write(fd,events,sizeof(events));
|
||||
if(written!=(ssize_t)sizeof(events)){perror("keypad inject");return 1;}
|
||||
return 0;
|
||||
}
|
||||
static int inject_keys(const char *keys,int sequence) {
|
||||
size_t length=strlen(keys);
|
||||
if((sequence && (!length || length>40 || strspn(keys,"0123456789*#")!=length)) ||
|
||||
(!sequence && !native_code(keys)))return 2;
|
||||
unsigned code=sequence?0:native_code(keys);
|
||||
const char *path=code==KEY_VOLUMEUP ? "/dev/input/event3" :
|
||||
(code==KEY_POWER || code==KEY_VOLUMEDOWN) ? "/dev/input/event2" : "/dev/input/event0";
|
||||
int fd=open(path,O_WRONLY|O_CLOEXEC);
|
||||
if(fd<0){perror("keypad injection open");return 2;}
|
||||
if(!ioctl(fd,EVIOCGRAB,1)) {
|
||||
ioctl(fd,EVIOCGRAB,0);close(fd);
|
||||
fputs("Keypad reader must own the device before injection\n",stderr);return 3;
|
||||
}
|
||||
if(errno!=EBUSY){perror("keypad ownership");close(fd);return 3;}
|
||||
int result=0;
|
||||
for(size_t i=0;i<(sequence?length:1);i++) {
|
||||
char single[2]={keys[i],0};
|
||||
if(inject(fd,native_code(sequence?single:keys))){result=1;break;}
|
||||
usleep(130000);
|
||||
}
|
||||
close(fd);return result;
|
||||
}
|
||||
int main(int argc,char **argv) {
|
||||
if(argc==3 && !strcmp(argv[1],"--inject-key"))return inject_keys(argv[2],0);
|
||||
if(argc==3 && !strcmp(argv[1],"--inject-keys"))return inject_keys(argv[2],1);
|
||||
Display *display=NULL;
|
||||
for(unsigned attempt=0;attempt<100 && !display;attempt++) {
|
||||
display=XOpenDisplay(NULL);
|
||||
if(!display)usleep(100000);
|
||||
}
|
||||
if(!display){fputs("Keypad: X server not ready after 10 seconds\n",stderr);return 1;}
|
||||
if(argc==3 && !strcmp(argv[1],"--type-keys")) {
|
||||
size_t length=strlen(argv[2]);
|
||||
if(!length || length>40 || strspn(argv[2],"0123456789*#")!=length)return 2;
|
||||
for(size_t i=0;i<length;i++) {
|
||||
char key=argv[2][i];int shifted=key=='*'||key=='#';
|
||||
KeyCode code=XKeysymToKeycode(display,key=='*'?XK_8:key=='#'?XK_3:XK_0+key-'0');
|
||||
if(shifted)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),True,CurrentTime);
|
||||
XTestFakeKeyEvent(display,code,True,CurrentTime);
|
||||
XTestFakeKeyEvent(display,code,False,CurrentTime);
|
||||
if(shifted)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),False,CurrentTime);
|
||||
XFlush(display);usleep(130000);
|
||||
}
|
||||
XSync(display,False);XCloseDisplay(display);return 0;
|
||||
}
|
||||
if(argc==3 && !strcmp(argv[1],"--send-key")) {
|
||||
KeySym key=!strcmp(argv[2],"ArrowRight")?XK_Right:
|
||||
!strcmp(argv[2],"ArrowLeft")?XK_Left:!strcmp(argv[2],"ArrowUp")?XK_Up:
|
||||
!strcmp(argv[2],"ArrowDown")?XK_Down:!strcmp(argv[2],"Backspace")?XK_BackSpace:
|
||||
!strcmp(argv[2],"Enter")?XK_Return:!strcmp(argv[2],"Escape")?XK_Escape:
|
||||
!strcmp(argv[2],"2")?XK_2:NoSymbol;
|
||||
if(key==NoSymbol){XCloseDisplay(display);return 2;}
|
||||
KeyCode code=XKeysymToKeycode(display,key);
|
||||
XTestFakeKeyEvent(display,code,True,CurrentTime);
|
||||
XTestFakeKeyEvent(display,code,False,CurrentTime);
|
||||
XSync(display,False);XCloseDisplay(display);return 0;
|
||||
}
|
||||
if(argc==2 && !strcmp(argv[1],"--self-test")) {
|
||||
unsigned keys[]={KEY_UP,KEY_OK,KEY_BACK};
|
||||
for(unsigned i=0;i<sizeof(keys)/sizeof(keys[0]);i++) {
|
||||
KeyCode code=XKeysymToKeycode(display,symbol(keys[i]));
|
||||
XTestFakeKeyEvent(display,code,True,CurrentTime);
|
||||
XTestFakeKeyEvent(display,code,False,CurrentTime);
|
||||
}
|
||||
XSync(display,False);XCloseDisplay(display);return 0;
|
||||
}
|
||||
int fd=open("/dev/input/event0",O_RDONLY|O_NONBLOCK);
|
||||
if(fd<0){perror("keypad open");return 2;}
|
||||
if(ioctl(fd,EVIOCGRAB,1)){perror("keypad grab");return 3;}
|
||||
int power=open("/dev/input/event2",O_RDONLY|O_NONBLOCK);
|
||||
char name[80]={0};
|
||||
if(power<0 || ioctl(power,EVIOCGNAME(sizeof(name)),name)<0 || strcmp(name,"qpnp_pon") || ioctl(power,EVIOCGRAB,1)) {
|
||||
fprintf(stderr,"Nokia end-call input unavailable: %s (name=%s)\n",strerror(errno),name);
|
||||
if(power>=0)close(power);
|
||||
ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 3;
|
||||
}
|
||||
int side=open("/dev/input/event3",O_RDONLY|O_NONBLOCK);
|
||||
memset(name,0,sizeof(name));
|
||||
if(side<0 || ioctl(side,EVIOCGNAME(sizeof(name)),name)<0 || strcmp(name,"gpio-keys") || ioctl(side,EVIOCGRAB,1)) {
|
||||
fprintf(stderr,"Nokia volume input unavailable: %s (name=%s)\n",strerror(errno),name);
|
||||
if(side>=0)close(side);
|
||||
ioctl(power,EVIOCGRAB,0);close(power);ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 3;
|
||||
}
|
||||
puts("Nokia keypad bridge ready");fflush(stdout);
|
||||
struct pollfd pollers[3]={{.fd=fd,.events=POLLIN},{.fd=power,.events=POLLIN},{.fd=side,.events=POLLIN}};
|
||||
for(;;) {
|
||||
if(poll(pollers,3,1000)<0)break;
|
||||
for(unsigned source=0;source<3;source++) {
|
||||
if(!(pollers[source].revents&POLLIN))continue;
|
||||
struct input_event event;
|
||||
while(read(pollers[source].fd,&event,sizeof(event))==sizeof(event)) {
|
||||
if(source==1 && event.code!=KEY_POWER && event.code!=KEY_VOLUMEDOWN)continue;
|
||||
if(source==2 && event.code!=KEY_VOLUMEUP && event.code!=KEY_VOLUMEDOWN)continue;
|
||||
/* Long holds must not hang up and then immediately dismiss the screen. */
|
||||
if((event.code==KEY_POWER || event.code==KEY_VOLUMEUP || event.code==KEY_VOLUMEDOWN) && event.value==2)continue;
|
||||
if(event.type!=EV_KEY)continue;
|
||||
/* Always release keys, but ignore presses from the closed inner keypad. */
|
||||
if(event.value) {
|
||||
FILE *hall=fopen("/sys/devices/soc.0/hall_sensor1.73/hall_status","r");
|
||||
int opened=-1;
|
||||
if(hall){if(fscanf(hall,"%d",&opened)!=1)opened=-1;fclose(hall);}
|
||||
if(!allow_key(event.code,event.value,opened))continue;
|
||||
}
|
||||
KeySym key=symbol(event.code);
|
||||
if(key==NoSymbol)continue;
|
||||
int shifted=event.code==KEY_NUMERIC_STAR||event.code==KEY_NUMERIC_POUND;
|
||||
if(shifted && event.value)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),True,CurrentTime);
|
||||
XTestFakeKeyEvent(display,XKeysymToKeycode(display,key),event.value!=0,CurrentTime);
|
||||
if(shifted && !event.value)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),False,CurrentTime);
|
||||
XFlush(display);
|
||||
}
|
||||
}
|
||||
}
|
||||
ioctl(side,EVIOCGRAB,0);close(side);
|
||||
ioctl(power,EVIOCGRAB,0);close(power);
|
||||
ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 0;
|
||||
}
|
||||
90
tundra/probes/beatles-lid.h
Normal file
90
tundra/probes/beatles-lid.h
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
/* Panel policy belongs to the Nokia adapter, outside the browser sandbox. */
|
||||
#include "beatles-hall.h"
|
||||
struct lid_state {
|
||||
int inner, outer, opened, outer_on, ready, candidate;
|
||||
int keys_brightness;
|
||||
double last_poll, closed_at, candidate_at;
|
||||
};
|
||||
#ifndef BEATLES_KEYS_BRIGHTNESS
|
||||
#define BEATLES_KEYS_BRIGHTNESS "/sys/class/leds/button-backlight/brightness"
|
||||
#endif
|
||||
static int lid_keys_read(void) {
|
||||
FILE *f=fopen(BEATLES_KEYS_BRIGHTNESS,"r");
|
||||
if(!f)return -1;
|
||||
int value=-1;if(fscanf(f,"%d",&value)!=1)value=-1;
|
||||
fclose(f);return value;
|
||||
}
|
||||
static void lid_keys_write(int value) {
|
||||
if(value<0)return;
|
||||
FILE *f=fopen(BEATLES_KEYS_BRIGHTNESS,"w");
|
||||
if(!f){perror("keypad brightness");return;}
|
||||
if(fprintf(f,"%d\n",value)<0)perror("keypad brightness write");
|
||||
if(fclose(f))perror("keypad brightness close");
|
||||
}
|
||||
static int lid_read_open(void) {
|
||||
FILE *f=fopen("/sys/devices/soc.0/hall_sensor1.73/hall_status","r");
|
||||
if(!f)return -1;
|
||||
int value=-1;
|
||||
if(fscanf(f,"%d",&value)!=1)value=-1;
|
||||
fclose(f);return beatles_hall_open(value);
|
||||
}
|
||||
static void lid_publish(const struct lid_state *lid) {
|
||||
FILE *f=fopen(ROOT "/work/lid-state.tmp","w");
|
||||
if(!f)return;
|
||||
fchmod(fileno(f),0644);
|
||||
fprintf(f,"{\"opened\":%s,\"externalOn\":%s}\n",lid->opened?"true":"false",lid->outer_on?"true":"false");
|
||||
fclose(f);rename(ROOT "/work/lid-state.tmp",ROOT "/work/lid-state.json");
|
||||
}
|
||||
static void lid_init(struct lid_state *lid,int inner) {
|
||||
memset(lid,0,sizeof(*lid));lid->inner=inner;lid->opened=-1;lid->candidate=-1;
|
||||
lid->keys_brightness=lid_keys_read();
|
||||
lid->outer=open("/dev/graphics/fb1",O_RDWR|O_CLOEXEC);
|
||||
unlink(ROOT "/work/cover-ready");unlink(ROOT "/work/lid-state.json");
|
||||
}
|
||||
static void lid_apply(struct lid_state *lid,int opened,int ready,double now) {
|
||||
if(opened!=0 && opened!=1)return;
|
||||
/* Xorg can wake fb0 during initialization after our first closed-lid poll.
|
||||
* cover-ready is published after Xorg/keypad startup: reapply the panel
|
||||
* selection then, before presenting the first external frame. */
|
||||
int changed=opened!=lid->opened || (ready && !lid->ready && !opened);
|
||||
if(changed) {
|
||||
/* Finish blanking the cover before waking the inner display. */
|
||||
if(opened && lid->outer>=0) {
|
||||
if(ioctl(lid->outer,FBIOBLANK,FB_BLANK_POWERDOWN)){perror("lid outer blank");return;}
|
||||
lid->outer_on=0;
|
||||
}
|
||||
if(ioctl(lid->inner,FBIOBLANK,opened?FB_BLANK_UNBLANK:FB_BLANK_POWERDOWN)) {
|
||||
perror("lid inner blank");return;
|
||||
}
|
||||
lid_keys_write(opened?lid->keys_brightness:0);
|
||||
lid->opened=opened;lid->closed_at=opened?0:now;
|
||||
printf("Clamshell: %s; internal screen %s\n",opened?"open":"closed",opened?"on":"off");
|
||||
}
|
||||
if(ready && !lid->ready && !opened)lid->closed_at=now;
|
||||
lid->ready=ready;
|
||||
int outer=lid->outer>=0 && !opened && now-lid->closed_at<15 && ready;
|
||||
int outer_changed=outer!=lid->outer_on;
|
||||
if(outer_changed || (changed && !opened)) {
|
||||
if(lid->outer>=0 && ioctl(lid->outer,FBIOBLANK,outer?FB_BLANK_UNBLANK:FB_BLANK_POWERDOWN))perror("lid outer blank");
|
||||
else lid->outer_on=outer;
|
||||
printf("External screen: %s\n",lid->outer_on?"on":"off");
|
||||
}
|
||||
if(changed || outer_changed)lid_publish(lid);
|
||||
}
|
||||
static void lid_update(struct lid_state *lid,double now) {
|
||||
if(now-lid->last_poll<.1)return;
|
||||
lid->last_poll=now;
|
||||
int opened=lid_read_open();
|
||||
if(opened<0)return;
|
||||
if(opened!=lid->candidate) {
|
||||
lid->candidate=opened;lid->candidate_at=now;return;
|
||||
}
|
||||
if(now-lid->candidate_at<.15)return;
|
||||
lid_apply(lid,opened,!access(ROOT "/work/cover-ready",F_OK),now);
|
||||
}
|
||||
static void lid_restore(struct lid_state *lid) {
|
||||
if(lid->outer>=0) {ioctl(lid->outer,FBIOBLANK,FB_BLANK_POWERDOWN);close(lid->outer);}
|
||||
if(lid->inner>=0 && ioctl(lid->inner,FBIOBLANK,FB_BLANK_UNBLANK))perror("restore internal panel");
|
||||
lid_keys_write(lid->keys_brightness);
|
||||
}
|
||||
25
tundra/probes/beatles-panel-info.c
Normal file
25
tundra/probes/beatles-panel-info.c
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <fcntl.h>
|
||||
#include <linux/fb.h>
|
||||
#include <linux/input.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
int main(void) {
|
||||
FILE *out=fopen("/data/local/tmp/vulpes-panel-info.json","w");
|
||||
if(!out)return 1;
|
||||
fchmod(fileno(out),0644);
|
||||
fprintf(out,"{\"panels\":[");
|
||||
for(int i=0;i<2;i++) {
|
||||
char path[64];snprintf(path,sizeof(path),"/dev/graphics/fb%d",i);
|
||||
int fd=open(path,O_RDONLY);struct fb_var_screeninfo v;struct fb_fix_screeninfo f;
|
||||
if(fd<0 || ioctl(fd,FBIOGET_VSCREENINFO,&v) || ioctl(fd,FBIOGET_FSCREENINFO,&f))return 2;
|
||||
fprintf(out,"%s{\"width\":%u,\"height\":%u,\"virtualHeight\":%u,\"bpp\":%u,\"stride\":%u,\"bytes\":%u}",i?",":"",v.xres,v.yres,v.yres_virtual,v.bits_per_pixel,f.line_length,f.smem_len);
|
||||
close(fd);
|
||||
}
|
||||
unsigned char keys[(KEY_MAX+8)/8]={0};int fd=open("/dev/input/event1",O_RDONLY);
|
||||
if(fd<0 || ioctl(fd,EVIOCGKEY(sizeof(keys)),keys)<0)return 3;
|
||||
fprintf(out,"],\"hallKey249\":%s}\n",(keys[249/8]&(1<<(249%8)))?"true":"false");
|
||||
close(fd);fclose(out);return 0;
|
||||
}
|
||||
55
tundra/probes/beatles-x11-test.c
Normal file
55
tundra/probes/beatles-x11-test.c
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/Xutil.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int main(void) {
|
||||
Display *display=XOpenDisplay(NULL);
|
||||
if(!display){fputs("Cannot connect to Xorg\n",stderr);return 1;}
|
||||
int screen=DefaultScreen(display);
|
||||
Window window=XCreateSimpleWindow(display,RootWindow(display,screen),0,0,240,320,
|
||||
0,0,0x0010);
|
||||
XSetWindowAttributes attributes={.override_redirect=True};
|
||||
XChangeWindowAttributes(display,window,CWOverrideRedirect,&attributes);
|
||||
XSelectInput(display,window,ExposureMask|KeyPressMask|KeyReleaseMask);
|
||||
XMapRaised(display,window); XSetInputFocus(display,window,RevertToParent,CurrentTime);
|
||||
GC gc=XCreateGC(display,window,0,NULL);
|
||||
char keytext[128]="Press a physical key";
|
||||
time_t start=time(NULL);
|
||||
int count=0;
|
||||
while(time(NULL)-start<25) {
|
||||
while(XPending(display)) {
|
||||
XEvent event; XNextEvent(display,&event);
|
||||
if(event.type==KeyPress||event.type==KeyRelease) {
|
||||
KeySym key=XLookupKeysym(&event.xkey,0);
|
||||
snprintf(keytext,sizeof(keytext),"%s: %s",event.type==KeyPress?"Down":"Up",
|
||||
XKeysymToString(key)?XKeysymToString(key):"unknown");
|
||||
printf("keycode=%u keysym=%lu %s\n",event.xkey.keycode,key,keytext);fflush(stdout);
|
||||
}
|
||||
}
|
||||
XSetForeground(display,gc,0x0010);XFillRectangle(display,window,gc,0,0,240,320);
|
||||
XSetForeground(display,gc,0xffff);
|
||||
const char *lines[]={"VULPES DISPLAY TEST","Linux / Xorg / ARMv7","240 x 320 / physical keypad",keytext,"KaiOS returns automatically"};
|
||||
for(unsigned i=0;i<sizeof(lines)/sizeof(lines[0]);i++)
|
||||
XDrawString(display,window,gc,10,35+i*35,lines[i],strlen(lines[i]));
|
||||
XSetForeground(display,gc,0x07ff); XFillRectangle(display,window,gc,10+(count++%160),235,50,25);
|
||||
XFlush(display);usleep(100000);
|
||||
}
|
||||
XImage *image=XGetImage(display,window,0,0,240,320,AllPlanes,ZPixmap);
|
||||
if(image){FILE *file=fopen("/work/x11-test.ppm","wb");if(file){
|
||||
fprintf(file,"P6\n240 320\n255\n");
|
||||
for(int y=0;y<320;y++) {
|
||||
for(int x=0;x<240;x++) {
|
||||
unsigned long pixel=XGetPixel(image,x,y);
|
||||
unsigned char rgb[]={((pixel>>11)&31)*255/31,((pixel>>5)&63)*255/63,(pixel&31)*255/31};
|
||||
fwrite(rgb,1,3,file);
|
||||
}
|
||||
}
|
||||
fclose(file);
|
||||
}
|
||||
XDestroyImage(image);}
|
||||
XCloseDisplay(display);return 0;
|
||||
}
|
||||
|
|
@ -39,6 +39,9 @@ class Vibration:
|
|||
raise ValueError('INVALID_VIBRATION')
|
||||
self.steps = list(enumerate(pattern))
|
||||
self.deadline = None
|
||||
trigger = VIBRATOR/'trigger'
|
||||
if trigger.exists():
|
||||
trigger.write_text('none')
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
self.advance(time.monotonic())
|
||||
|
||||
|
|
@ -60,11 +63,27 @@ class Vibration:
|
|||
|
||||
vibration = Vibration()
|
||||
|
||||
class WakeRecovery:
|
||||
def __init__(self):
|
||||
self.deadline = None
|
||||
|
||||
def arm(self, now):
|
||||
self.deadline = now + 1.5
|
||||
|
||||
def advance(self, now):
|
||||
if self.deadline is not None and now >= self.deadline:
|
||||
self.deadline = None
|
||||
if int((BACKLIGHT/'brightness').read_text()) == 0:
|
||||
control({'operation':'screen', 'enabled':True, 'level':0.5})
|
||||
|
||||
wake_recovery = WakeRecovery()
|
||||
|
||||
def control(p):
|
||||
if p.get('operation') == 'screen':
|
||||
enabled, level = p.get('enabled'), p.get('level')
|
||||
if type(enabled) is not bool or type(level) not in (int, float) or not 0 <= level <= 1:
|
||||
raise ValueError('INVALID_SCREEN')
|
||||
wake_recovery.deadline = None
|
||||
maximum = int((BACKLIGHT/'max_brightness').read_text())
|
||||
(BACKLIGHT/'brightness').write_text(str(max(1, round(maximum * level)) if enabled else 0))
|
||||
elif p.get('operation') == 'vibrate':
|
||||
|
|
@ -125,15 +144,16 @@ def serve():
|
|||
_, _, kind, code, value = INPUT.unpack_from(data, offset)
|
||||
event = key_event(kind, code, value)
|
||||
if event:
|
||||
# Always allow a physical press to recover a black display,
|
||||
# even if Gaia has stopped responding.
|
||||
# Let Gaia restore the lock screen before lighting it.
|
||||
# Recover independently only if its screen command never arrives.
|
||||
if code == 116 and value == 1 and int((BACKLIGHT/'brightness').read_text()) == 0:
|
||||
control({'operation':'screen', 'enabled':True, 'level':0.5})
|
||||
wake_recovery.arm(time.monotonic())
|
||||
state['sequence'] += 1
|
||||
state['events'].append({'id':state['sequence'], 'type':event})
|
||||
state['events'] = state['events'][-64:]
|
||||
if vibration.deadline is not None:
|
||||
vibration.advance(time.monotonic())
|
||||
wake_recovery.advance(time.monotonic())
|
||||
publish()
|
||||
|
||||
def client():
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class Camera:
|
|||
def metadata(self):
|
||||
return {'ready': True, 'width': self.width, 'height': self.height,
|
||||
'orientation': self.orientation, 'format': 'nv21',
|
||||
'flashModes': [m for m in self.parameters.get('flash-mode-values', 'off').split(',') if m in ('off','auto','on','torch')],
|
||||
'pictureSizes': [{'width':w,'height':h} for w,h in self.picture_sizes],
|
||||
'focusModes': self.parameters.get('focus-mode-values', 'fixed').split(','),
|
||||
'maxFocusAreas': min(1,int(self.parameters.get('max-num-focus-areas', 0))),
|
||||
|
|
@ -135,6 +136,16 @@ class Camera:
|
|||
def control(self, command):
|
||||
P, I = C.c_void_p, C.c_int
|
||||
action = command.get('action')
|
||||
if action == 'flash':
|
||||
mode = command.get('mode')
|
||||
modes = ('off', 'auto', 'on', 'torch')
|
||||
if mode not in modes or mode not in self.metadata()['flashModes']:
|
||||
raise ValueError('CAMERA_FLASH_UNAVAILABLE')
|
||||
function(self.lib, 'android_camera_set_flash_mode', [P,I])(self.camera,modes.index(mode))
|
||||
actual = self.read_parameters().get('flash-mode', 'off')
|
||||
if actual != mode:
|
||||
raise RuntimeError('CAMERA_FLASH_READBACK_FAILED')
|
||||
return {'mode':actual}
|
||||
if action == 'zoom':
|
||||
ratio = command.get('zoom')
|
||||
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:
|
||||
|
|
|
|||
59
tundra/runtime/clock-state.py
Normal file
59
tundra/runtime/clock-state.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Preserve wall time across boots when Qualcomm's RTC has no Unix epoch."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
STATE = Path('/var/lib/tundra/clock/last-time.json')
|
||||
RTC = Path('/sys/class/rtc/rtc0/since_epoch')
|
||||
|
||||
|
||||
def rtc_seconds():
|
||||
try:
|
||||
return int(RTC.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def valid_epoch(value):
|
||||
return type(value) in (int, float) and 946684800 <= value <= 4102444800
|
||||
|
||||
|
||||
def restore():
|
||||
try:
|
||||
saved = json.loads(STATE.read_text())
|
||||
epoch, previous = saved['epoch'], saved.get('rtc')
|
||||
if not valid_epoch(epoch):
|
||||
return False
|
||||
current = rtc_seconds()
|
||||
# A reset/replaced RTC is not evidence of time elapsed while powered off.
|
||||
if type(previous) is int and current is not None and 0 <= current - previous <= 366*86400:
|
||||
epoch += current - previous
|
||||
if valid_epoch(epoch) and time.time() < epoch:
|
||||
time.clock_settime(time.CLOCK_REALTIME, epoch)
|
||||
return True
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def save():
|
||||
epoch = time.time()
|
||||
if not valid_epoch(epoch):
|
||||
return
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = STATE.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps({'epoch': epoch, 'rtc': rtc_seconds()}) + '\n')
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(STATE)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if sys.argv[1:] == ['--restore']:
|
||||
restore()
|
||||
elif sys.argv[1:] == ['--save']:
|
||||
save()
|
||||
else:
|
||||
raise SystemExit('Use --restore or --save')
|
||||
|
|
@ -8,6 +8,11 @@ from pathlib import Path
|
|||
|
||||
CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
|
||||
|
||||
def checkpoint_clock():
|
||||
import runpy
|
||||
runpy.run_path(str(Path(__file__).with_name('clock-state.py')))['save']()
|
||||
|
||||
|
||||
def clock_automatic():
|
||||
try:
|
||||
return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
|
||||
|
|
@ -43,6 +48,7 @@ def sync_network_clock():
|
|||
if abs(time.time() - seconds) > 2:
|
||||
# oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
|
||||
time.clock_settime(time.CLOCK_REALTIME, seconds)
|
||||
checkpoint_clock()
|
||||
|
||||
|
||||
|
||||
|
|
@ -159,6 +165,8 @@ def device_request(value):
|
|||
result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise RuntimeError('CLOCK_REQUEST_FAILED')
|
||||
if action == 'setTime':
|
||||
checkpoint_clock()
|
||||
result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
|
|
|
|||
|
|
@ -37,6 +37,38 @@ def prepare(root, network=None, hardware=None, diagnostics=False):
|
|||
ExecStartPre=/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
|
||||
''')
|
||||
write('etc/os-release','NAME="Tundra"\nID=tundra\nPRETTY_NAME="Tundra Sargo development trial"\n')
|
||||
(root/'etc/tundra').mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(Path(__file__).with_name('clock-state.py'), root/'etc/tundra/clock-state.py')
|
||||
write('etc/systemd/system/tundra-clock-restore.service', """[Unit]
|
||||
Description=Restore Tundra clock from persistent state and RTC elapsed time
|
||||
DefaultDependencies=no
|
||||
After=local-fs.target
|
||||
Before=time-set.target tundra-display.service tundra-radio.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/clock-state.py --restore
|
||||
RemainAfterExit=yes
|
||||
ExecStop=/usr/bin/python3 /etc/tundra/clock-state.py --save
|
||||
StateDirectory=tundra/clock
|
||||
""")
|
||||
write('etc/systemd/system/tundra-clock-save.service', """[Unit]
|
||||
Description=Checkpoint Tundra clock
|
||||
After=tundra-clock-restore.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/clock-state.py --save
|
||||
StateDirectory=tundra/clock
|
||||
""")
|
||||
write('etc/systemd/system/tundra-clock-save.timer', """[Unit]
|
||||
Description=Checkpoint Tundra clock once per minute
|
||||
[Timer]
|
||||
OnBootSec=60
|
||||
OnUnitActiveSec=60
|
||||
""")
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
wants.mkdir(parents=True, exist_ok=True)
|
||||
for unit in ('tundra-clock-restore.service', 'tundra-clock-save.timer'):
|
||||
(wants/unit).symlink_to('../'+unit)
|
||||
write('etc/hostname','vulpes-sargo\n')
|
||||
write('etc/shells','/bin/sh\n/bin/bash\n/usr/bin/bash\n')
|
||||
write('etc/pam.d/tundra','auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_systemd.so\n')
|
||||
|
|
@ -177,6 +209,7 @@ NoNewPrivileges=yes
|
|||
write('etc/NetworkManager/conf.d/80-vulpes-hotspot.conf',Path(__file__).with_name('hotspot-firewall.conf').read_text())
|
||||
for name in ('iptables','ip6tables'):
|
||||
alias=root/'usr/sbin'/name
|
||||
alias.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not alias.exists() and not alias.is_symlink():alias.symlink_to(name+'-legacy')
|
||||
write('etc/tundra/network-enabled', 'sargo\n')
|
||||
display = root/'etc/systemd/system/tundra-display.service'
|
||||
|
|
|
|||
26
tundra/tests/beatles-keymap-test.c
Normal file
26
tundra/tests/beatles-keymap-test.c
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <assert.h>
|
||||
#define main keypad_reader_main
|
||||
#include "../probes/beatles-keypad.c"
|
||||
#undef main
|
||||
|
||||
int main(void) {
|
||||
for(unsigned code=KEY_1;code<=KEY_0;code++)assert(symbol(code)!=NoSymbol);
|
||||
const unsigned controls[]={KEY_UP,KEY_DOWN,KEY_LEFT,KEY_RIGHT,KEY_OK,
|
||||
KEY_BACK,KEY_BACKSPACE,KEY_MENU,KEY_SEND,KEY_POWER,KEY_CAMERA,
|
||||
KEY_NUMERIC_STAR,KEY_NUMERIC_POUND,KEY_VOLUMEUP,KEY_VOLUMEDOWN};
|
||||
for(unsigned i=0;i<sizeof(controls)/sizeof(controls[0]);i++)
|
||||
assert(symbol(controls[i])!=NoSymbol);
|
||||
assert(native_code("Notifications")==KEY_CAMERA);
|
||||
assert(symbol(KEY_CAMERA)==XK_F2);
|
||||
/* Do not give the assistance key an accidental call/emergency action. */
|
||||
assert(symbol(112)==NoSymbol);
|
||||
assert(!allow_key(KEY_SEND,1,1));
|
||||
assert(!allow_key(KEY_2,1,1));
|
||||
assert(allow_key(KEY_SEND,0,1));
|
||||
assert(allow_key(KEY_VOLUMEUP,1,1));
|
||||
assert(allow_key(KEY_VOLUMEDOWN,1,1));
|
||||
assert(allow_key(KEY_2,1,0));
|
||||
puts("Nokia key map and closed-lid input policy: OK");
|
||||
return 0;
|
||||
}
|
||||
56
tundra/tests/beatles-lid-test.c
Normal file
56
tundra/tests/beatles-lid-test.c
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* SPDX-License-Identifier: MPL-2.0 */
|
||||
#include <assert.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/fb.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#define ROOT "/nonexistent-vulpes-lid-test"
|
||||
static char keys_path[]="/tmp/vulpes-lid-keys-XXXXXX";
|
||||
#define BEATLES_KEYS_BRIGHTNESS keys_path
|
||||
static int last_panel=-1;
|
||||
static int inner=-1,outer=-1,fail_inner=0;
|
||||
static int panel_ioctl(int fd,unsigned long request,int value) {
|
||||
assert(request==FBIOBLANK);
|
||||
last_panel=fd;
|
||||
if(fd==10){if(fail_inner)return -1;inner=value;}
|
||||
else {assert(fd==11);outer=value;}
|
||||
return 0;
|
||||
}
|
||||
#define ioctl panel_ioctl
|
||||
#include "../probes/beatles-lid.h"
|
||||
int main(void) {
|
||||
assert(beatles_hall_open(0)==1);
|
||||
assert(beatles_hall_open(1)==0);
|
||||
assert(beatles_hall_open(-1)==-1);
|
||||
assert(beatles_hall_open(2)==-1);
|
||||
int fixture=mkstemp(keys_path);assert(fixture>=0);close(fixture);
|
||||
lid_keys_write(40);assert(lid_keys_read()==40);
|
||||
struct lid_state lid={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
|
||||
lid_apply(&lid,1,1,100);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,0,1,101);assert(inner==FB_BLANK_POWERDOWN && outer==FB_BLANK_UNBLANK);
|
||||
assert(lid_keys_read()==0);
|
||||
lid_apply(&lid,0,1,117);assert(outer==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,-1,1,118);assert(inner==FB_BLANK_POWERDOWN);
|
||||
lid_apply(&lid,1,1,119);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
|
||||
assert(last_panel==10 && lid_keys_read()==40);
|
||||
fail_inner=1;lid_apply(&lid,0,1,120);assert(lid.opened==1 && !lid.outer_on);
|
||||
fail_inner=0;lid_apply(&lid,0,0,121);assert(!lid.outer_on);
|
||||
lid_apply(&lid,0,1,150);assert(lid.outer_on); /* late renderer still gets 15s */
|
||||
lid_apply(&lid,0,1,166);assert(!lid.outer_on);
|
||||
struct lid_state closed={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
|
||||
lid_apply(&closed,0,0,200);
|
||||
assert(inner==FB_BLANK_POWERDOWN && !closed.outer_on && lid_keys_read()==0);
|
||||
/* Xorg unblanks fb0 while initializing, without a physical lid transition. */
|
||||
inner=FB_BLANK_UNBLANK;
|
||||
lid_apply(&closed,0,0,205);
|
||||
lid_apply(&closed,0,1,220);
|
||||
assert(inner==FB_BLANK_POWERDOWN && closed.outer_on && lid_keys_read()==0);
|
||||
lid_apply(&closed,0,1,234);assert(closed.outer_on);
|
||||
lid_apply(&closed,0,1,235);assert(!closed.outer_on);
|
||||
lid.outer=-1;lid_apply(&lid,1,1,170);lid_apply(&lid,0,1,171);assert(!lid.outer_on);
|
||||
lid_restore(&lid);assert(lid_keys_read()==40);unlink(keys_path);
|
||||
puts("Clamshell panel transitions and keypad backlight: OK");return 0;
|
||||
}
|
||||
44
tundra/tests/test_beatles_boot.py
Normal file
44
tundra/tests/test_beatles_boot.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'tools'))
|
||||
from beatles_boot import read_cpio, write_cpio, add_file, pack_boot, unpack_boot
|
||||
|
||||
class BootTests(unittest.TestCase):
|
||||
def archive(self):
|
||||
entries={'.':{'fields':[1,stat.S_IFDIR|0o750,1000,1001,2,17,0,0,0,0,0,2,0],'data':b''}}
|
||||
add_file(entries,'init.rc',b'on boot\n',0o640)
|
||||
entries['init.rc']['fields'][2:4]=[1000,1001]
|
||||
return entries
|
||||
def test_repack_preserves_ownership_and_modes(self):
|
||||
original=self.archive();repacked=read_cpio(write_cpio(original))
|
||||
self.assertEqual(repacked['init.rc']['data'],b'on boot\n')
|
||||
self.assertEqual(repacked['init.rc']['fields'][1:6],original['init.rc']['fields'][1:6])
|
||||
self.assertEqual(write_cpio(original),write_cpio(original))
|
||||
def test_missing_trailer_and_truncated_entry_rejected(self):
|
||||
raw=gzip.decompress(write_cpio(self.archive()))
|
||||
for end in (90,115,180):
|
||||
with self.assertRaises(ValueError):read_cpio(gzip.compress(raw[:end]))
|
||||
def test_traversal_rejected(self):
|
||||
entries=self.archive();add_file(entries,'../escape',b'unsafe')
|
||||
with self.assertRaises(ValueError):read_cpio(write_cpio(entries))
|
||||
def test_duplicate_added_file_rejected(self):
|
||||
with self.assertRaises(ValueError):add_file(self.archive(),'init.rc',b'bad')
|
||||
def image(self):
|
||||
header=bytearray(2048);header[:8]=b'ANDROID!'
|
||||
struct.pack_into('<10I',header,8,3,0x80008000,4,0x81000000,0,0,0x80000100,2048,0,0)
|
||||
return bytes(header)+b'abc'+b'\0'*2045+b'test'+b'\0'*2044+b'old signature'
|
||||
def test_changed_image_drops_original_signature_and_preserves_kernel(self):
|
||||
result=unpack_boot(pack_boot(self.image(),b'new ramdisk'))
|
||||
self.assertEqual(result['kernel'],b'abc');self.assertEqual(result['ramdisk'],b'new ramdisk')
|
||||
self.assertEqual(result['trailer'],b'')
|
||||
def test_unsupported_header_and_short_payload_rejected(self):
|
||||
with self.assertRaises(ValueError):unpack_boot(self.image()[:2500])
|
||||
image=bytearray(self.image());struct.pack_into('<I',image,40,1)
|
||||
with self.assertRaises(ValueError):unpack_boot(image)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
38
tundra/tests/test_beatles_boot_guard.py
Normal file
38
tundra/tests/test_beatles_boot_guard.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SOURCE=Path(__file__).parents[1]/'devices/beatles/boot/vulpes-boot.sh'
|
||||
|
||||
class GuardTests(unittest.TestCase):
|
||||
def run_guard(self,mode):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);base=root/'runtime';base.mkdir()
|
||||
screen=root/'fb';screen.touch();ril=socket.socket(socket.AF_UNIX)
|
||||
try:
|
||||
ril.bind(str(root/'ril'))
|
||||
start=root/'start';start.write_text('#!/bin/sh\necho "$1" >> '+str(root/'fallback')+'\n');start.chmod(0o755)
|
||||
supervisor=root/'display'
|
||||
action='exit 1' if mode=='launch-failed' else 'exit 0'
|
||||
if mode=='ready':action='printf \'{"readyMs":100}\' > '+str(base/'rootfs/work/gaia-audit.json')
|
||||
supervisor.write_text('#!/bin/sh\n'+action+'\n');supervisor.chmod(0o755)
|
||||
if mode!='missing':
|
||||
(base/'rootfs/opt/firefox').mkdir(parents=True);(base/'rootfs/work').mkdir()
|
||||
engine=base/'rootfs/opt/firefox/firefox';engine.touch();engine.chmod(0o755)
|
||||
(base/'runtime-ready').touch()
|
||||
if mode=='disabled':(base/'disable-boot').touch()
|
||||
script=SOURCE.read_text().replace('/data/local/vulpes-beatles',str(base)).replace('/system/bin/start',str(start)).replace('/sbin/vulpes-display',str(supervisor)).replace('/system/bin/busybox grep','grep').replace('/dev/graphics/fb0',str(screen)).replace('/dev/socket/rilproxy',str(root/'ril')).replace('sleep 1',':')
|
||||
proc=subprocess.run(['sh','-c',script],capture_output=True,text=True,timeout=5)
|
||||
self.assertEqual(proc.returncode,0,proc.stderr)
|
||||
return (root/'fallback').exists(),(base/'stop-session').exists()
|
||||
finally:ril.close()
|
||||
def test_missing_runtime_falls_back(self):self.assertEqual(self.run_guard('missing'),(True,False))
|
||||
def test_opt_out_falls_back(self):self.assertEqual(self.run_guard('disabled'),(True,False))
|
||||
def test_failed_launch_falls_back(self):self.assertEqual(self.run_guard('launch-failed'),(True,False))
|
||||
def test_stalled_gaia_requests_stop_and_falls_back(self):self.assertEqual(self.run_guard('stalled'),(True,True))
|
||||
def test_ready_gaia_does_not_start_second_ui(self):self.assertEqual(self.run_guard('ready'),(False,False))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
60
tundra/tests/test_beatles_control_client.py
Normal file
60
tundra/tests/test_beatles_control_client.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class ControlRelayTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.directory=tempfile.TemporaryDirectory()
|
||||
cls.root=Path(cls.directory.name)
|
||||
code=(Path(__file__).parents[1]/'probes/beatles-control-client.c').read_text()
|
||||
for name in ('phone-control','wifi-control'):
|
||||
code=code.replace('/work/'+name+'.sock',str(cls.root/(name+'.sock')))
|
||||
source=cls.root/'client.c';source.write_text(code);cls.binary=cls.root/'client'
|
||||
subprocess.run(['cc','-Wall','-Wextra','-Werror',str(source),'-o',str(cls.binary)],check=True)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):cls.directory.cleanup()
|
||||
|
||||
def exchange(self,mode,reply):
|
||||
path=self.root/(('phone' if mode=='--radio' else 'wifi')+'-control.sock')
|
||||
path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
||||
server.bind(str(path));server.listen(1);server.settimeout(3)
|
||||
def broker():
|
||||
with server.accept()[0] as conn:
|
||||
conn.settimeout(3);data=b''
|
||||
while b'\n' not in data:data+=conn.recv(4096)
|
||||
for part in reply:conn.sendall(part)
|
||||
return data
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future=pool.submit(broker)
|
||||
result=subprocess.run([str(self.binary),mode],input=b'{"operation":"status"}',capture_output=True,timeout=4)
|
||||
request=future.result()
|
||||
return result,request
|
||||
|
||||
def test_radio_and_wifi_preserve_broker_envelopes(self):
|
||||
for mode in ('--radio','--wifi'):
|
||||
result,request=self.exchange(mode,[b'{"ok":true,',b'"result":[]}\n'])
|
||||
self.assertEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'{"ok":true,"result":[]}\n')
|
||||
self.assertEqual(request,b'{"operation":"status"}\n')
|
||||
|
||||
def test_disconnect_and_oversized_response_fail(self):
|
||||
for reply in ([b'{"incomplete":'],[b'x'*65537]):
|
||||
result,_=self.exchange('--wifi',reply)
|
||||
self.assertNotEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'')
|
||||
|
||||
def test_unknown_target_and_multiple_requests_are_rejected(self):
|
||||
for mode,data in [('--shell',b'{}'),('--radio',b'{}\n{}'),('--wifi',b'x'*8193)]:
|
||||
result=subprocess.run([str(self.binary),mode],input=data,capture_output=True,timeout=3)
|
||||
self.assertNotEqual(result.returncode,0)
|
||||
self.assertEqual(result.stdout,b'')
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
63
tundra/tests/test_beatles_cover.py
Normal file
63
tundra/tests/test_beatles_cover.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
import tempfile
|
||||
import struct
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('cover',Path(__file__).parents[1]/'devices/beatles/cover-display.py')
|
||||
cover=importlib.util.module_from_spec(spec);spec.loader.exec_module(cover)
|
||||
|
||||
class CoverTests(unittest.TestCase):
|
||||
def test_animation_timing_padding_and_loop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'animation'
|
||||
first=b'\x00\xf8'*(240*240);second=b'\x1f\x00'*(240*240)
|
||||
path.write_bytes(b'VULPANM1'+struct.pack('<I',2)+struct.pack('<I',50)+first+struct.pack('<I',100)+second)
|
||||
animation=cover.Animation(path)
|
||||
try:
|
||||
self.assertEqual(animation.frame(0,480),(first,.05,0))
|
||||
self.assertEqual(animation.frame(.05,480),(second,.1,1))
|
||||
self.assertEqual(animation.frame(.15,480),(first,.05,0))
|
||||
padded,_,_=animation.frame(0,484)
|
||||
self.assertEqual(len(padded),240*484)
|
||||
self.assertEqual(padded[480:484],bytes(4))
|
||||
finally:animation.close()
|
||||
|
||||
def test_animation_rejects_invalid_and_truncated_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'animation'
|
||||
for data in (b'',b'WRONGHDR'+struct.pack('<I',1),b'VULPANM1'+struct.pack('<I',241),
|
||||
b'VULPANM1'+struct.pack('<II',1,1)+bytes(240*240*2)):
|
||||
path.write_bytes(data)
|
||||
with self.assertRaises(ValueError):cover.Animation(path)
|
||||
|
||||
def test_unknown_and_stale_are_not_full_battery_or_registered_network(self):
|
||||
self.assertEqual(cover.labels({},100),('—','—'))
|
||||
self.assertEqual(cover.labels({'recordedAt':1,'battery':{'level':1}},100),('—','—'))
|
||||
def test_live_values_and_absent_second_sim(self):
|
||||
state={'recordedAt':100,'battery':{'level':.73},'radio':[
|
||||
{'available':False},{'available':True,'operator':{'longName':'Orange F'}}]}
|
||||
self.assertEqual(cover.labels(state,105),('73 %','Orange F'))
|
||||
def test_custom_title_and_colors(self):
|
||||
title,ink,battery=cover.preferences({'vulpes.cover.title':' Mon Nokia ',
|
||||
'vulpes.cover.titleColor':'#FF0000','vulpes.cover.batteryColor':'#00ff00'})
|
||||
self.assertEqual(title,'Mon Nokia');self.assertEqual(ink,(1,0,0));self.assertEqual(battery,(0,1,0))
|
||||
|
||||
def test_invalid_preferences_and_long_title_are_safe(self):
|
||||
title,ink,battery=cover.preferences({'vulpes.cover.title':['invalid'],
|
||||
'vulpes.cover.titleColor':'url(file:///x)','vulpes.cover.batteryColor':None})
|
||||
self.assertEqual(title,'VULPES');self.assertEqual(ink,battery)
|
||||
self.assertEqual(cover.preferences({'vulpes.cover.title':'A'*30+'\n'})[0],'A'*24)
|
||||
self.assertEqual(cover.preferences({'vulpes.cover.title':'\n'})[0],'VULPES')
|
||||
|
||||
def test_invalid_battery(self):
|
||||
self.assertEqual(cover.labels({'recordedAt':100,'battery':{'level':2}},100),('—','—'))
|
||||
|
||||
def test_rejected_refresh_can_recover_without_stopping_gaia(self):
|
||||
with patch.object(cover.fcntl,'ioctl',side_effect=[PermissionError(1,'panel asleep'),0]):
|
||||
self.assertEqual(cover.present(3,bytearray(160)),{'ok':False,'errno':1})
|
||||
self.assertEqual(cover.present(3,bytearray(160)),{'ok':True})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
37
tundra/tests/test_beatles_hardware.py
Normal file
37
tundra/tests/test_beatles_hardware.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import sys,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
|
||||
from hardware_control import HardwareControl
|
||||
|
||||
class HardwareTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name)
|
||||
for name,value in [('backlight',170),('backlight-max',255),('vibrator',0)]:
|
||||
(self.root/name).write_text(str(value))
|
||||
self.hw=HardwareControl(self.root)
|
||||
def value(self,name):return int((self.root/name).read_text())
|
||||
def test_brightness_and_restore(self):
|
||||
self.hw.request({'operation':'screen','enabled':True,'level':.2})
|
||||
self.assertEqual(self.value('backlight'),51)
|
||||
self.hw.request({'operation':'screen','enabled':False,'level':.2})
|
||||
self.assertEqual(self.value('backlight'),0)
|
||||
self.hw.restore();self.assertEqual(self.value('backlight'),170)
|
||||
def test_vibration_sequence_cancel_and_shutdown(self):
|
||||
with patch('hardware_control.time.monotonic',return_value=10) as clock:
|
||||
self.hw.request({'operation':'vibrate','pattern':[120,80,200]})
|
||||
self.assertEqual(self.value('vibrator'),120)
|
||||
clock.return_value=10.13;self.hw.tick();self.assertEqual(self.value('vibrator'),0)
|
||||
clock.return_value=10.22;self.hw.tick();self.assertEqual(self.value('vibrator'),200)
|
||||
self.hw.request({'operation':'vibrate','pattern':[]})
|
||||
self.assertEqual(self.value('vibrator'),0);self.assertFalse(self.hw.pattern)
|
||||
self.hw.request({'operation':'vibrate','pattern':[200]});self.hw.restore()
|
||||
self.assertEqual(self.value('vibrator'),0)
|
||||
def test_invalid_requests_never_write(self):
|
||||
for request in ({'operation':'vibrate','pattern':[10001]}, {'operation':'vibrate','pattern':[True]},
|
||||
{'operation':'vibrate','pattern':[5000,5001]}, {'operation':'screen','enabled':True,'level':float('nan')},
|
||||
{'operation':'screen','enabled':1,'level':.5},{'operation':'shell'},None):
|
||||
with self.assertRaises(ValueError):self.hw.request(request)
|
||||
self.assertEqual(self.value('backlight'),170);self.assertEqual(self.value('vibrator'),0)
|
||||
213
tundra/tests/test_beatles_phone.py
Normal file
213
tundra/tests/test_beatles_phone.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
DEVICE=Path(__file__).parents[1]/'devices/beatles'
|
||||
sys.path.insert(0,str(DEVICE))
|
||||
import radio_control as control
|
||||
import sms_pdu as sms
|
||||
from radio_status import Parcel
|
||||
|
||||
HELLO='00040A91214365870900006201812100008005E8329BFD06'
|
||||
|
||||
class PduTests(unittest.TestCase):
|
||||
def test_standard_gsm_vector_and_unicode(self):
|
||||
self.assertEqual(sms.pack7(sms.septets('hello')).hex(),'e8329bfd06')
|
||||
self.assertEqual(sms.submit('+1234567890','hello'),'01000A912143658709000005E8329BFD06')
|
||||
row=sms.deliver(HELLO)
|
||||
self.assertEqual(row['sender'],'+1234567890');self.assertEqual(row['body'],'hello')
|
||||
self.assertEqual(row['timestamp'],1792317600000)
|
||||
self.assertEqual(sms.submit('123','漢').lower(),'0100038121f30008026f22')
|
||||
def test_extensions_and_length_limits(self):
|
||||
text='é € {} [] ^ ~ \\';values=sms.septets(text)
|
||||
self.assertEqual(sms.unpack7(sms.pack7(values),len(values)),text)
|
||||
sms.submit('123','A'*160)
|
||||
for text in ('A'*161,'€'*81,'漢'*71):
|
||||
with self.assertRaisesRegex(ValueError,'SMS_TOO_LONG'):sms.submit('123',text)
|
||||
for value in ('112;reboot','*#06#','',None,'+123\n'):
|
||||
with self.assertRaises(ValueError):sms.submit(value,'hello')
|
||||
def test_truncated_and_unsupported_pdus(self):
|
||||
for end in range(0,len(HELLO),2):
|
||||
with self.assertRaises((ValueError,UnicodeError)):sms.deliver(HELLO[:end])
|
||||
for pdu in ('00ff','xx','00040A9121436587097F006201812100008005E8329BFD06'):
|
||||
with self.assertRaises(ValueError):sms.deliver(pdu)
|
||||
def test_unicode_and_concatenation(self):
|
||||
prefix='00440A912143658709000862018121000080'
|
||||
row=sms.deliver(prefix+'080500030702010041')
|
||||
self.assertEqual(row['body'],'A');self.assertEqual(row['concat'],(7,2,1))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
|
||||
inbox.receive(prefix+'080500030702020042');self.assertEqual(inbox.value['inbox'],[])
|
||||
inbox.receive(prefix+'080500030702010041')
|
||||
self.assertEqual(inbox.value['inbox'][0]['body'],'AB')
|
||||
self.assertEqual(control.Inbox(path).value['inbox'][0]['body'],'AB')
|
||||
def test_durable_inbox_dedup_and_failed_save(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
|
||||
with patch.object(inbox,'save',side_effect=OSError('disk full')):
|
||||
with self.assertRaises(OSError):inbox.receive(HELLO)
|
||||
self.assertEqual(inbox.value['seen'],{})
|
||||
inbox.receive(HELLO);inbox.receive(HELLO)
|
||||
self.assertEqual(len(inbox.value['inbox']),1)
|
||||
self.assertEqual(path.stat().st_mode&0o777,0o600)
|
||||
receipt=inbox.value['inbox'][0]['receipt'];inbox.acknowledge([receipt])
|
||||
inbox.receive(HELLO);self.assertEqual(inbox.value['inbox'],[])
|
||||
|
||||
class ControlTests(unittest.TestCase):
|
||||
def test_unanswered_request_is_not_sent_again(self):
|
||||
radio=control.ControlRadio('/unused',0,None)
|
||||
radio.pending={10:(9,0)}
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(radio,'send') as send:
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.exchange(9)
|
||||
send.assert_not_called()
|
||||
|
||||
def test_call_list_timeout_is_short_and_request_stays_pending(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.sock=unittest.mock.Mock()
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(control.time,'monotonic',side_effect=[0,0,1,2,3]),patch.object(broker,'step'):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_TIMEOUT'):broker.exchange(9)
|
||||
self.assertIsNone(radio.waiting)
|
||||
self.assertEqual(len(radio.pending),1)
|
||||
|
||||
def test_call_layout_and_caller_privacy(self):
|
||||
call=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(0)+control.string(None)+control.integer(0,0)
|
||||
self.assertEqual(control.current_calls(Parcel(call)),[{'index':2,'state':'incoming','number':'+123456789','direction':'incoming'}])
|
||||
hidden=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(1)+control.string(None)+control.integer(0,0)
|
||||
self.assertEqual(control.current_calls(Parcel(hidden))[0]['number'],'')
|
||||
self.assertEqual(control.current_calls(Parcel(b'')),[])
|
||||
def test_late_modem_reply_does_not_complete_another_request(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
|
||||
radio.response(9,0,Parcel(control.integer(0)),19);self.assertIsNone(radio.result)
|
||||
radio.response(9,0,Parcel(control.integer(0)),20);self.assertEqual(radio.result,(True,[]))
|
||||
def test_malformed_call_list_does_not_drop_radio_transport(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
|
||||
radio.sock=unittest.mock.Mock()
|
||||
radio.response(9,0,Parcel(control.integer(1)),20)
|
||||
self.assertEqual(radio.result,(False,'RADIO_INVALID_CALL_LIST'))
|
||||
radio.sock.close.assert_not_called()
|
||||
|
||||
def test_unknown_operations_do_not_reach_the_modem(self):
|
||||
broker=control.Broker([],None)
|
||||
with patch.object(broker,'exchange') as exchange:
|
||||
for value in ({'version':1,'operation':'shell'},{'operation':'sms'},[],None):
|
||||
with self.assertRaises(ValueError):broker.request(value)
|
||||
exchange.assert_not_called()
|
||||
def test_calls_answer_hangup_and_sms_parcels(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
|
||||
broker=control.Broker([radio],None)
|
||||
row={'index':2,'state':'incoming','number':'123','direction':'incoming'}
|
||||
with patch.object(broker,'exchange',return_value=[row]) as exchange:
|
||||
calls=broker.request({'version':1,'operation':'calls'});path=calls[0]['path']
|
||||
broker.request({'version':1,'operation':'hangup','path':path})
|
||||
self.assertEqual(exchange.call_args.args,(12,control.integer(1,2)))
|
||||
broker.request({'version':1,'operation':'answer','path':path})
|
||||
self.assertEqual(exchange.call_args.args,(40,))
|
||||
with self.assertRaisesRegex(ValueError,'INVALID_CALL'):
|
||||
broker.request({'version':1,'operation':'hangup','path':'/stale/call/2'})
|
||||
with patch.object(broker,'exchange',return_value={'state':'sent'}) as exchange:
|
||||
self.assertEqual(broker.request({'version':1,'operation':'sms','number':'123','body':'hello'}),{'state':'sent'})
|
||||
self.assertEqual(exchange.call_args.args,(25,control.integer(2)+control.string(None)+control.string(sms.submit('123','hello'))))
|
||||
def test_second_call_is_rejected_before_dial(self):
|
||||
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
|
||||
broker=control.Broker([radio],None)
|
||||
with patch.object(broker,'calls',return_value=[{'path':'/live/call/1'}]),patch.object(broker,'exchange') as exchange:
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.request({'version':1,'operation':'dial','number':'123'})
|
||||
exchange.assert_not_called()
|
||||
|
||||
def test_sms_ack_only_after_storage(self):
|
||||
inbox=unittest.mock.Mock();radio=control.ControlRadio('/unused',0,inbox)
|
||||
with patch.object(radio,'send') as send:
|
||||
radio.unsolicited(1003,Parcel(control.string(HELLO)))
|
||||
inbox.receive.assert_called_once_with(HELLO);send.assert_called_with(37,control.integer(2,1,0))
|
||||
inbox.receive.side_effect=OSError('full')
|
||||
radio.unsolicited(1003,Parcel(control.string(HELLO)))
|
||||
send.assert_called_with(37,control.integer(2,0,255))
|
||||
|
||||
|
||||
class RadioPowerTests(unittest.TestCase):
|
||||
def broker(self):
|
||||
radios=[control.ControlRadio('/unused',i,None) for i in range(2)]
|
||||
for radio in radios:
|
||||
radio.state={'radioState':'enabled'};radio.sock=unittest.mock.Mock()
|
||||
return control.Broker(radios,None)
|
||||
def test_slot_payload_readback_and_restore(self):
|
||||
broker=self.broker()
|
||||
def exchange(request,payload,slot=0):
|
||||
self.assertEqual(request,23)
|
||||
count,enabled=struct.unpack('<ii',payload);self.assertEqual(count,1)
|
||||
broker.radios[slot].state['radioState']='enabled' if enabled else 'disabled'
|
||||
with patch.object(broker,'exchange',side_effect=exchange) as send,patch.object(broker,'calls',return_value=[]):
|
||||
self.assertEqual(broker.request({'version':1,'operation':'radioPower','slot':1,'enabled':False}),
|
||||
{'slot':1,'radioState':'disabled'})
|
||||
self.assertEqual(broker.radios[0].state['radioState'],'enabled')
|
||||
send.assert_called_once_with(23,control.integer(1,0),slot=1)
|
||||
broker.restore_power()
|
||||
self.assertEqual(broker.radios[1].state['radioState'],'enabled')
|
||||
def test_idempotent_power_does_not_send(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange') as send:
|
||||
broker.radio_power(0,True);send.assert_not_called()
|
||||
def test_calls_invalid_values_and_unknown_state_block_power(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange') as send,patch.object(broker,'calls',return_value=[{'state':'active'}]):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.radio_power(0,False)
|
||||
for slot,enabled in [(False,False),(2,False),(0,0),(0,'false')]:
|
||||
with self.assertRaisesRegex(ValueError,'INVALID_REQUEST'):broker.radio_power(slot,enabled)
|
||||
broker.radios[0].state['radioState']='unknown'
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_NOT_READY'):broker.radio_power(0,False)
|
||||
send.assert_not_called()
|
||||
def test_acknowledgement_without_state_change_fails(self):
|
||||
broker=self.broker()
|
||||
with patch.object(broker,'exchange'),patch.object(broker,'calls',return_value=[]),patch.object(control.time,'monotonic',side_effect=[0,9]):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_READBACK_FAILED'):broker.radio_power(0,False)
|
||||
self.assertEqual(broker.original_power,{0:True})
|
||||
|
||||
|
||||
class AudioTests(unittest.TestCase):
|
||||
def test_call_mode_routing_and_restore(self):
|
||||
from call_audio import CallAudio
|
||||
audio=CallAudio();state={'mode':0,'route':0,'muted':0};commands=[]
|
||||
def transaction(service,method,*values):
|
||||
commands.append((service,method,values))
|
||||
if (service,method)==('policy',6):return state['route']
|
||||
if (service,method)==('flinger',18):return state['muted']
|
||||
if (service,method)==('policy',3):state['mode']=values[0]
|
||||
elif (service,method)==('policy',5):state['route']=values[1]
|
||||
elif (service,method)==('flinger',17):state['muted']=values[0]
|
||||
else:raise AssertionError('Unexpected audio transaction')
|
||||
return 0
|
||||
with patch.object(audio,'transact',side_effect=transaction):
|
||||
audio.update([{'state':'incoming'}]);self.assertEqual(state['mode'],1)
|
||||
audio.update([{'state':'active'}]);self.assertEqual(state['mode'],2)
|
||||
self.assertTrue(audio.request({'operation':'set','muted':True,'speaker':True})['speaker'])
|
||||
audio.restore();self.assertEqual(state,{'mode':0,'route':0,'muted':0})
|
||||
def test_audio_failure_does_not_throw_from_call_tracking(self):
|
||||
from call_audio import CallAudio
|
||||
audio=CallAudio()
|
||||
with patch.object(audio,'transact',side_effect=OSError('service missing')):
|
||||
audio.update([{'state':'active'}]);self.assertFalse(audio.available)
|
||||
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):audio.request({'operation':'status'})
|
||||
|
||||
class RilRequestSafetyTests(unittest.TestCase):
|
||||
def test_call_list_uses_android_request_nine(self):
|
||||
broker=control.Broker([],None)
|
||||
with patch.object(broker,'exchange',return_value=[]) as exchange:
|
||||
self.assertEqual(broker.calls(),[])
|
||||
exchange.assert_called_once_with(9)
|
||||
def test_pin_puk_and_identity_requests_never_reach_socket(self):
|
||||
radio=control.ControlRadio('/unused',0,None)
|
||||
radio.sock=unittest.mock.Mock()
|
||||
for request in (2,3,4,5,6,7,8,11,28,38,39,43):
|
||||
with self.assertRaisesRegex(ValueError,'RADIO_INVALID_REQUEST'):radio.send(request)
|
||||
radio.sock.sendall.assert_not_called()
|
||||
self.assertEqual(control.CONTROL_REQUESTS,{9,10,12,23,25,37,40})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
65
tundra/tests/test_beatles_radio.py
Normal file
65
tundra/tests/test_beatles_radio.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
spec=importlib.util.spec_from_file_location('beatles_radio',Path(__file__).resolve().parents[1]/'devices/beatles/radio_status.py')
|
||||
radio=importlib.util.module_from_spec(spec);spec.loader.exec_module(radio)
|
||||
def integers(*values):return struct.pack('<'+'i'*len(values),*values)
|
||||
def string(value):
|
||||
if value is None:return integers(-1)
|
||||
data=integers(len(value))+value.encode('utf-16-le')+b'\0\0'
|
||||
return data+b'\0'*((-len(data))%4)
|
||||
|
||||
class RadioTest(unittest.TestCase):
|
||||
def test_registration_does_not_publish_cell_location(self):
|
||||
payload=integers(4)+b''.join(map(string,['5','ffff','123456','14']))
|
||||
result=radio.decode(20,radio.Parcel(payload))['voice']
|
||||
self.assertEqual(result['type'],'lte');self.assertTrue(result['connected']);self.assertTrue(result['roaming'])
|
||||
self.assertNotIn('123456',str(result));self.assertNotIn('ffff',str(result))
|
||||
def test_lte_signal_and_registration_without_data_bearer(self):
|
||||
values=[99,0,0,0,0,0,0,99,95,0,0,0,0]
|
||||
self.assertEqual(radio.decode(19,radio.Parcel(integers(*values)))['signal'],47)
|
||||
values[8]=2147483647
|
||||
self.assertIsNone(radio.decode(19,radio.Parcel(integers(*values)))['signal'])
|
||||
payload=integers(4)+b''.join(map(string,['1','','','14']))
|
||||
state=radio.decode(21,radio.Parcel(payload))['data']
|
||||
self.assertEqual(state['state'],'registered');self.assertFalse(state['connected'])
|
||||
def test_sim_absent_and_locked(self):
|
||||
self.assertEqual(radio.decode(1,radio.Parcel(integers(0,0,-1,-1,-1,0))),{'sim':{'present':False,'cardState':'absent'}})
|
||||
payload=integers(1,0,0,-1,-1,1,2,2,0)+string('PRIVATE_AID')+string('SIM')+integers(0,1,0)
|
||||
result=radio.decode(1,radio.Parcel(payload))
|
||||
self.assertEqual(result,{'sim':{'present':True,'cardState':'pinRequired'}})
|
||||
def test_bounds_and_unknown_signal(self):
|
||||
self.assertIsNone(radio.decode(19,radio.Parcel(integers(99)))['signal'])
|
||||
for data in [b'',integers(999999),integers(2)+b'a\0']:
|
||||
with self.assertRaises(ValueError):radio.Parcel(data).string()
|
||||
with self.assertRaises(ValueError):radio.decode(25,radio.Parcel(b''))
|
||||
def test_partial_frame_and_disconnect_drop_state(self):
|
||||
client,server=socket.socketpair()
|
||||
r=radio.Radio('/unused',0);r.sock=client;r.pending={3:(1,0)}
|
||||
body=integers(0,3,0,0,0,-1,-1,-1,0);frame=struct.pack('>I',len(body))+body
|
||||
server.sendall(frame[:7]);r.receive();self.assertFalse(r.state['available'])
|
||||
server.sendall(frame[7:]);r.receive();self.assertFalse(r.state['sim']['present'])
|
||||
r.close();server.close();self.assertEqual(r.state,{'slot':0,'available':False,'radioState':'unknown'})
|
||||
def test_handover_grace_and_bounded_reconnect_backoff(self):
|
||||
r=radio.Radio('/unused',0);r.sock=Mock();r.serial=1;r.last_poll=100;r.pending={1:(9,90)}
|
||||
with patch.object(radio.time,'monotonic',return_value=110):
|
||||
r.poll();self.assertIsNotNone(r.sock)
|
||||
with patch.object(radio.time,'monotonic',return_value=140):
|
||||
r.poll();self.assertIsNone(r.sock);self.assertEqual(r.retry_at,145)
|
||||
r.close();self.assertEqual(r.retry_at,150)
|
||||
for _ in range(8):r.close()
|
||||
self.assertEqual(r.retry_at,200)
|
||||
|
||||
def test_battery_unknown_is_not_empty(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
p=Path(root);self.assertIsNone(radio.battery(p))
|
||||
(p/'capacity').write_text('73');(p/'status').write_text('Discharging')
|
||||
self.assertEqual(radio.battery(p),{'level':.73,'charging':False})
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
20
tundra/tests/test_beatles_sync.py
Normal file
20
tundra/tests/test_beatles_sync.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
spec=importlib.util.spec_from_file_location('sync',Path(__file__).resolve().parents[1]/'tools/sync-beatles-gaia.py')
|
||||
sync=importlib.util.module_from_spec(spec);spec.loader.exec_module(sync)
|
||||
|
||||
class SharedGaiaSync(unittest.TestCase):
|
||||
def test_incremental_changes_and_removal(self):
|
||||
self.assertEqual(sync.changes({'host/shell.js':'new','assets/a':'same','overrides/new.js':'new'},
|
||||
{'host/shell.js':'old','assets/a':'same','overrides/old.js':'old'}),
|
||||
(['host/shell.js','overrides/new.js'],['overrides/old.js']))
|
||||
def test_profiles_and_escape_cannot_be_deleted(self):
|
||||
for path in ('profiles/user.json','../profile','host/../../profile','/data/profile','hostile/file'):
|
||||
with self.subTest(path=path),self.assertRaises(ValueError):sync.changes({}, {path:'digest'})
|
||||
def test_first_sync_and_no_change(self):
|
||||
files={'host/shell.js':'digest','release.json':'digest'}
|
||||
self.assertEqual(sync.changes(files,{}),(sorted(files),[]))
|
||||
self.assertEqual(sync.changes(files,files),([],[]))
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
26
tundra/tests/test_beatles_t9.py
Normal file
26
tundra/tests/test_beatles_t9.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
'package_beatles', ROOT / 'tundra/tools/package-beatles-build.py')
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(package)
|
||||
|
||||
|
||||
class BeatlesT9Package(unittest.TestCase):
|
||||
def test_all_offline_lexicons_are_included_at_runtime_path(self):
|
||||
files = {}
|
||||
package.add_beatles_t9(files)
|
||||
self.assertEqual(len(files), 42)
|
||||
self.assertEqual(
|
||||
set(files),
|
||||
{f'opt/vulpes/tundra/devices/beatles/t9/{locale}.tsv'
|
||||
for locale in (path.stem for path in
|
||||
(ROOT / 'tundra/devices/beatles/t9').glob('*.tsv'))})
|
||||
self.assertTrue(all(path.is_file() for path in files.values()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
41
tundra/tests/test_beatles_volume.py
Normal file
41
tundra/tests/test_beatles_volume.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import Mock,patch
|
||||
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
|
||||
from stream_volume import StreamVolume
|
||||
|
||||
|
||||
class VolumeTests(unittest.TestCase):
|
||||
def test_route_readback_and_restoration(self):
|
||||
volume=StreamVolume();state={(0,1):3,(0,2):5}
|
||||
def transaction(method,*args):
|
||||
if method==24:return [3]
|
||||
stream,device=args[0],args[-1]
|
||||
if method==17:return [state[stream,device],0]
|
||||
if method==16:state[stream,device]=args[1];return [0]
|
||||
raise AssertionError(method)
|
||||
volume.transact=transaction
|
||||
self.assertEqual(volume.request({'stream':'telephony','level':.4})['index'],2)
|
||||
self.assertEqual(state,{(0,1):2,(0,2):2})
|
||||
volume.request({'stream':'telephony','level':.8})
|
||||
volume.restore();self.assertEqual(state,{(0,1):3,(0,2):5})
|
||||
|
||||
def test_invalid_request_never_touches_hardware(self):
|
||||
volume=StreamVolume();volume.transact=Mock()
|
||||
for value in (None,{}, {'stream':'shell','level':.5}, *({'stream':'content','level':n} for n in (True,-1,2,'1',float('nan'),float('inf')))):
|
||||
with self.assertRaises(ValueError):volume.request(value)
|
||||
volume.transact.assert_not_called()
|
||||
|
||||
def test_readback_failure_is_reported(self):
|
||||
volume=StreamVolume();volume.transact=Mock(side_effect=[[2],[3,0],[0],[3,0]])
|
||||
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):
|
||||
volume.request({'stream':'content','level':.5})
|
||||
|
||||
def test_parcel_formats(self):
|
||||
volume=StreamVolume()
|
||||
for text,expected in (("Result: Parcel(00000008 00000000 '........')\n",[8,0]),
|
||||
("Result: Parcel(\n 0x00000000: 00000008 00000000 '........'\n)\n",[8,0])):
|
||||
with patch('stream_volume.subprocess.run',return_value=Mock(returncode=0,stdout=text)):
|
||||
self.assertEqual(volume.transact(17,3,2),expected)
|
||||
102
tundra/tests/test_beatles_wifi.py
Normal file
102
tundra/tests/test_beatles_wifi.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec=importlib.util.spec_from_file_location('beatles_wifi',Path(__file__).parents[1]/'devices/beatles/wifi_control.py')
|
||||
wifi=importlib.util.module_from_spec(spec);spec.loader.exec_module(wifi)
|
||||
|
||||
class WifiTests(unittest.TestCase):
|
||||
def fixture(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_scan=[{'id':'aa:bb:cc:dd:ee:ff','ssid':'Test "é"','security':'wpa-psk'}]
|
||||
obj.enabled=lambda:True
|
||||
obj.known=lambda:[]
|
||||
obj.stopping=False
|
||||
obj.lease=lambda state:None
|
||||
return obj
|
||||
|
||||
def test_scan_decodes_ssids_and_does_not_offer_enterprise_as_psk(self):
|
||||
data='bssid / frequency / signal level / flags / ssid\n'
|
||||
data+='aa:bb:cc:dd:ee:ff\t2412\t-45\t[WPA2-PSK-CCMP][ESS]\tCaf\\xc3\\xa9\\\\Test\n'
|
||||
data+='00:11:22:33:44:55\t5200\t-80\t[WPA2-EAP-CCMP]\tOffice\n'
|
||||
rows=wifi.parse_scan(data)
|
||||
self.assertEqual(rows[0]['ssid'],'Café\\Test')
|
||||
self.assertEqual(rows[0]['signal'],100)
|
||||
self.assertEqual(rows[1]['security'],'unsupported')
|
||||
self.assertEqual(wifi.parse_scan('header\nnot-a-bssid\t2412\t-20\t[ESS]\tfake'),[])
|
||||
|
||||
def test_invalid_password_cannot_create_or_mutate_network(self):
|
||||
with patch.object(wifi,'command') as command:
|
||||
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_PASSWORD'):
|
||||
self.fixture().connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'short'})
|
||||
command.assert_not_called()
|
||||
|
||||
def test_forget_rejects_command_injection(self):
|
||||
with patch.object(wifi,'command') as command:
|
||||
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_REQUEST'):
|
||||
self.fixture().request({'operation':'forget','id':'0\nREMOVE_NETWORK all'})
|
||||
command.assert_not_called()
|
||||
|
||||
def test_connect_encodes_ssid_and_passphrase_before_supplicant(self):
|
||||
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
calls=[]
|
||||
def command(text,required=True):
|
||||
calls.append(text)
|
||||
return '7' if text=='ADD_NETWORK' else 'OK'
|
||||
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
|
||||
self.assertIn('SET_NETWORK 7 ssid '+'Test "é"'.encode().hex(),calls)
|
||||
psk=next(c for c in calls if c.startswith('SET_NETWORK 7 psk ')).split()[-1]
|
||||
self.assertEqual(len(psk),64)
|
||||
self.assertNotIn('test-only-password',' '.join(calls))
|
||||
self.assertIn('SAVE_CONFIG',calls)
|
||||
|
||||
def test_saved_network_reuses_credentials_without_reading_password(self):
|
||||
obj=self.fixture();obj.known=lambda:[{'id':'3','ssid':'Test "é"','security':'wpa-psk'}]
|
||||
obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
with patch.object(wifi,'command',return_value='OK') as command,patch.object(wifi,'android',return_value='ok'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff'})
|
||||
self.assertEqual([call.args[0] for call in command.call_args_list],['SELECT_NETWORK 3'])
|
||||
|
||||
def test_save_failure_rolls_back_new_profile(self):
|
||||
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
|
||||
calls=[]
|
||||
def command(text,required=True):
|
||||
calls.append(text)
|
||||
return '7' if text=='ADD_NETWORK' else 'FAIL' if text=='SAVE_CONFIG' else 'OK'
|
||||
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
|
||||
with self.assertRaisesRegex(RuntimeError,'WIFI_SAVE_FAILED'):
|
||||
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
|
||||
self.assertIn('REMOVE_NETWORK 7',calls)
|
||||
self.assertIn('ENABLE_NETWORK all',calls)
|
||||
|
||||
class LeaseTests(unittest.TestCase):
|
||||
def test_dhcp_service_gets_dynamic_interface_argument(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_lease=0;obj.lease_network=None;obj.route=None
|
||||
state={'state':'connected','network':{'ssid':'Test','savedId':'0'}}
|
||||
with patch.object(wifi,'android',return_value='stopped') as android,patch.object(wifi.time,'monotonic',return_value=100):
|
||||
obj.lease(state)
|
||||
commands=[call.args for call in android.call_args_list]
|
||||
self.assertIn(('setprop','ctl.start','dhcpcd_wlan0:wlan0'),commands)
|
||||
self.assertNotIn(('setprop','ctl.restart','dhcpcd_wlan0'),commands)
|
||||
|
||||
def test_routes_are_restored_when_link_down_removed_cached_routes(self):
|
||||
obj=wifi.Wifi.__new__(wifi.Wifi)
|
||||
obj.last_lease=0;obj.lease_network=('Test','0');obj.rule_owned=True
|
||||
obj.route=('192.0.2.0/24','192.0.2.1')
|
||||
properties={'dhcp.wlan0.result':'ok','dhcp.wlan0.ipaddress':'192.0.2.3',
|
||||
'dhcp.wlan0.gateway':'192.0.2.1','dhcp.wlan0.mask':'255.255.255.0'}
|
||||
def native(*args,**kwargs):
|
||||
return properties.get(args[1],'') if args[0]=='getprop' else ''
|
||||
with patch.object(wifi,'android',side_effect=native) as android,patch.object(wifi.time,'monotonic',return_value=100):
|
||||
obj.lease({'state':'connected','network':{'ssid':'Test','savedId':'0'}})
|
||||
commands=[call.args for call in android.call_args_list]
|
||||
self.assertIn(('ip','route','add','default','via','192.0.2.1','dev','wlan0','table','21052'),commands)
|
||||
self.assertEqual([c for c in commands if c[:3]==('ip','route','flush')],
|
||||
[('ip','route','flush','table','21052')])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -22,7 +22,9 @@ class Buttons(unittest.TestCase):
|
|||
|
||||
def test_vibration_pattern_progresses_without_blocking_and_can_be_cancelled(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(b,'VIBRATOR',Path(d)),patch.object(b.time,'monotonic',return_value=10):
|
||||
(Path(d)/'trigger').write_text('[transient]')
|
||||
v=b.Vibration();v.start([200,100,300])
|
||||
self.assertEqual((Path(d)/'trigger').read_text(),'none')
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'255')
|
||||
self.assertEqual(v.deadline,10.2)
|
||||
v.advance(10.1);self.assertEqual((Path(d)/'brightness').read_text(),'255')
|
||||
|
|
@ -32,3 +34,18 @@ class Buttons(unittest.TestCase):
|
|||
v.start([200]);v.start([]);self.assertIsNone(v.deadline);self.assertEqual((Path(d)/'brightness').read_text(),'0')
|
||||
for pattern in [None,[-1],[True],[10001],[5000,5001],[1]*33]:
|
||||
with self.assertRaises(ValueError):v.start(pattern)
|
||||
|
||||
def test_wake_recovery_waits_for_gaia_and_recovers_if_unresponsive(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(b,'BACKLIGHT',Path(d)):
|
||||
(Path(d)/'max_brightness').write_text('1000')
|
||||
(Path(d)/'brightness').write_text('0')
|
||||
recovery=b.WakeRecovery()
|
||||
with patch.object(b,'wake_recovery',recovery):
|
||||
recovery.arm(10);recovery.advance(11)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'0')
|
||||
b.control({'operation':'screen','enabled':True,'level':.3})
|
||||
recovery.advance(12)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'300')
|
||||
b.control({'operation':'screen','enabled':False,'level':.3})
|
||||
recovery.arm(20);recovery.advance(21.6)
|
||||
self.assertEqual((Path(d)/'brightness').read_text(),'500')
|
||||
|
|
|
|||
31
tundra/tests/test_clock_state.py
Normal file
31
tundra/tests/test_clock_state.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
spec=importlib.util.spec_from_file_location('clock',Path(__file__).parents[1]/'runtime/clock-state.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
|
||||
class ClockState(unittest.TestCase):
|
||||
def test_preserves_time_plus_powered_off_elapsed_without_setting_rtc(self):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(m,'STATE',Path(d)/'time.json'), patch.object(m,'RTC',Path(d)/'rtc'):
|
||||
m.RTC.write_text('1000')
|
||||
with patch.object(m.time,'time',return_value=1792340000): m.save()
|
||||
m.RTC.write_text('1600')
|
||||
with patch.object(m.time,'time',return_value=1792000000), patch.object(m.time,'clock_settime') as write:
|
||||
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340600)
|
||||
self.assertEqual(m.RTC.read_text(),'1600')
|
||||
self.assertEqual(m.STATE.stat().st_mode & 0o777,0o600)
|
||||
def test_never_rewinds_valid_wall_time(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=None):
|
||||
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
|
||||
with patch.object(m.time,'time',return_value=1792350000),patch.object(m.time,'clock_settime') as write:
|
||||
self.assertFalse(m.restore());write.assert_not_called()
|
||||
def test_corrupt_state_or_reset_rtc_does_not_invent_elapsed_time(self):
|
||||
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=10),patch.object(m.time,'time',return_value=100):
|
||||
m.STATE.write_text('invalid')
|
||||
self.assertFalse(m.restore())
|
||||
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
|
||||
with patch.object(m.time,'clock_settime') as write:
|
||||
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340000)
|
||||
|
|
@ -5,6 +5,8 @@ from unittest.mock import patch
|
|||
spec=importlib.util.spec_from_file_location('mobile',Path(__file__).parents[1]/'runtime/mobile-settings.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
class NetworkClock(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.checkpoint=patch.object(m,'checkpoint_clock');self.checkpoint.start();self.addCleanup(self.checkpoint.stop)
|
||||
def test_monotonic_age_is_added_to_nitz(self):
|
||||
payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':530}}]}
|
||||
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
|
||||
|
|
|
|||
|
|
@ -129,6 +129,18 @@ on boot
|
|||
'mount ext4 /dev/mmcblk0p72 /data','mount tmpfs /dev/block/x /data'):
|
||||
self.assertFalse(policy.command_allowed(line.split()),line)
|
||||
|
||||
def test_network_config_on_empty_overlay_creates_firewall_aliases(self):
|
||||
import tempfile
|
||||
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
|
||||
native = importlib.util.module_from_spec(spec); spec.loader.exec_module(native)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root/'opt/vulpes/trial').mkdir(parents=True)
|
||||
(root/'opt/vulpes/trial/session.py').touch()
|
||||
native.prepare(root, network=True, hardware=True)
|
||||
for name in ('iptables', 'ip6tables'):
|
||||
self.assertEqual((root/'usr/sbin'/name).readlink(), Path(name+'-legacy'))
|
||||
|
||||
def test_interactive_boot_checks_are_opt_in(self):
|
||||
import tempfile
|
||||
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue