Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
156
tundra/runtime/buttons.py
Normal file
156
tundra/runtime/buttons.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Sargo key events and fixed backlight/volume controls; no general command API."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
SOCKET = Path('/run/tundra-buttons/control.sock')
|
||||
EVENTS = SOCKET.with_name('events.json')
|
||||
BACKLIGHT = Path('/sys/class/backlight/panel0-backlight')
|
||||
KEYS = {116: 'sleep', 115: 'volume-up', 114: 'volume-down'}
|
||||
INPUT = struct.Struct('@llHHi')
|
||||
|
||||
def key_event(kind, code, value):
|
||||
if kind != 1 or code not in KEYS or value not in (0, 1):
|
||||
return None
|
||||
return KEYS[code] + '-button-' + ('press' if value else 'release')
|
||||
|
||||
VIBRATOR = Path('/sys/class/leds/vibrator')
|
||||
|
||||
# This kernel routes DRV2624 start/stop through LED brightness. The legacy
|
||||
# activate/duration attributes do not reliably stop the physical motor.
|
||||
class Vibration:
|
||||
def __init__(self):
|
||||
self.steps = []
|
||||
self.deadline = None
|
||||
|
||||
def start(self, 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_VIBRATION')
|
||||
self.steps = list(enumerate(pattern))
|
||||
self.deadline = None
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
self.advance(time.monotonic())
|
||||
|
||||
def advance(self, now):
|
||||
if self.deadline is not None and now < self.deadline:
|
||||
return
|
||||
self.deadline = None
|
||||
while self.steps:
|
||||
index, duration = self.steps.pop(0)
|
||||
if not duration:
|
||||
continue
|
||||
if index % 2 == 0:
|
||||
(VIBRATOR/'brightness').write_text('255')
|
||||
else:
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
self.deadline = now + duration / 1000
|
||||
return
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
|
||||
vibration = Vibration()
|
||||
|
||||
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')
|
||||
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':
|
||||
vibration.start(p.get('pattern'))
|
||||
elif p.get('operation') == 'volume':
|
||||
level = p.get('level')
|
||||
if type(level) not in (int, float) or not 0 <= level <= 1:
|
||||
raise ValueError('INVALID_VOLUME')
|
||||
env = {**os.environ, 'XDG_RUNTIME_DIR': '/run/user/32011',
|
||||
'PULSE_SERVER': 'unix:/run/user/32011/pulse/native'}
|
||||
subprocess.run(['/usr/bin/pactl',
|
||||
'set-sink-volume', '@DEFAULT_SINK@', str(round(level*100))+'%'],
|
||||
env=env, user=32011, group=32011, extra_groups=[], check=True, timeout=3, capture_output=True)
|
||||
elif p.get('operation') == 'power' and p.get('action') in ('reboot', 'poweroff'):
|
||||
subprocess.run(['/usr/bin/systemctl', '--no-block', p['action']], check=True, timeout=3, capture_output=True)
|
||||
else:
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
return True
|
||||
|
||||
def serve():
|
||||
SOCKET.parent.mkdir(parents=True, exist_ok=True)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
state = {'session': str(uuid.uuid4()), 'sequence': 0, 'events': []}
|
||||
def publish():
|
||||
state['recordedAt'] = time.time()
|
||||
temporary = EVENTS.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(state))
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(EVENTS)
|
||||
publish()
|
||||
with selectors.DefaultSelector() as selector, socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET)); SOCKET.chmod(0o660)
|
||||
import grp
|
||||
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
|
||||
server.listen(8); selector.register(server, selectors.EVENT_READ)
|
||||
for device in Path('/sys/class/input').glob('event*'):
|
||||
if (device/'device/name').read_text().strip() not in ('qpnp_pon', 'gpio-keys'):
|
||||
continue
|
||||
fd = os.open('/dev/input/'+device.name, os.O_RDONLY | os.O_NONBLOCK)
|
||||
selector.register(fd, selectors.EVENT_READ)
|
||||
while True:
|
||||
timeout = max(0, min(1, vibration.deadline - time.monotonic())) if vibration.deadline is not None else 1
|
||||
for key, _ in selector.select(timeout):
|
||||
if key.fileobj is server:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
conn.settimeout(2)
|
||||
try:
|
||||
raw = conn.makefile('rb').readline(1024)
|
||||
result = control(json.loads(raw))
|
||||
reply = {'ok': True, 'result': result}
|
||||
except Exception:
|
||||
reply = {'ok': False, 'error': 'HARDWARE_REQUEST_FAILED'}
|
||||
conn.sendall((json.dumps(reply)+'\n').encode())
|
||||
else:
|
||||
data = os.read(key.fd, INPUT.size*64)
|
||||
for offset in range(0, len(data), INPUT.size):
|
||||
_, _, 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.
|
||||
if code == 116 and value == 1 and int((BACKLIGHT/'brightness').read_text()) == 0:
|
||||
control({'operation':'screen', 'enabled':True, 'level':0.5})
|
||||
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())
|
||||
publish()
|
||||
|
||||
def client():
|
||||
raw = sys.stdin.read(1024)
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(5); sock.connect(str(SOCKET))
|
||||
sock.sendall(raw.encode()+b'\n')
|
||||
print(sock.makefile('r').readline(2048).strip())
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--stop-vibration' in sys.argv:
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
elif '--client' in sys.argv:
|
||||
client()
|
||||
else:
|
||||
try:
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
serve()
|
||||
finally:
|
||||
(VIBRATOR/'brightness').write_text('0')
|
||||
216
tundra/runtime/call-audio.py
Normal file
216
tundra/runtime/call-audio.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Route modem calls through the Droid audio HAL; never dial or record audio.
|
||||
|
||||
Profile/parking sequence: mer-hybris/pulseaudio-modules-droid README.
|
||||
Only the physical earpiece is selected automatically, never the loudspeaker.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def names(items):
|
||||
return set(items) if isinstance(items, dict) else {p['name'] for p in items}
|
||||
|
||||
|
||||
def parse_devices(text):
|
||||
"""Read pactl's C-locale listing, including PulseAudio versions before JSON."""
|
||||
devices, current, section = [], None, None
|
||||
scalar = {'Name':'name', 'Owner Module':'owner_module', 'Active Profile':'active_profile',
|
||||
'Active Port':'active_port', 'Mute':'mute'}
|
||||
for line in text.splitlines():
|
||||
header = re.fullmatch(r'(?:Card|Sink|Source|Sink Input) #(\d+)', line)
|
||||
if header:
|
||||
current = {'index':int(header[1]), 'ports':{}, 'profiles':{}, 'properties':{}}
|
||||
devices.append(current); section = None
|
||||
elif current is not None and line.startswith('\t') and not line.startswith('\t\t'):
|
||||
key, separator, value = line.strip().partition(':')
|
||||
section = key.lower() if key in ('Ports', 'Profiles', 'Properties') else None
|
||||
if key in scalar and separator:
|
||||
value = value.strip()
|
||||
if key == 'Owner Module': value = int(value)
|
||||
elif key == 'Mute': value = value == 'yes'
|
||||
current[scalar[key]] = value
|
||||
elif current is not None and line.startswith('\t\t') and not line.startswith('\t\t\t'):
|
||||
value = line.strip()
|
||||
if section in ('ports', 'profiles') and ':' in value:
|
||||
current[section][value.split(':', 1)[0]] = {}
|
||||
elif section == 'properties' and ' = ' in value:
|
||||
key, value = value.split(' = ', 1)
|
||||
current['properties'][key] = value.strip('"')
|
||||
return devices
|
||||
|
||||
|
||||
def pactl(*args):
|
||||
result = subprocess.run(
|
||||
['/usr/bin/pactl', *args], capture_output=True, text=True, timeout=5,
|
||||
user=32011, group=32011, extra_groups=[],
|
||||
env={**os.environ, 'XDG_RUNTIME_DIR':'/run/user/32011',
|
||||
'PULSE_SERVER':'unix:/run/user/32011/pulse/native', 'LC_ALL':'C'})
|
||||
if result.returncode:
|
||||
raise RuntimeError('CALL_AUDIO_COMMAND_FAILED')
|
||||
return result.stdout
|
||||
|
||||
|
||||
class CallAudio:
|
||||
def __init__(self, command=pactl):
|
||||
self.command = command
|
||||
self.saved = None
|
||||
self.active = False
|
||||
self.speaker = False
|
||||
self.muted = False
|
||||
self.volume = 0.6
|
||||
|
||||
def devices(self, kind):
|
||||
return parse_devices(self.command('list', kind))
|
||||
|
||||
def enter(self):
|
||||
if self.active:
|
||||
return
|
||||
if self.saved is not None:
|
||||
self.leave()
|
||||
card = next((c for c in self.devices('cards')
|
||||
if c['name'] == 'droid_card.primary' and 'voicecall' in names(c['profiles'])), None)
|
||||
if card is None:
|
||||
raise RuntimeError('CALL_AUDIO_CARD_UNAVAILABLE')
|
||||
sinks = self.devices('sinks')
|
||||
sink = next((s for s in sinks if s.get('owner_module') == card['owner_module'] and
|
||||
'output-earpiece' in names(s['ports']) and 'output-parking' in names(s['ports'])), None)
|
||||
source = next((s for s in self.devices('sources') if s['name'] == 'source.droid'), None)
|
||||
if sink is None or source is None:
|
||||
raise RuntimeError('CALL_AUDIO_EARPIECE_UNAVAILABLE')
|
||||
self.saved = {'card':card['name'], 'profile':card['active_profile'],
|
||||
'sink':sink['name'], 'port':sink['active_port'], 'sinkMute':sink['mute'],
|
||||
'source':source['name'], 'sourceMute':source['mute']}
|
||||
try:
|
||||
self.command('set-card-profile', card['name'], 'voicecall')
|
||||
self.command('set-sink-port', sink['name'], 'output-parking')
|
||||
self.command('set-sink-port', sink['name'], 'output-earpiece')
|
||||
self.command('set-sink-mute', sink['name'], '0')
|
||||
self.command('set-source-mute', source['name'], '0')
|
||||
# voice_virtual_stream creates this HAL volume-control stream.
|
||||
for stream in self.devices('sink-inputs'):
|
||||
if stream.get('properties', {}).get('media.role') == 'phone':
|
||||
self.command('set-sink-input-volume', str(stream['index']), str(round(self.volume * 100)) + '%')
|
||||
self.active = True
|
||||
except Exception:
|
||||
self.leave()
|
||||
raise
|
||||
|
||||
def control(self, request):
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'speaker', 'muted', 'volume'}:
|
||||
raise ValueError('INVALID_AUDIO_REQUEST')
|
||||
if request.get('operation') == 'status':
|
||||
return {'active': self.active, 'speaker': self.speaker, 'muted': self.muted, 'volume': self.volume}
|
||||
if request.get('operation') != 'set' or not self.active:
|
||||
raise ValueError('CALL_AUDIO_NOT_ACTIVE')
|
||||
for key in ('speaker', 'muted'):
|
||||
if key in request and type(request[key]) is not bool:
|
||||
raise ValueError('INVALID_AUDIO_REQUEST')
|
||||
if 'volume' in request and (type(request['volume']) not in (int, float) or not 0 <= request['volume'] <= 1):
|
||||
raise ValueError('INVALID_AUDIO_REQUEST')
|
||||
if 'speaker' in request:
|
||||
port = 'output-speaker' if request['speaker'] else 'output-earpiece'
|
||||
sink = next((s for s in self.devices('sinks') if s['name'] == self.saved['sink']), None)
|
||||
if not sink or port not in names(sink['ports']):
|
||||
raise RuntimeError('CALL_AUDIO_PORT_UNAVAILABLE')
|
||||
self.command('set-sink-port', sink['name'], 'output-parking')
|
||||
self.command('set-sink-port', sink['name'], port)
|
||||
self.speaker = request['speaker']
|
||||
if 'muted' in request:
|
||||
self.command('set-source-mute', self.saved['source'], str(int(request['muted'])))
|
||||
self.muted = request['muted']
|
||||
if 'volume' in request:
|
||||
streams = [s for s in self.devices('sink-inputs') if s.get('properties', {}).get('media.role') == 'phone']
|
||||
if not streams:
|
||||
raise RuntimeError('CALL_AUDIO_VOLUME_UNAVAILABLE')
|
||||
for stream in streams:
|
||||
self.command('set-sink-input-volume', str(stream['index']), str(round(request['volume'] * 100)) + '%')
|
||||
self.volume = request['volume']
|
||||
return self.control({'operation': 'status'})
|
||||
|
||||
def leave(self):
|
||||
if self.saved is None:
|
||||
return
|
||||
self.active = False
|
||||
state = self.saved
|
||||
self.command('set-card-profile', state['card'], state['profile'])
|
||||
self.command('set-sink-port', state['sink'], 'output-parking')
|
||||
self.command('set-sink-port', state['sink'], state['port'])
|
||||
self.command('set-sink-mute', state['sink'], str(int(state['sinkMute'])))
|
||||
self.command('set-source-mute', state['source'], str(int(state['sourceMute'])))
|
||||
self.saved = None
|
||||
self.speaker = False
|
||||
self.muted = False
|
||||
|
||||
def update(self, calls):
|
||||
if any(c['state'] in ('dialing', 'alerting', 'active', 'held') for c in calls):
|
||||
self.enter()
|
||||
else:
|
||||
self.leave()
|
||||
|
||||
|
||||
def main():
|
||||
spec = importlib.util.spec_from_file_location('radio', Path(__file__).with_name('radio-control.py'))
|
||||
radio = importlib.util.module_from_spec(spec); spec.loader.exec_module(radio)
|
||||
route = CallAudio()
|
||||
target = Path('/run/tundra-call-audio/status.json')
|
||||
path = target.with_name('control.sock')
|
||||
path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(path))
|
||||
os.chown(path, 0, 32011)
|
||||
path.chmod(0o660)
|
||||
server.listen(4)
|
||||
server.settimeout(0.3)
|
||||
next_poll = 0
|
||||
while True:
|
||||
if time.monotonic() >= next_poll:
|
||||
try:
|
||||
route.update(radio.request({'version':1, 'operation':'calls'}))
|
||||
state = {**route.control({'operation':'status'}), 'error':None}
|
||||
except Exception as error:
|
||||
state = {'active':route.active, 'error':str(error)}
|
||||
state.update(routed=route.active, recordedAt=time.time())
|
||||
tmp = target.with_suffix('.tmp'); tmp.write_text(json.dumps(state)); tmp.replace(target)
|
||||
next_poll = time.monotonic() + 1
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with conn:
|
||||
conn.settimeout(2)
|
||||
try:
|
||||
_, uid, _ = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if uid not in (0, 32011):
|
||||
raise ValueError('PERMISSION_DENIED')
|
||||
line = conn.makefile('rb').readline(1024)
|
||||
if not line.endswith(b'\n'):
|
||||
raise ValueError('INVALID_AUDIO_REQUEST')
|
||||
result = {'result':route.control(json.loads(line))}
|
||||
except Exception as error:
|
||||
result = {'error':str(error)}
|
||||
try:
|
||||
conn.sendall((json.dumps(result)+'\n').encode())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def client():
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(8)
|
||||
sock.connect('/run/tundra-call-audio/control.sock')
|
||||
sock.sendall(sys.stdin.buffer.read(1024).rstrip(b'\n') + b'\n')
|
||||
print(sock.makefile('r').readline(4096))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
client() if '--client' in sys.argv else main()
|
||||
269
tundra/runtime/camera-preview.py
Normal file
269
tundra/runtime/camera-preview.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Bounded libhybris preview worker. Frames stay in memory; no microphone access.
|
||||
|
||||
The parent owns stdin. EOF or five seconds without a request closes the sensor.
|
||||
A pbuffer and external texture are required even for the software NV21 callback.
|
||||
"""
|
||||
import argparse
|
||||
import struct
|
||||
import ctypes as C
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def function(lib, name, args, result=None):
|
||||
value = getattr(lib, name)
|
||||
value.argtypes, value.restype = args, result
|
||||
return value
|
||||
|
||||
|
||||
class Listener(C.Structure):
|
||||
_fields_ = [(name, C.c_void_p) for name in
|
||||
('error', 'shutter', 'focus', 'zoom', 'raw', 'jpeg', 'texture', 'context', 'preview')]
|
||||
|
||||
|
||||
class Camera:
|
||||
width, height = 640, 480
|
||||
|
||||
def __init__(self, index):
|
||||
self.camera = None
|
||||
self.display = self.surface = self.context = None
|
||||
self.frame = None
|
||||
self.jpeg = None
|
||||
self.focus_done = False
|
||||
self.count = self.errors = 0
|
||||
self.texture_pending = False
|
||||
os.environ['HYBRIS_EGLPLATFORM'] = 'null'
|
||||
os.environ['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/10_libhybris.json'
|
||||
self.egl = C.CDLL('libEGL.so.1')
|
||||
self.gl = C.CDLL('libGLESv2.so.2')
|
||||
self.lib = C.CDLL('libcamera.so.1')
|
||||
P, I, U = C.c_void_p, C.c_int, C.c_uint
|
||||
self.display = function(self.egl, 'eglGetDisplay', [P], P)(None)
|
||||
if not function(self.egl, 'eglInitialize', [P, P, P], U)(self.display, None, None):
|
||||
raise RuntimeError('CAMERA_EGL_INIT_FAILED')
|
||||
attrs = (I * 13)(0x3033, 1, 0x3040, 4, 0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3021, 8, 0x3038)
|
||||
config, count = P(), I()
|
||||
function(self.egl, 'eglChooseConfig', [P, P, P, I, P], U)(self.display, attrs, C.byref(config), 1, C.byref(count))
|
||||
if not count.value:
|
||||
raise RuntimeError('CAMERA_EGL_CONFIG_FAILED')
|
||||
function(self.egl, 'eglBindAPI', [U], U)(0x30A0)
|
||||
self.context = function(self.egl, 'eglCreateContext', [P, P, P, P], P)(self.display, config, None, (I * 3)(0x3098, 2, 0x3038))
|
||||
self.surface = function(self.egl, 'eglCreatePbufferSurface', [P, P, P], P)(self.display, config, (I * 5)(0x3057, 16, 0x3056, 16, 0x3038))
|
||||
if not function(self.egl, 'eglMakeCurrent', [P, P, P, P], U)(self.display, self.surface, self.surface, self.context):
|
||||
raise RuntimeError('CAMERA_EGL_CONTEXT_FAILED')
|
||||
texture = U()
|
||||
function(self.gl, 'glGenTextures', [I, P])(1, C.byref(texture))
|
||||
|
||||
@C.CFUNCTYPE(None, P, U, P)
|
||||
def preview(data, size, _context):
|
||||
if size == self.width * self.height * 3 // 2:
|
||||
self.frame = C.string_at(data, size)
|
||||
self.count += 1
|
||||
|
||||
@C.CFUNCTYPE(None, P)
|
||||
def error(_context):
|
||||
self.errors += 1
|
||||
|
||||
@C.CFUNCTYPE(None, P)
|
||||
def available(_context):
|
||||
self.texture_pending = True
|
||||
|
||||
@C.CFUNCTYPE(None, P)
|
||||
def focused(_context):
|
||||
self.focus_done = True
|
||||
|
||||
@C.CFUNCTYPE(None, P, U, P)
|
||||
def jpeg(data, size, _context):
|
||||
if 0 < size <= 32 * 1024 * 1024:
|
||||
self.jpeg = C.string_at(data, size)
|
||||
|
||||
self.callbacks = (preview, error, available, focused, jpeg)
|
||||
self.listener = Listener()
|
||||
for name, callback in [('preview', preview), ('error', error), ('texture', available), ('focus', focused), ('jpeg', jpeg)]:
|
||||
setattr(self.listener, name, C.cast(callback, P).value)
|
||||
self.camera = function(self.lib, 'android_camera_connect_by_id', [I, P], P)(index, C.byref(self.listener))
|
||||
if not self.camera:
|
||||
raise RuntimeError('CAMERA_CONNECT_FAILED')
|
||||
facing, orientation = I(), I()
|
||||
function(self.lib, 'android_camera_get_device_info', [I, P, P], I)(index, C.byref(facing), C.byref(orientation))
|
||||
self.orientation = orientation.value
|
||||
function(self.lib, 'android_camera_set_preview_size', [P, I, I])(self.camera, self.width, self.height)
|
||||
function(self.lib, 'android_camera_set_preview_format', [P, I])(self.camera, 1) # YUV420SP / NV21
|
||||
function(self.lib, 'android_camera_set_preview_texture', [P, I])(self.camera, texture.value)
|
||||
function(self.lib, 'android_camera_set_preview_callback_mode', [P, I], I)(self.camera, 1)
|
||||
function(self.lib, 'android_camera_start_preview', [P])(self.camera)
|
||||
self.parameters = self.read_parameters()
|
||||
self.picture_sizes = [tuple(map(int, size.split('x'))) for size in self.parameters.get('picture-size-values', '').split(',') if 'x' in size]
|
||||
self.picture_size = max(self.picture_sizes, key=lambda size:size[0]*size[1])
|
||||
self.zoom_ratios = [int(n)/100 for n in self.parameters.get('zoom-ratios', '100').split(',')]
|
||||
|
||||
def read_parameters(self):
|
||||
# libhybris exposes the flattened parameters through a stdout dump.
|
||||
# Capture it in RAM so it cannot corrupt the worker's binary protocol.
|
||||
libc = C.CDLL(None)
|
||||
fd = os.memfd_create('camera-parameters')
|
||||
saved = os.dup(1)
|
||||
try:
|
||||
libc.fflush(None)
|
||||
os.dup2(fd, 1)
|
||||
function(self.lib, 'android_camera_dump_parameters', [C.c_void_p])(self.camera)
|
||||
libc.fflush(None)
|
||||
finally:
|
||||
os.dup2(saved, 1)
|
||||
os.close(saved)
|
||||
try:
|
||||
os.lseek(fd, 0, 0)
|
||||
raw = os.read(fd, 65536).decode()
|
||||
return dict(item.split('=', 1) for item in raw.strip().split(';') if '=' in item)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def metadata(self):
|
||||
return {'ready': True, 'width': self.width, 'height': self.height,
|
||||
'orientation': self.orientation, 'format': 'nv21',
|
||||
'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))),
|
||||
'zoomRatios': self.zoom_ratios}
|
||||
|
||||
def control(self, command):
|
||||
P, I = C.c_void_p, C.c_int
|
||||
action = command.get('action')
|
||||
if action == 'zoom':
|
||||
ratio = command.get('zoom')
|
||||
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:
|
||||
raise ValueError('CAMERA_INVALID_ZOOM')
|
||||
index = min(range(len(self.zoom_ratios)), key=lambda i:abs(self.zoom_ratios[i]-ratio))
|
||||
function(self.lib,'android_camera_set_zoom',[P,I])(self.camera,index)
|
||||
actual = int(self.read_parameters().get('zoom',index))
|
||||
if not 0 <= actual < len(self.zoom_ratios): raise RuntimeError('CAMERA_INVALID_ZOOM_REPLY')
|
||||
return {'zoom':self.zoom_ratios[actual]}
|
||||
if action == 'focus':
|
||||
if 'auto' not in self.metadata()['focusModes']:
|
||||
raise ValueError('CAMERA_FOCUS_UNAVAILABLE')
|
||||
function(self.lib,'android_camera_stop_autofocus',[P])(self.camera)
|
||||
function(self.lib,'android_camera_set_auto_focus_mode',[P,I])(self.camera,2)
|
||||
area = command.get('area')
|
||||
if area is not None:
|
||||
values = [area.get(k) for k in ('top','left','bottom','right')]
|
||||
if any(not isinstance(n,int) or not -1000 <= n <= 1000 for n in values) or values[0]>=values[2] or values[1]>=values[3]:
|
||||
raise ValueError('CAMERA_INVALID_FOCUS_AREA')
|
||||
if not self.metadata()['maxFocusAreas']:
|
||||
raise ValueError('CAMERA_FOCUS_AREA_UNAVAILABLE')
|
||||
region = (I*5)(*values,1000)
|
||||
function(self.lib,'android_camera_set_focus_region',[P,P])(self.camera,region)
|
||||
else:
|
||||
function(self.lib,'android_camera_reset_focus_region',[P])(self.camera)
|
||||
self.focus_done = False
|
||||
function(self.lib,'android_camera_start_autofocus',[P])(self.camera)
|
||||
deadline = time.monotonic()+4
|
||||
while not self.focus_done and time.monotonic()<deadline:
|
||||
self.tick(); time.sleep(.01)
|
||||
if not self.focus_done:
|
||||
raise RuntimeError('CAMERA_FOCUS_TIMEOUT')
|
||||
return {'completed':True}
|
||||
if action == 'picture':
|
||||
size = command.get('size') or dict(zip(('width','height'),self.picture_size))
|
||||
pair = (size.get('width'),size.get('height'))
|
||||
if pair not in self.picture_sizes:
|
||||
raise ValueError('CAMERA_INVALID_PICTURE_SIZE')
|
||||
function(self.lib,'android_camera_set_picture_size',[P,I,I])(self.camera,*pair)
|
||||
function(self.lib,'android_camera_set_jpeg_quality',[P,I])(self.camera,95)
|
||||
function(self.lib,'android_camera_set_rotation',[P,I])(self.camera,self.orientation)
|
||||
self.jpeg = None
|
||||
try:
|
||||
function(self.lib,'android_camera_take_snapshot',[P])(self.camera)
|
||||
deadline = time.monotonic()+12
|
||||
while self.jpeg is None and time.monotonic()<deadline:
|
||||
self.tick(); time.sleep(.01)
|
||||
if self.jpeg is None:
|
||||
raise RuntimeError('CAMERA_CAPTURE_TIMEOUT')
|
||||
return self.jpeg
|
||||
finally:
|
||||
function(self.lib,'android_camera_start_preview',[P])(self.camera)
|
||||
raise ValueError('CAMERA_INVALID_COMMAND')
|
||||
|
||||
def tick(self):
|
||||
if self.texture_pending:
|
||||
self.texture_pending = False
|
||||
function(self.lib, 'android_camera_update_preview_texture', [C.c_void_p])(self.camera)
|
||||
|
||||
def close(self):
|
||||
P = C.c_void_p
|
||||
if self.camera:
|
||||
function(self.lib, 'android_camera_stop_preview', [P])(self.camera)
|
||||
function(self.lib, 'android_camera_disconnect', [P])(self.camera)
|
||||
self.camera = None
|
||||
if self.display:
|
||||
function(self.egl, 'eglMakeCurrent', [P, P, P, P], C.c_uint)(self.display, None, None, None)
|
||||
if self.surface:
|
||||
function(self.egl, 'eglDestroySurface', [P, P], C.c_uint)(self.display, self.surface)
|
||||
if self.context:
|
||||
function(self.egl, 'eglDestroyContext', [P, P], C.c_uint)(self.display, self.context)
|
||||
function(self.egl, 'eglTerminate', [P], C.c_uint)(self.display)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
|
||||
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
|
||||
args = parser.parse_args()
|
||||
output = os.fdopen(os.dup(1), 'wb', buffering=0)
|
||||
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
|
||||
camera = None
|
||||
try:
|
||||
camera = Camera(args.camera)
|
||||
started = last_request = time.monotonic()
|
||||
if not args.probe:
|
||||
metadata = json.dumps(camera.metadata()).encode()
|
||||
output.write(struct.pack('<I', len(metadata)) + metadata)
|
||||
output.flush()
|
||||
while time.monotonic() - last_request < 5:
|
||||
camera.tick()
|
||||
if args.probe:
|
||||
time.sleep(.01)
|
||||
continue
|
||||
if not select.select([sys.stdin], [], [], .01)[0]:
|
||||
continue
|
||||
command = sys.stdin.readline(4096)
|
||||
if not command or len(command)>=4096:
|
||||
break
|
||||
last_request = time.monotonic()
|
||||
if command == 'frame\n':
|
||||
data = camera.frame or b''
|
||||
output.write(struct.pack('<II', camera.count & 0xffffffff, len(data)))
|
||||
output.write(data)
|
||||
else:
|
||||
try:
|
||||
result = camera.control(json.loads(command))
|
||||
if isinstance(result,bytes):
|
||||
data = json.dumps({'ok':True,'jpegLength':len(result)}).encode()
|
||||
else:
|
||||
data = json.dumps({'ok':True,'result':result}).encode()
|
||||
except (ValueError,RuntimeError,TypeError,AttributeError) as error:
|
||||
result = None
|
||||
data = json.dumps({'ok':False,'error':str(error)}).encode()
|
||||
output.write(struct.pack('<I',len(data))+data)
|
||||
if isinstance(result,bytes):
|
||||
output.write(result)
|
||||
output.flush()
|
||||
last_request = time.monotonic()
|
||||
if args.probe:
|
||||
output.write((json.dumps({'camera': args.camera, 'frames': camera.count, 'errors': camera.errors,
|
||||
'width': camera.width, 'height': camera.height, 'orientation': camera.orientation,
|
||||
'seconds': round(time.monotonic() - started, 2), 'imagesRetained': False})+'\n').encode())
|
||||
if not camera.count or camera.errors:
|
||||
return 1
|
||||
return 0
|
||||
finally:
|
||||
if camera:
|
||||
camera.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
98
tundra/runtime/hal_policy.py
Normal file
98
tundra/runtime/hal_policy.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Reduce Android init to display HAL services for the read-only Sargo trial.
|
||||
|
||||
Input files retain their upstream copyright notices. Removed commands and services
|
||||
are reported; this is not a general Android init parser or a production policy.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
SERVICES = frozenset(('ueventd','logd','servicemanager','hwservicemanager','vndservicemanager',
|
||||
'vendor.hwcomposer-2-2','vendor.qti.hardware.display.allocator','vendor.configstore-hal',
|
||||
'vendor.power-hal-1-3','vendor.thermal-engine','thermal-hal-1-1','hidl_memory'))
|
||||
COMMANDS = frozenset(('mkdir','chmod','chown','write','setprop','symlink','copy',
|
||||
'restorecon','restorecon_recursive','trigger','class_start','class_stop',
|
||||
'load_system_props','load_persist_props','export','setrlimit','hostname','domainname','loglevel','sysclktz','start','stop','restart'))
|
||||
PSEUDO_FS = frozenset(('cgroup','cgroup2','cpuset','tmpfs','proc','sysfs','debugfs','pstore','bpf'))
|
||||
|
||||
# The native network trial supplies rmt_storage with RAM copies of NV files.
|
||||
# It retains the blanket denial of block-device access for this container.
|
||||
NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage',
|
||||
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
|
||||
|
||||
|
||||
HARDWARE_SERVICES = frozenset(('vendor.qcrild', 'vendor.google.radioext@1.0',
|
||||
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
|
||||
'camera_service', 'minimedia', 'minisf', 'miniaf'))
|
||||
|
||||
def is_init_script(path):
|
||||
# ueventd uses a different grammar: these files define device permissions.
|
||||
return not path.name.startswith('ueventd')
|
||||
|
||||
|
||||
def command_allowed(tokens):
|
||||
if not tokens:
|
||||
return True
|
||||
op=tokens[0]
|
||||
if op=='onrestart':
|
||||
return command_allowed(tokens[1:])
|
||||
if op in ('start','stop','restart'):
|
||||
return len(tokens)==2 and tokens[1] in SERVICES
|
||||
if op=='mount':
|
||||
return len(tokens)>3 and tokens[1] in PSEUDO_FS and not tokens[2].startswith('/dev/')
|
||||
if op not in COMMANDS:
|
||||
return False
|
||||
if any('/dev/block' in t or '/dev/mmcblk' in t or '/sys/kernel/config' in t or '/sys/class/android_usb' in t or '/config/usb_gadget' in t or '/proc/sys/kernel/panic' in t or '/proc/sysrq-trigger' in t for t in tokens[1:]):
|
||||
return False
|
||||
if op=='setprop' and len(tokens)>2 and tokens[1].startswith('ctl.'):
|
||||
return tokens[1] in ('ctl.start','ctl.stop','ctl.restart') and tokens[2] in SERVICES
|
||||
return True
|
||||
|
||||
|
||||
def logical_lines(text):
|
||||
pending = ''
|
||||
first = 1
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if not pending: first = number
|
||||
pending += line
|
||||
if pending.endswith('\\'):
|
||||
pending = pending[:-1]+' '
|
||||
continue
|
||||
try:
|
||||
shlex.split(pending.strip(), comments=True)
|
||||
except ValueError:
|
||||
pending += '\n'
|
||||
continue
|
||||
yield first, pending
|
||||
pending = ''
|
||||
if pending:
|
||||
raise ValueError('Unterminated Android init command at line '+str(first))
|
||||
|
||||
|
||||
def filter_rc(text):
|
||||
output=[];removed=[];kept=[];section=None;keep=True
|
||||
for number,line in logical_lines(text):
|
||||
stripped=line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
if keep:output.append(line)
|
||||
continue
|
||||
tokens=shlex.split(stripped,comments=True)
|
||||
if not line[0].isspace():
|
||||
section=tokens[0];keep=True
|
||||
if section=='service':
|
||||
keep=len(tokens)>2 and tokens[1] in SERVICES and not any('_DISABLED' in t for t in tokens[2:])
|
||||
if keep:kept.append(tokens[1])
|
||||
elif section=='import':
|
||||
# USB, recovery, and Android app processes are outside this trial.
|
||||
keep=not any(word in stripped for word in ('usb','recovery','zygote'))
|
||||
elif section!='on':
|
||||
keep=False
|
||||
if keep:output.append(line)
|
||||
else:removed.append({'line':number,'text':line})
|
||||
continue
|
||||
accepted=keep and (section=='service' or command_allowed(tokens))
|
||||
# Keep failed trial services inspectable instead of rebooting the device.
|
||||
if section=='service' and tokens[0]=='critical':accepted=False
|
||||
if section=='service' and tokens[0]=='onrestart':accepted=keep and command_allowed(tokens)
|
||||
if accepted:output.append(line)
|
||||
else:removed.append({'line':number,'text':line})
|
||||
return '\n'.join(output)+'\n',{'services':kept,'removed':removed}
|
||||
64
tundra/runtime/hardware-status.py
Normal file
64
tundra/runtime/hardware-status.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Publish read-only hardware state. No SSIDs, IP addresses or modem identifiers."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
def battery_state(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', 'Discharging', 'Full', 'Not charging'):
|
||||
return None
|
||||
return {'level': level/100, 'charging': status in ('Charging', 'Full'), 'status': status}
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def wifi_state():
|
||||
try:
|
||||
result = subprocess.run(['nmcli', '-g', 'GENERAL.STATE', 'device', 'show', 'wlan0'],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
env={**os.environ, 'LC_ALL': 'C'})
|
||||
if result.returncode:
|
||||
return {'available': False, 'state': 'unavailable'}
|
||||
state = int(result.stdout.split()[0])
|
||||
names = {10:'unmanaged', 20:'unavailable', 30:'disconnected', 40:'connecting',
|
||||
50:'connecting', 60:'authentication', 70:'connecting', 80:'connecting',
|
||||
90:'connecting', 100:'connected', 110:'disconnecting', 120:'failed'}
|
||||
return {'available': state >= 30, 'state': names.get(state, 'unknown')}
|
||||
except (OSError, ValueError, IndexError, subprocess.TimeoutExpired):
|
||||
return {'available': False, 'state': 'unavailable'}
|
||||
|
||||
|
||||
def snapshot():
|
||||
# Do not D-Bus-activate NetworkManager from an otherwise display-only boot.
|
||||
wifi = wifi_state() if Path('/etc/tundra/network-enabled').is_file() else {
|
||||
'available': False, 'state': 'unavailable'}
|
||||
return {'schema': 1, 'recordedAt': time.time(), 'battery': battery_state(), 'wifi': wifi}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--once', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.once:
|
||||
print(json.dumps(snapshot()))
|
||||
return
|
||||
output = Path('/run/tundra-hardware/status.json')
|
||||
output.parent.mkdir(mode=0o755, exist_ok=True)
|
||||
while True:
|
||||
temporary = output.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(snapshot())+'\n')
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(output)
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
39
tundra/runtime/lxc-guard.py
Normal file
39
tundra/runtime/lxc-guard.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Restrict the legacy HAL to character devices before creating its container.
|
||||
|
||||
Sargo's 4.9 kernel rejects LXC's cgroup2 device BPF program. A separate v1
|
||||
devices controller is required; LXC's usual rules alone do not enforce it.
|
||||
"""
|
||||
import errno
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
|
||||
raise RuntimeError('Temporary boot flag missing')
|
||||
controller=Path('/run/tundra-device-controller')
|
||||
controller.mkdir(exist_ok=True)
|
||||
if not (controller/'devices.deny').exists():
|
||||
subprocess.run(['mount','-t','cgroup','-o','devices','devices',str(controller)],check=True)
|
||||
group=controller/'hal'
|
||||
group.mkdir(exist_ok=True)
|
||||
(group/'devices.deny').write_text('b *:* rwm')
|
||||
(group/'cgroup.procs').write_text(str(os.getpid()))
|
||||
# Test the actual open, not devices.list: this kernel lists a default allow
|
||||
# rule even when the blacklist is active. No bytes are written or read.
|
||||
try:
|
||||
descriptor=os.open('/dev/mmcblk0',os.O_RDONLY)
|
||||
except OSError as error:
|
||||
if error.errno!=errno.EPERM:raise
|
||||
else:
|
||||
os.close(descriptor)
|
||||
raise RuntimeError('Block device restriction was not enforced')
|
||||
with open('/dev/null','wb'):pass
|
||||
os.execv('/usr/bin/lxc-start',['lxc-start']+sys.argv[1:])
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
101
tundra/runtime/prepare-hardware.py
Normal file
101
tundra/runtime/prepare-hardware.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Configure isolated oFono/PulseAudio diagnostics; no call or SMS on startup."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def prepare(root, write):
|
||||
write('etc/tundra/hardware-enabled', 'sargo\n')
|
||||
with (root/'etc/group').open('a') as groups:
|
||||
groups.write('audio:x:1005:droidian\ncamera:x:1006:droidian\nradio:x:1001:\nmedia:x:1013:droidian\n')
|
||||
write('etc/ofono/binder.conf', '[Settings]\nExpectSlots = slot1\n\n[slot1]\npath = /ril_0\nslot = 0\n')
|
||||
# Only the diagnostic root process may operate the modem. Gaia needs its
|
||||
# separate, permission-checked broker before telephony can be exposed.
|
||||
write('etc/dbus-1/system.d/ofono.conf', '''<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig>
|
||||
<policy context="default"><deny send_destination="org.ofono"/></policy>
|
||||
<policy user="root"><allow own="org.ofono"/><allow send_destination="org.ofono"/></policy>
|
||||
</busconfig>
|
||||
''')
|
||||
write('etc/systemd/system/ofono.service', '''[Unit]
|
||||
Description=Tundra modem diagnostic service
|
||||
Requires=tundra-hal.service dbus.service
|
||||
After=tundra-hal.service dbus.service
|
||||
[Service]
|
||||
Type=dbus
|
||||
BusName=org.ofono
|
||||
ExecStartPre=/usr/bin/binder-wait android.hardware.radio@1.0::IRadio/slot1
|
||||
ExecStart=/usr/sbin/ofonod --nodetach
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
''')
|
||||
write('etc/systemd/system/tundra-radio.service', '''[Unit]
|
||||
Description=Tundra restricted oFono bridge
|
||||
Requires=dbus.service ofono.service
|
||||
After=dbus.service ofono.service
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/radio-control.py --server
|
||||
RuntimeDirectory=tundra-radio
|
||||
RuntimeDirectoryMode=0755
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
StateDirectory=tundra/sms
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
|
||||
''')
|
||||
write('etc/pulse/client.conf', 'autospawn = no\n')
|
||||
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
|
||||
write('etc/pulse/default.pa', '''.fail
|
||||
load-module module-native-protocol-unix
|
||||
load-module module-droid-card rate=48000 voice_virtual_stream=true
|
||||
load-module module-droid-hidl
|
||||
load-module module-default-device-restore
|
||||
load-module module-suspend-on-idle
|
||||
''')
|
||||
# Prefer AAL for the next camera integration tests. The GStreamer probe
|
||||
# requests libstagefright_ccodec.so, absent from this Android 9 image.
|
||||
# Selecting AAL alone does not prove that a preview reaches the sensor.
|
||||
write('usr/lib/droidian/device/droidian-camera.conf',
|
||||
'[General]\nblacklist=""\nbackend=aal\n')
|
||||
write('etc/systemd/system/tundra-audio.service', '''[Unit]
|
||||
Description=Tundra Sargo PulseAudio HAL bridge
|
||||
Requires=tundra-hal.service systemd-logind.service
|
||||
After=tundra-hal.service systemd-logind.service
|
||||
[Service]
|
||||
User=droidian
|
||||
PAMName=tundra
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/32011
|
||||
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
''')
|
||||
write('etc/systemd/system/tundra-call-audio.service', '''[Unit]
|
||||
Description=Tundra modem call audio routing
|
||||
Requires=ofono.service tundra-audio.service
|
||||
After=ofono.service tundra-audio.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
|
||||
RuntimeDirectory=tundra-call-audio
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run/tundra-call-audio
|
||||
''')
|
||||
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
write('etc/systemd/system/tundra-sms.service', '\n'.join([
|
||||
'[Unit]','Description=Persistent incoming SMS inbox','Requires=ofono.service',
|
||||
'After=ofono.service','[Service]',
|
||||
'ExecStart=/usr/bin/python3 /etc/tundra/sms-inbox.py',
|
||||
'UMask=0077','ProtectSystem=strict','ProtectHome=yes',
|
||||
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
|
||||
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
|
||||
(wants/(name+'.service')).symlink_to('../'+name+'.service')
|
||||
240
tundra/runtime/prepare-native.py
Normal file
240
tundra/runtime/prepare-native.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Generate native trial configuration inside an already staged runtime tree."""
|
||||
import argparse
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
|
||||
def prepare(root, network=None, hardware=None, diagnostics=False):
|
||||
if hardware is None:
|
||||
hardware = (root/'usr/sbin/ofonod').is_file()
|
||||
if network is None:
|
||||
network = (root/'usr/sbin/NetworkManager').is_file()
|
||||
if hardware and not network:
|
||||
raise ValueError('Hardware diagnostics require the isolated NV/network setup')
|
||||
if not (root/'opt/vulpes/trial/session.py').is_file():raise ValueError('Stage the common product first')
|
||||
def write(name,text,mode=0o644):
|
||||
p=root/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_text(text);p.chmod(mode)
|
||||
fonts = Path(__file__).resolve().parents[2]/'gaia/shared/elements/vulpes-fonts'
|
||||
destination = root/'usr/share/fonts/opentype/vulpes'
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted(fonts.glob('*.otf')):
|
||||
shutil.copyfile(source, destination/source.name)
|
||||
(destination/source.name).chmod(0o644)
|
||||
for name in ('LICENSE-OFL', 'NOTICE', 'provenance.json'):
|
||||
shutil.copyfile(fonts/name, destination/name)
|
||||
write('etc/fonts/conf.d/00-vulpes-sans.conf', '''<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<alias binding="strong"><family>sans-serif</family><prefer><family>Fira Sans</family></prefer></alias>
|
||||
<alias binding="strong"><family>FiraSans</family><prefer><family>Fira Sans</family></prefer></alias>
|
||||
</fontconfig>
|
||||
''')
|
||||
write('etc/systemd/system/tundra-display.service.d/fonts.conf', '''[Service]
|
||||
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')
|
||||
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')
|
||||
write('etc/pam.d/other','auth required pam_deny.so\naccount required pam_deny.so\npassword required pam_deny.so\nsession required pam_deny.so\n')
|
||||
write('etc/pam.d/common-account','account required pam_unix.so\n')
|
||||
write('etc/pam.d/common-session-noninteractive','session required pam_unix.so\n')
|
||||
write('etc/systemd/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=ignore\n')
|
||||
write('etc/systemd/journald.conf','[Journal]\nStorage=volatile\nRuntimeMaxUse=32M\n')
|
||||
write('etc/passwd','root:x:0:0:root:/root:/bin/bash\ndroidian:x:32011:32011:Vulpes trial:/home/droidian:/bin/bash\nsystem:x:1000:1000:Android HAL:/:/usr/sbin/nologin\n')
|
||||
write('etc/group','root:x:0:\ndroidian:x:32011:\nsystem:x:1000:droidian\ngraphics:x:1003:droidian\nandroid_input:x:1004:droidian\nvideo:x:44:droidian\nrender:x:105:droidian\ninput:x:104:droidian\nnetdev:x:988:\n')
|
||||
write('etc/shadow','root:!:20000:0:99999:7:::\ndroidian:!:20000:0:99999:7:::\nsystem:!:20000:0:99999:7:::\n',0o600)
|
||||
write('etc/tundra/phoc.ini','[output:HWCOMPOSER-1]\nscale=3\n')
|
||||
write('etc/udev/rules.d/90-tundra-display.rules','KERNEL=="kgsl*", GROUP="graphics", MODE="0660"\nKERNEL=="ion", GROUP="graphics", MODE="0660"\nKERNEL=="event*", GROUP="input", MODE="0660"\nKERNEL=="*binder", MODE="0666"\n')
|
||||
# The Android camera subdevices are not generic V4L capture devices. The
|
||||
# desktop v4l_id probe opens them before their HAL and crashes this kernel.
|
||||
(root/'etc/udev/rules.d/60-persistent-v4l.rules').symlink_to('/dev/null')
|
||||
write('etc/systemd/system/tundra.target','''[Unit]
|
||||
Description=Tundra temporary hardware trial
|
||||
Requires=basic.target tundra-display.service
|
||||
After=basic.target tundra-display.service
|
||||
AllowIsolate=yes
|
||||
''')
|
||||
# systemd does not consistently honor a --unit argument passed through
|
||||
# switch_root on the Android-derived initramfs. Make the trial target the
|
||||
# normal boot target as well, so the display starts without an SSH nudge.
|
||||
default_target = root/'etc/systemd/system/default.target'
|
||||
if default_target.exists() or default_target.is_symlink():
|
||||
default_target.unlink()
|
||||
default_target.symlink_to('tundra.target')
|
||||
write('etc/systemd/system/tundra-hal.service','''[Unit]
|
||||
Description=Curated Android display HAL
|
||||
After=systemd-udev-trigger.service
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/usr/bin/python3 /etc/tundra/setup-hal.py
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/lxc-guard.py -F -n tundra-hal -f /etc/tundra/lxc.conf -o /run/tundra/lxc.log -l DEBUG
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=15
|
||||
''')
|
||||
write('etc/systemd/system/tundra-display.service','''[Unit]
|
||||
Description=Gaia and Gecko on the Sargo display
|
||||
Requires=tundra-hal.service dbus.service systemd-logind.service
|
||||
After=tundra-hal.service dbus.service systemd-logind.service systemd-user-sessions.service
|
||||
Wants=systemd-user-sessions.service
|
||||
[Service]
|
||||
User=droidian
|
||||
PAMName=tundra
|
||||
WorkingDirectory=/home/droidian
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/32011
|
||||
Environment=XDG_SESSION_TYPE=wayland
|
||||
Environment=XDG_CURRENT_DESKTOP=Vulpes
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=VULPES_TUNDRA=1
|
||||
Environment=VULPES_TUNDRA_RADIO=1
|
||||
Environment=WLR_BACKENDS=hwcomposer,libinput
|
||||
Environment=EGL_PLATFORM=hwcomposer
|
||||
Environment=WLR_HWC_SKIP_VERSION_CHECK=1
|
||||
ExecStartPre=+/usr/bin/python3 /etc/tundra/wait-hal.py
|
||||
ExecStartPre=+chvt 7
|
||||
ExecStart=/usr/bin/dbus-run-session -- /usr/bin/phoc --no-xwayland --socket=tundra-0 -C /etc/tundra/phoc.ini -E "/usr/bin/python3 /opt/vulpes/trial/session.py --own-compositor --hybris-egl"
|
||||
TTYPath=/dev/tty7
|
||||
TTYReset=yes
|
||||
TTYVHangup=yes
|
||||
StandardInput=tty-fail
|
||||
StandardOutput=journal
|
||||
StandardError=inherit
|
||||
KillMode=control-group
|
||||
TimeoutStartSec=90
|
||||
TimeoutStopSec=15
|
||||
''')
|
||||
if diagnostics:
|
||||
display = root/'etc/systemd/system/tundra-display.service'
|
||||
display.write_text(display.read_text().replace('--own-compositor --hybris-egl',
|
||||
'--own-compositor --hybris-egl --diagnostics'))
|
||||
write('etc/tundra/wait-hal.py','''import subprocess,time
|
||||
for _ in range(120):
|
||||
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True)
|
||||
if p.stdout.strip()=='running':break
|
||||
time.sleep(.5)
|
||||
else:raise RuntimeError('Display HAL did not start')
|
||||
''')
|
||||
write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
|
||||
lxc.uts.name = tundra-hal
|
||||
lxc.net.0.type = empty
|
||||
lxc.arch = aarch64
|
||||
lxc.init.cmd = /init
|
||||
lxc.apparmor.profile = unconfined
|
||||
lxc.autodev = 0
|
||||
lxc.cap.drop = mac_admin mac_override sys_boot
|
||||
lxc.cgroup.devices.deny = b *:* rwm
|
||||
lxc.cgroup2.devices.deny = b *:* rwm
|
||||
lxc.cgroup2.memory.max = 536870912
|
||||
lxc.mount.entry = tmpfs dev tmpfs nosuid 0 0
|
||||
lxc.mount.entry = /dev/__properties__ dev/__properties__ none bind,create=dir 0 0
|
||||
lxc.mount.entry = /dev/socket dev/socket none bind,create=dir 0 0
|
||||
lxc.mount.entry = proc proc proc nodev,noexec,nosuid 0 0
|
||||
lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
|
||||
lxc.mount.entry = /android/data data none bind 0 0
|
||||
lxc.mount.entry = /mnt mnt none rbind 0 0
|
||||
''')
|
||||
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
|
||||
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
|
||||
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
|
||||
Description=Tundra read-only battery and network state
|
||||
After=systemd-udev-trigger.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/hardware-status.py
|
||||
RuntimeDirectory=tundra-hardware
|
||||
RuntimeDirectoryMode=0755
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=yes
|
||||
''')
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
wants.mkdir(exist_ok=True)
|
||||
(wants/'tundra-hardware.service').symlink_to('../tundra-hardware.service')
|
||||
write('etc/systemd/system/tundra-buttons.service', '''[Unit]
|
||||
Description=Sargo physical keys, backlight and audio volume
|
||||
After=systemd-udev-trigger.service
|
||||
[Service]
|
||||
ExecStartPre=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/buttons.py
|
||||
ExecStopPost=/usr/bin/python3 /etc/tundra/buttons.py --stop-vibration
|
||||
RuntimeDirectory=tundra-buttons
|
||||
RuntimeDirectoryMode=0755
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/run/tundra-buttons /sys/class/backlight -/sys/class/leds/vibrator
|
||||
NoNewPrivileges=yes
|
||||
''')
|
||||
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
|
||||
# Older immutable images may not contain the networking packages yet.
|
||||
if network:
|
||||
write('etc/tundra/network-enabled', 'sargo\n')
|
||||
display = root/'etc/systemd/system/tundra-display.service'
|
||||
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
|
||||
'Environment=VULPES_TUNDRA=1\nEnvironment=VULPES_TUNDRA_WIFI=1'))
|
||||
with (root/'etc/tundra/lxc.conf').open('a') as config:
|
||||
config.write('lxc.mount.entry = /run/tundra/nv-block dev/block none bind,create=dir 0 0\n')
|
||||
write('etc/NetworkManager/NetworkManager.conf', '''[main]
|
||||
plugins=keyfile
|
||||
dhcp=internal
|
||||
dns=default
|
||||
auth-polkit=root-only
|
||||
[keyfile]
|
||||
unmanaged-devices=*,except:interface-name:wlan0
|
||||
[device]
|
||||
wifi.scan-rand-mac-address=no
|
||||
[connection]
|
||||
wifi.cloned-mac-address=permanent
|
||||
[connectivity]
|
||||
enabled=false
|
||||
''')
|
||||
write('etc/systemd/system/tundra-network.service', '''[Unit]
|
||||
Description=Sargo Wi-Fi driver
|
||||
Requires=tundra-hal.service
|
||||
After=tundra-hal.service
|
||||
Before=NetworkManager.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/prepare-network.py
|
||||
TimeoutStartSec=180
|
||||
RemainAfterExit=yes
|
||||
''')
|
||||
write('etc/systemd/system/NetworkManager.service.d/tundra.conf', '''[Unit]
|
||||
Requires=tundra-network.service
|
||||
After=tundra-network.service
|
||||
''')
|
||||
# Wi-Fi profiles live in the private persistent root overlay.
|
||||
(wants/'NetworkManager.service').symlink_to('/usr/lib/systemd/system/NetworkManager.service')
|
||||
(root/'etc/systemd/system/dbus-fi.w1.wpa_supplicant1.service').symlink_to('/usr/lib/systemd/system/wpa_supplicant.service')
|
||||
(root/'etc/NetworkManager/system-connections').mkdir(parents=True, exist_ok=True)
|
||||
write('etc/systemd/system/tundra-wifi.service', '''[Unit]
|
||||
Description=Vulpes native Wi-Fi control
|
||||
Requires=NetworkManager.service
|
||||
After=NetworkManager.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/wifi-control.py
|
||||
Group=droidian
|
||||
RuntimeDirectory=tundra-wifi
|
||||
RuntimeDirectoryMode=0750
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/run /etc/NetworkManager/system-connections
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
''')
|
||||
(wants/'tundra-wifi.service').symlink_to('../tundra-wifi.service')
|
||||
if hardware:
|
||||
runpy.run_path(str(Path(__file__).with_name('prepare-hardware.py')))['prepare'](root, write)
|
||||
# Generate the initial machine identity locally. Persistent boots retain it.
|
||||
write('etc/machine-id', '')
|
||||
for directory in [root/'etc', *[p for p in (root/'etc').rglob('*') if p.is_dir()]]:
|
||||
directory.chmod(0o755)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--root',type=Path,required=True);a=p.parse_args();prepare(a.root.resolve())
|
||||
40
tundra/runtime/prepare-network.py
Normal file
40
tundra/runtime/prepare-network.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Activate the Sargo WLAN driver after the read-only vendor mount exists."""
|
||||
from pathlib import Path
|
||||
import errno
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
|
||||
raise RuntimeError('Temporary Tundra boot required')
|
||||
firmware = Path('/vendor/firmware')
|
||||
if not firmware.is_dir():
|
||||
raise RuntimeError('Vendor firmware not mounted')
|
||||
Path('/sys/module/firmware_class/parameters/path').write_text(str(firmware))
|
||||
boot = Path('/sys/kernel/boot_wlan/boot_wlan')
|
||||
if boot.exists():
|
||||
try:
|
||||
boot.write_text('1\n')
|
||||
except OSError as error:
|
||||
if error.errno != errno.EALREADY:
|
||||
raise
|
||||
# pd-mapper publishes the Qualcomm WLAN protection domain. Its firmware
|
||||
# can take a minute to start after the display HAL is ready.
|
||||
for _ in range(180):
|
||||
if Path('/sys/class/net/wlan0').exists():
|
||||
return
|
||||
time.sleep(.5)
|
||||
wlan = Path('/dev/wlan')
|
||||
if wlan.exists():
|
||||
wlan.write_text('ON\n')
|
||||
for _ in range(60):
|
||||
if Path('/sys/class/net/wlan0').exists():
|
||||
return
|
||||
time.sleep(.5)
|
||||
raise RuntimeError('Sargo WLAN driver did not expose wlan0')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
186
tundra/runtime/radio-control.py
Normal file
186
tundra/runtime/radio-control.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Small, allow-listed oFono broker used by the native Tundra session.
|
||||
|
||||
The Gaia process never receives access to the system D-Bus modem service. The
|
||||
root service accepts one JSON request per Unix-socket connection and invokes
|
||||
only fixed oFono methods with validated values. This keeps the diagnostic
|
||||
image useful while avoiding a general purpose command bridge.
|
||||
"""
|
||||
import json
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
SOCKET = Path('/run/tundra-radio/radio.sock')
|
||||
MODEM = '/ril_0'
|
||||
NUMBER = re.compile(r'^[0-9+#*(), .-]{3,32}$')
|
||||
VOICE = re.compile(r'^/ril_0/voicecall[0-9]+$')
|
||||
|
||||
|
||||
def bus(*args):
|
||||
"""Call one fixed system-bus method and return busctl's JSON data."""
|
||||
result = subprocess.run(
|
||||
['/usr/bin/busctl', '--system', '--json=short', 'call', *args],
|
||||
capture_output=True, text=True, timeout=15, check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
# Keep modem errors distinct from transport failures, without returning
|
||||
# command lines, phone numbers or subscriber identifiers to Gaia.
|
||||
detail = result.stderr.lower()
|
||||
if 'not registered' in detail or 'notregistered' in detail:
|
||||
raise RuntimeError('RADIO_NOT_REGISTERED')
|
||||
if 'in progress' in detail or 'inprogress' in detail or 'busy' in detail:
|
||||
raise RuntimeError('RADIO_BUSY')
|
||||
if 'not available' in detail or 'notavailable' in detail:
|
||||
raise RuntimeError('RADIO_NOT_READY')
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
# Successful void D-Bus methods (Hangup, Answer) produce no JSON in busctl.
|
||||
if not result.stdout.strip():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
return payload.get('data')
|
||||
except (ValueError, AttributeError):
|
||||
raise RuntimeError('INVALID_OFONO_REPLY')
|
||||
|
||||
|
||||
def request(value):
|
||||
if not isinstance(value, dict) or value.get('version') != 1:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
operation = value.get('operation')
|
||||
if operation in ('incomingMessages','ackMessages','messageState'):
|
||||
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
|
||||
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
|
||||
if operation == 'messageState':
|
||||
path = value.get('path', '')
|
||||
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-f_]+',path):
|
||||
raise ValueError('INVALID_SMS_PATH')
|
||||
state = inbox.outgoing_state(path)
|
||||
if state is None:
|
||||
try:
|
||||
props = bus('org.ofono',path,'org.ofono.Message','GetProperties')[0]
|
||||
state = props.get('State',{}).get('data','unknown')
|
||||
except RuntimeError: state = 'unknown'
|
||||
return {'state':state if state in ('pending','sent','failed') else 'unknown'}
|
||||
return inbox.pending() if operation == 'incomingMessages' else inbox.acknowledge(value.get('ids'))
|
||||
if operation == 'calls':
|
||||
data = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager', 'GetCalls')
|
||||
calls = []
|
||||
for path, properties in data[0]:
|
||||
if not VOICE.fullmatch(path):
|
||||
continue
|
||||
def field(name):
|
||||
item = properties.get(name, {})
|
||||
return item.get('data') if isinstance(item, dict) else item
|
||||
state = field('State')
|
||||
if state not in ('dialing', 'alerting', 'active', 'held', 'incoming', 'waiting', 'disconnected'):
|
||||
continue
|
||||
calls.append({'path': path, 'state': state,
|
||||
'number': str(field('LineIdentification') or '')[:80]})
|
||||
return calls
|
||||
if operation == 'status':
|
||||
return {
|
||||
'modem': bus('org.ofono', MODEM, 'org.ofono.Modem', 'GetProperties'),
|
||||
'registration': bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties'),
|
||||
}
|
||||
if operation == 'dial':
|
||||
number = str(value.get('number', '')).strip()
|
||||
if not NUMBER.fullmatch(number):
|
||||
raise ValueError('INVALID_NUMBER')
|
||||
path = bus('org.ofono', MODEM, 'org.ofono.VoiceCallManager',
|
||||
'Dial', 'ss', number, 'default')
|
||||
return {'path': path[0] if isinstance(path, list) and path else path,
|
||||
'startedAt': time.time()}
|
||||
if operation in ('hangup', 'answer'):
|
||||
path = value.get('path', '')
|
||||
if not isinstance(path, str) or not VOICE.fullmatch(path):
|
||||
raise ValueError('INVALID_CALL')
|
||||
bus('org.ofono', path, 'org.ofono.VoiceCall',
|
||||
'Hangup' if operation == 'hangup' else 'Answer')
|
||||
return {'path': path}
|
||||
if operation == 'sms':
|
||||
number = str(value.get('number', '')).strip()
|
||||
body = value.get('body', '')
|
||||
if not NUMBER.fullmatch(number) or not isinstance(body, str) or not body or len(body) > 1600:
|
||||
raise ValueError('INVALID_SMS')
|
||||
registration = bus('org.ofono', MODEM, 'org.ofono.NetworkRegistration', 'GetProperties')[0]
|
||||
if registration.get('Status',{}).get('data') not in ('registered','roaming'):
|
||||
raise RuntimeError('RADIO_NOT_REGISTERED')
|
||||
result = bus('org.ofono', MODEM, 'org.ofono.MessageManager',
|
||||
'SendMessage', 'ss', number, body)
|
||||
return {'path': result[0] if isinstance(result, list) and result else result,
|
||||
'state':'pending', 'number': number, 'bodyLength': len(body), 'submittedAt': time.time()}
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def reply(stream, value):
|
||||
stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
|
||||
stream.flush()
|
||||
|
||||
|
||||
def serve():
|
||||
SOCKET.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
try:
|
||||
SOCKET.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
||||
server.bind(str(SOCKET))
|
||||
os.chmod(SOCKET, 0o660)
|
||||
try:
|
||||
import grp
|
||||
os.chown(SOCKET, 0, grp.getgrnam('droidian').gr_gid)
|
||||
except (KeyError, OSError):
|
||||
pass
|
||||
server.listen(8)
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
conn.settimeout(20)
|
||||
line = b''
|
||||
while not line.endswith(b'\n') and len(line) < 8192:
|
||||
chunk = conn.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
line += chunk
|
||||
try:
|
||||
result = request(json.loads(line.decode()))
|
||||
reply(conn.makefile('wb'), {'ok': True, 'result': result})
|
||||
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
|
||||
|
||||
|
||||
def client(envelope=False):
|
||||
raw = sys.stdin.read(8192)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(20)
|
||||
sock.connect(str(SOCKET))
|
||||
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
|
||||
data = b''
|
||||
while not data.endswith(b'\n') and len(data) < 65536:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
result = json.loads(data.decode())
|
||||
if envelope:
|
||||
print(json.dumps(result, separators=(',', ':')))
|
||||
return
|
||||
if not result.get('ok'):
|
||||
raise RuntimeError(result.get('error', 'RADIO_ERROR'))
|
||||
print(json.dumps(result.get('result'), separators=(',', ':')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--server' in sys.argv:
|
||||
serve()
|
||||
elif '--client' in sys.argv or '--client-envelope' in sys.argv:
|
||||
client(envelope='--client-envelope' in sys.argv)
|
||||
else:
|
||||
raise SystemExit('use --server or --client')
|
||||
86
tundra/runtime/setup-hal.py
Normal file
86
tundra/runtime/setup-hal.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Mount the inventoried Sargo HAL read-only, with disposable init/data overlays."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import hal_policy
|
||||
from hal_policy import filter_rc, is_init_script
|
||||
|
||||
|
||||
def run(*args):subprocess.run(args,check=True)
|
||||
def directory(p):Path(p).mkdir(parents=True,exist_ok=True)
|
||||
def overlay(lower,target,label):
|
||||
upper='/run/tundra/'+label+'-upper';work='/run/tundra/'+label+'-work'
|
||||
for p in (upper,work,target):directory(p)
|
||||
run('mount','-t','overlay','overlay','-o',f'lowerdir={lower},upperdir={upper},workdir={work}',target)
|
||||
|
||||
|
||||
def main():
|
||||
if os.getuid()!=0:raise RuntimeError('HAL mounts require root')
|
||||
# This service only runs in the temporary boot assembled by rescue-init.
|
||||
if 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():raise RuntimeError('Temporary boot flag missing')
|
||||
directory('/var/lib/lxc')
|
||||
if Path('/run/tundra/hal-ready').is_file():return
|
||||
network = Path('/etc/tundra/network-enabled').is_file()
|
||||
if network:
|
||||
# Never let the experimental modem firmware write the installed NV.
|
||||
# rmt_storage opens these paths as files; no block device is exposed.
|
||||
destination = Path('/run/tundra/nv-block/bootdevice/by-name')
|
||||
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
partitions = {'modemst1': (46,2097152), 'modemst2': (47,2097152),
|
||||
'fsg': (12,2097152), 'fsc': (42,131072)}
|
||||
for name, (number, size) in partitions.items():
|
||||
device = 'mmcblk0p'+str(number)
|
||||
sectors = int(Path('/sys/class/block',device,'size').read_text())
|
||||
if sectors*512 != size:
|
||||
raise RuntimeError('Unexpected NV partition size: '+name)
|
||||
with open('/dev/'+device,'rb',buffering=0) as source:
|
||||
data = source.read(size)
|
||||
if len(data) != size:raise RuntimeError('Incomplete NV read: '+name)
|
||||
target = destination/name
|
||||
target.write_bytes(data)
|
||||
target.chmod(0o600)
|
||||
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.NETWORK_SERVICES
|
||||
if Path('/etc/tundra/hardware-enabled').is_file():
|
||||
hal_policy.SERVICES = hal_policy.SERVICES | hal_policy.HARDWARE_SERVICES
|
||||
for p in ('/run/tundra/android-original','/run/tundra/vendor-original','/dev/__properties__','/dev/socket','/mnt/vendor/persist'):
|
||||
directory(p)
|
||||
image=Path('/reference/android-rootfs.img')
|
||||
with image.open('rb') as f:sha=hashlib.file_digest(f,'sha256').hexdigest()
|
||||
if sha!='132d0e18c6a6a111293447e940267f35aa2b14fa00a872ef160b20dc2014d886':raise RuntimeError('Android reference changed')
|
||||
run('mount','-t','ext4','-o','loop,ro,noload',str(image),'/run/tundra/android-original')
|
||||
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p70','/run/tundra/vendor-original')
|
||||
run('mount','-t','ext4','-o','ro,noload','/dev/mmcblk0p48','/mnt/vendor/persist')
|
||||
overlay('/run/tundra/android-original','/android','android')
|
||||
overlay('/run/tundra/vendor-original','/android/vendor','vendor')
|
||||
if network:
|
||||
# IPA firmware is not initialized in the minimal HAL. Enabling its WLAN
|
||||
# offload dereferences a null ipahal context when the interface comes up.
|
||||
config = Path('/android/vendor/firmware/wlan/qca_cld/WCNSS_qcom_cfg.ini')
|
||||
text, count = re.subn(r'^gIPAConfig=.*$', 'gIPAConfig=0', config.read_text(), flags=re.M)
|
||||
if count != 1:raise RuntimeError('Expected exactly one Sargo WLAN IPA setting')
|
||||
config.write_text(text) # vendor overlay upper is tmpfs, never the partition
|
||||
directory('/android/vendor/firmware_mnt')
|
||||
run('mount','-t','vfat','-o','ro','/dev/mmcblk0p21','/android/vendor/firmware_mnt')
|
||||
report={}
|
||||
for p in sorted(Path('/android').rglob('*.rc')):
|
||||
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
|
||||
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
|
||||
p.write_text(filtered);report[str(p)]=record
|
||||
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
|
||||
# The container cannot open block devices. Its data and metadata live in RAM.
|
||||
for p in ('/android/data','/android/metadata'):
|
||||
run('mount','-t','tmpfs','-o','mode=0771','tmpfs',p)
|
||||
for p in ('/system','/vendor'):
|
||||
path=Path(p)
|
||||
if not path.is_symlink():
|
||||
if path.exists():path.rmdir()
|
||||
path.symlink_to('/android'+p)
|
||||
Path('/run/tundra/hal-ready').touch()
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
79
tundra/runtime/sms-inbox.py
Normal file
79
tundra/runtime/sms-inbox.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Keep oFono incoming messages until Gecko durably acknowledges them."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = Path('/var/lib/tundra/sms')
|
||||
|
||||
|
||||
def transaction(change, root=ROOT, filename="inbox.json"):
|
||||
if filename not in ("inbox.json", "outgoing.json"): raise ValueError("INVALID_SMS_STORE")
|
||||
root.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
with (root/'lock').open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
path = root/filename
|
||||
rows = json.loads(path.read_text()) if path.exists() else []
|
||||
result = change(rows)
|
||||
temporary = 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(rows,stream)
|
||||
stream.flush(); os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
return result
|
||||
|
||||
|
||||
def pending():
|
||||
return transaction(lambda rows: rows[:100])
|
||||
|
||||
|
||||
def acknowledge(ids):
|
||||
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)>40 for i in ids):
|
||||
raise ValueError('INVALID_SMS_IDS')
|
||||
def remove(rows):
|
||||
rows[:] = [row for row in rows if row['receipt'] not in ids]
|
||||
transaction(remove)
|
||||
return True
|
||||
|
||||
|
||||
def outgoing_state(path):
|
||||
rows = transaction(lambda rows: list(rows), filename='outgoing.json')
|
||||
return next((row['state'] for row in reversed(rows) if row['path'] == path), None)
|
||||
|
||||
|
||||
def record_outgoing(path, state):
|
||||
if state not in ('sent','failed'): return
|
||||
def update(rows):
|
||||
rows[:] = [row for row in rows if row['path'] != path][-999:]
|
||||
rows.append({'path':path,'state':state,'timestamp':round(time.time()*1000)})
|
||||
transaction(update, filename='outgoing.json')
|
||||
|
||||
|
||||
def main():
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
os.umask(0o077)
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SystemBus()
|
||||
def incoming(body, properties):
|
||||
row = {'receipt':str(uuid.uuid4()),'sender':str(properties.get('Sender','')),
|
||||
'body':str(body),'timestamp':round(time.time()*1000)}
|
||||
transaction(lambda rows:rows.append(row))
|
||||
bus.add_signal_receiver(incoming,signal_name='IncomingMessage',
|
||||
dbus_interface='org.ofono.MessageManager',bus_name='org.ofono',path='/ril_0')
|
||||
def outgoing(name, value, path=None):
|
||||
if name == 'State' and path and str(path).startswith('/ril_0/message'):
|
||||
record_outgoing(str(path), str(value))
|
||||
bus.add_signal_receiver(outgoing, signal_name='PropertyChanged',
|
||||
dbus_interface='org.ofono.Message', bus_name='org.ofono', path_keyword='path')
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
275
tundra/runtime/wifi-control.py
Normal file
275
tundra/runtime/wifi-control.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
SOCKET = '/run/tundra-wifi/control.sock'
|
||||
MAX_REQUEST = 16384
|
||||
DEVICE = 'wlan0'
|
||||
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
|
||||
|
||||
|
||||
class WifiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def fields(line):
|
||||
"""Decode nmcli's escaped colon-separated output, including SSIDs."""
|
||||
result, value, escaped = [], '', False
|
||||
for char in line:
|
||||
if escaped:
|
||||
value += char
|
||||
escaped = False
|
||||
elif char == '\\':
|
||||
escaped = True
|
||||
elif char == ':':
|
||||
result.append(value)
|
||||
value = ''
|
||||
else:
|
||||
value += char
|
||||
if escaped:
|
||||
value += '\\'
|
||||
return result + [value]
|
||||
|
||||
|
||||
def nm(*args, timeout=45):
|
||||
try:
|
||||
return subprocess.run(['/usr/bin/nmcli', '--wait', '30', *args],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
env={'PATH': '/usr/sbin:/usr/bin:/sbin:/bin', 'LC_ALL': 'C'})
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
raise WifiError('WIFI_UNAVAILABLE') from None
|
||||
|
||||
|
||||
def networks(rescan=False):
|
||||
result = nm('-t', '--escape', 'yes', '-f', 'DBUS-PATH,SSID,SIGNAL,SECURITY,IN-USE',
|
||||
'device', 'wifi', 'list', 'ifname', DEVICE,
|
||||
'--rescan', 'yes' if rescan else 'no')
|
||||
if result.returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
found = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = fields(line)
|
||||
if len(parts) != 5:
|
||||
continue
|
||||
ident, ssid, signal, security, active = parts
|
||||
if not re.fullmatch(r'/org/freedesktop/NetworkManager/AccessPoint/\d+', ident) or not ssid:
|
||||
continue
|
||||
if not signal.isdigit() or not 0 <= int(signal) <= 100:
|
||||
continue
|
||||
if not security or security == '--':
|
||||
mode = 'open'
|
||||
elif '802.1X' in security or 'EAP' in security:
|
||||
mode = 'unsupported'
|
||||
elif 'WPA3' in security and 'WPA2' not in security:
|
||||
mode = 'sae'
|
||||
elif 'WPA2' in security or 'WPA1' in security or security == 'WPA':
|
||||
mode = 'wpa-psk'
|
||||
else:
|
||||
mode = 'unsupported'
|
||||
found.append({'id': ident, 'ssid': ssid, 'signal': int(signal),
|
||||
'security': mode, 'connected': active == '*'})
|
||||
return sorted(found, key=lambda row: -row['signal'])[:128]
|
||||
|
||||
|
||||
def saved_profiles(profile_dir):
|
||||
result = []
|
||||
for path in profile_dir.glob('vulpes-*.nmconnection'):
|
||||
try:
|
||||
if path.is_symlink():
|
||||
continue
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
parser.read(path)
|
||||
uid = str(uuid.UUID(parser['connection']['uuid']))
|
||||
if path.name != 'vulpes-' + uid + '.nmconnection':
|
||||
continue
|
||||
ssid = bytes(int(b) for b in parser['wifi']['ssid'].split(';') if b).decode()
|
||||
mode = parser.get('wifi-security', 'key-mgmt', fallback='open')
|
||||
result.append({'id': uid, 'ssid': ssid, 'security': mode, 'known': True})
|
||||
except (OSError, ValueError, KeyError, configparser.Error):
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
def private_write(target, text):
|
||||
temporary = target.with_suffix('.tmp')
|
||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, 'w') as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
stream.write(text)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(target)
|
||||
|
||||
|
||||
def profile_text(network, password, uid):
|
||||
ssid = network['ssid']
|
||||
if not isinstance(ssid, str) or not 1 <= len(ssid.encode()) <= 32:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
mode = network['security']
|
||||
if mode not in ('open', 'wpa-psk', 'sae'):
|
||||
raise WifiError('WIFI_UNSUPPORTED_SECURITY')
|
||||
if not isinstance(password, str) or any(ord(c) < 32 for c in password):
|
||||
raise WifiError('WIFI_INVALID_PASSWORD')
|
||||
length = len(password.encode())
|
||||
if mode == 'wpa-psk' and not (8 <= length <= 63 or re.fullmatch(r'[0-9a-fA-F]{64}', password)):
|
||||
raise WifiError('WIFI_INVALID_PASSWORD')
|
||||
if mode == 'sae' and not 1 <= length <= 63:
|
||||
raise WifiError('WIFI_INVALID_PASSWORD')
|
||||
text = (f'[connection]\nid=Vulpes Wi-Fi\nuuid={uid}\ntype=wifi\n'
|
||||
'interface-name=wlan0\nautoconnect=true\n[wifi]\nmode=infrastructure\n'
|
||||
'cloned-mac-address=permanent\nssid=' +
|
||||
''.join(str(b) + ';' for b in ssid.encode()) +
|
||||
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
|
||||
if mode != 'open':
|
||||
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
|
||||
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
|
||||
return text
|
||||
|
||||
|
||||
def dispatch(request, profile_dir=PROFILE_DIR):
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
operation = request.get('operation')
|
||||
saved = saved_profiles(profile_dir)
|
||||
if operation == 'known':
|
||||
return {'networks': saved}
|
||||
if operation == 'forget':
|
||||
network = next((row for row in saved if row['id'] == request.get('id')), None)
|
||||
if network is None:
|
||||
raise WifiError('WIFI_NETWORK_GONE')
|
||||
uid = network['id']
|
||||
if nm('connection', 'delete', 'uuid', uid).returncode:
|
||||
raise WifiError('WIFI_FORGET_FAILED')
|
||||
(profile_dir / ('vulpes-' + uid + '.nmconnection')).unlink(missing_ok=True)
|
||||
return {'forgotten': True}
|
||||
if operation in ('status', 'scan', 'setEnabled'):
|
||||
if operation == 'setEnabled':
|
||||
enabled = request.get('enabled')
|
||||
if type(enabled) is not bool:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if nm('radio', 'wifi', 'on' if enabled else 'off').returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
radio = nm('radio', 'wifi')
|
||||
if radio.returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
enabled = radio.stdout.strip() == 'enabled'
|
||||
found = networks(rescan=True) if enabled and operation == 'scan' else []
|
||||
for network in found:
|
||||
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
|
||||
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
|
||||
return {'enabled': enabled, 'networks': found}
|
||||
if operation == 'disconnect':
|
||||
if nm('device', 'disconnect', DEVICE).returncode:
|
||||
raise WifiError('WIFI_DISCONNECT_FAILED')
|
||||
return {'disconnected': True}
|
||||
if operation != 'connect':
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
|
||||
network = next((row for row in networks() if row['id'] == request.get('id')), None)
|
||||
if network is None:
|
||||
raise WifiError('WIFI_NETWORK_GONE')
|
||||
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
|
||||
uid = stored['id'] if stored else str(uuid.uuid4())
|
||||
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
|
||||
previous = target.read_text() if stored else None
|
||||
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
|
||||
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
|
||||
connected = False
|
||||
try:
|
||||
private_write(target, text)
|
||||
if nm('connection', 'load', str(target)).returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
if nm('connection', 'up', 'uuid', uid).returncode:
|
||||
raise WifiError('WIFI_CONNECT_FAILED')
|
||||
connected = True
|
||||
return {'connected': True}
|
||||
finally:
|
||||
if not connected:
|
||||
# Never include nmcli stderr: it may contain the SSID or credentials.
|
||||
if previous is not None:
|
||||
private_write(target, previous)
|
||||
nm('connection', 'load', str(target))
|
||||
else:
|
||||
nm('connection', 'delete', 'uuid', uid)
|
||||
target.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def receive(stream, limit):
|
||||
data = bytearray()
|
||||
while b'\n' not in data:
|
||||
part = stream.recv(min(4096, limit + 1 - len(data)))
|
||||
if not part:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
data.extend(part)
|
||||
if len(data) > limit:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if data.count(b'\n') != 1 or not data.endswith(b'\n'):
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def handle(stream):
|
||||
stream.settimeout(5)
|
||||
try:
|
||||
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if uid not in (0, 32011):
|
||||
raise WifiError('PERMISSION_DENIED')
|
||||
result = {'result': dispatch(receive(stream, MAX_REQUEST))}
|
||||
except WifiError as error:
|
||||
result = {'error': str(error)}
|
||||
except Exception:
|
||||
result = {'error': 'WIFI_UNAVAILABLE'}
|
||||
try:
|
||||
stream.sendall(json.dumps(result).encode() + b'\n')
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--client', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.client:
|
||||
try:
|
||||
data = sys.stdin.buffer.read(MAX_REQUEST + 1)
|
||||
if len(data) > MAX_REQUEST:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
with socket.socket(socket.AF_UNIX) as stream:
|
||||
stream.settimeout(90)
|
||||
stream.connect(SOCKET)
|
||||
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
|
||||
result = receive(stream, 65536)
|
||||
except Exception:
|
||||
result = {'error': 'WIFI_UNAVAILABLE'}
|
||||
print(json.dumps(result))
|
||||
return
|
||||
if os.getuid() or 'tundra.rescue=1' not in Path('/proc/cmdline').read_text():
|
||||
raise SystemExit('Temporary native boot required')
|
||||
os.umask(0o077)
|
||||
path = Path(SOCKET)
|
||||
path.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(SOCKET)
|
||||
os.chown(SOCKET, 0, 32011)
|
||||
path.chmod(0o660)
|
||||
server.listen(4)
|
||||
while True:
|
||||
stream, _ = server.accept()
|
||||
with stream:
|
||||
handle(stream)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in a new issue