Release Vulpes OS 2.7-preview.24
This commit is contained in:
parent
49572b6b45
commit
97ef99f827
135 changed files with 7717 additions and 2524 deletions
72
tundra/runtime/beatles-camera-client.py
Normal file
72
tundra/runtime/beatles-camera-client.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/python3.11
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Narrow stdio-to-socket adapter for the Android CameraService broker."""
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
SOCKET = '/work/camera-ipc/camera.sock'
|
||||
|
||||
def read_exact(stream, size):
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = stream.recv(size - len(data))
|
||||
if not chunk:
|
||||
raise EOFError('Camera broker closed the session')
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
def forward(sock, target, size):
|
||||
header = read_exact(sock, size)
|
||||
target.write(header); target.flush()
|
||||
return header
|
||||
|
||||
def packet(sock, output):
|
||||
header = read_exact(sock, 4)
|
||||
size = int.from_bytes(header, 'little')
|
||||
if size > 65536:
|
||||
raise ValueError('Camera broker sent an oversized response')
|
||||
payload = read_exact(sock, size)
|
||||
output.write(header + payload); output.flush()
|
||||
return json.loads(payload.decode('utf-8'))
|
||||
|
||||
def main():
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
client.connect(SOCKET)
|
||||
output = os.fdopen(os.dup(1), 'wb', buffering=0)
|
||||
try:
|
||||
client.sendall(b'start\n')
|
||||
header = forward(client, output, 4)
|
||||
length = int.from_bytes(header, 'little')
|
||||
if not 0 < length <= 65536:
|
||||
return 2
|
||||
forward(client, output, length)
|
||||
while True:
|
||||
command = sys.stdin.buffer.readline(2049)
|
||||
if not command or len(command) > 2048:
|
||||
break
|
||||
client.sendall(command)
|
||||
if command.strip() == b'frame':
|
||||
header = forward(client, output, 8)
|
||||
length = int.from_bytes(header[4:], 'little')
|
||||
if length > 320 * 240 * 3 // 2:
|
||||
return 3
|
||||
if length:
|
||||
forward(client, output, length)
|
||||
elif command.strip() == b'stop':
|
||||
packet(client, output)
|
||||
break
|
||||
else:
|
||||
response = packet(client, output)
|
||||
jpeg = response.get('jpegLength', 0)
|
||||
if isinstance(jpeg, int) and 0 < jpeg <= 32 * 1024 * 1024:
|
||||
forward(client, output, jpeg)
|
||||
except (BrokenPipeError, ConnectionError, EOFError):
|
||||
return 0
|
||||
finally:
|
||||
client.close(); output.close()
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -86,6 +86,7 @@ def control(p):
|
|||
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))
|
||||
set_session_idle(not enabled)
|
||||
elif p.get('operation') == 'vibrate':
|
||||
vibration.start(p.get('pattern'))
|
||||
elif p.get('operation') == 'volume':
|
||||
|
|
@ -103,6 +104,29 @@ def control(p):
|
|||
raise ValueError('NOT_SUPPORTED')
|
||||
return True
|
||||
|
||||
def set_session_idle(idle):
|
||||
"""Tell logind that Gaia has switched the display between active and idle."""
|
||||
try:
|
||||
result = subprocess.run(['/usr/bin/loginctl', 'list-sessions', '--no-legend', '--no-pager'],
|
||||
capture_output=True, text=True, timeout=3, check=True)
|
||||
sessions = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == '32011' and fields[0].isalnum():
|
||||
sessions.append(fields[0])
|
||||
if not sessions:
|
||||
raise RuntimeError('No Tundra logind sessions found')
|
||||
value = 'true' if idle else 'false'
|
||||
for session in sessions:
|
||||
subprocess.run(['/usr/bin/busctl', '--system', 'call', 'org.freedesktop.login1',
|
||||
'/org/freedesktop/login1/session/'+session,
|
||||
'org.freedesktop.login1.Session', 'SetIdleHint', 'b', value],
|
||||
capture_output=True, text=True, timeout=3, check=True)
|
||||
return True
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
print('TUNDRA_LOGIND_IDLE_FAILED '+str(error), file=sys.stderr, flush=True)
|
||||
return False
|
||||
|
||||
def serve():
|
||||
SOCKET.parent.mkdir(parents=True, exist_ok=True)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ class Listener(C.Structure):
|
|||
|
||||
|
||||
class Camera:
|
||||
width, height = 640, 480
|
||||
|
||||
def __init__(self, index):
|
||||
def __init__(self, index, width=640, height=480):
|
||||
self.width, self.height = width, height
|
||||
self.camera = None
|
||||
self.display = self.surface = self.context = None
|
||||
self.frame = None
|
||||
|
|
@ -223,6 +222,8 @@ class Camera:
|
|||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
|
||||
parser.add_argument('--width', type=int, choices=(320, 640), default=640)
|
||||
parser.add_argument('--height', type=int, choices=(240, 480), default=480)
|
||||
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)
|
||||
|
|
@ -235,7 +236,9 @@ def main():
|
|||
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
|
||||
camera = None
|
||||
try:
|
||||
camera = Camera(args.camera)
|
||||
if (args.width, args.height) not in ((320,240),(640,480)):
|
||||
raise ValueError('CAMERA_UNSUPPORTED_PREVIEW_SIZE')
|
||||
camera = Camera(args.camera, args.width, args.height)
|
||||
started = last_request = time.monotonic()
|
||||
if not args.probe:
|
||||
metadata = json.dumps(camera.metadata()).encode()
|
||||
|
|
|
|||
32
tundra/runtime/enable-cpuidle.py
Normal file
32
tundra/runtime/enable-cpuidle.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Enable the Sargo Qualcomm low-power states when the kernel exposes them."""
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
|
||||
PARAMETER = Path('/sys/module/lpm_levels/parameters/sleep_disabled')
|
||||
|
||||
|
||||
def main():
|
||||
deadline = time.monotonic() + 8
|
||||
while time.monotonic() < deadline and not PARAMETER.exists():
|
||||
time.sleep(.1)
|
||||
if not PARAMETER.exists():
|
||||
print('Qualcomm lpm_levels parameter unavailable; leaving kernel defaults')
|
||||
return
|
||||
current = PARAMETER.read_text().strip()
|
||||
if current == 'N':
|
||||
print('Qualcomm low-power states are already enabled')
|
||||
return
|
||||
if current != 'Y':
|
||||
print('Unknown sleep_disabled value; leaving kernel setting unchanged:', current)
|
||||
return
|
||||
PARAMETER.write_text('N')
|
||||
if PARAMETER.read_text().strip() != 'N':
|
||||
raise RuntimeError('Could not enable Qualcomm CPU low-power states')
|
||||
print('Enabled Qualcomm CPU low-power states')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -15,7 +15,33 @@ def battery_state(root=Path('/sys/class/power_supply/battery')):
|
|||
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}
|
||||
charging = status in ('Charging', 'Full')
|
||||
state = {'level': level/100, 'charging': charging, 'status': status}
|
||||
# Linux power-supply values use matching micro-units, so their ratio
|
||||
# gives hours. Keep the estimate absent when the driver has no usable
|
||||
# current/energy telemetry; Gaia will show that honestly.
|
||||
try:
|
||||
energy = int((root/'energy_now').read_text().strip())
|
||||
power = abs(int((root/'power_now').read_text().strip()))
|
||||
if charging:
|
||||
energy = int((root/'energy_full').read_text().strip()) - energy
|
||||
if power > 0 and energy > 0:
|
||||
seconds = energy * 3600 / power
|
||||
if 0 < seconds < 10 * 24 * 3600:
|
||||
state['chargingTime' if charging else 'dischargingTime'] = seconds
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
charge = int((root/'charge_counter').read_text().strip())
|
||||
current = abs(int((root/'current_now').read_text().strip()))
|
||||
if charging:
|
||||
charge = int((root/'charge_full').read_text().strip()) - charge
|
||||
if current > 0 and charge > 0:
|
||||
seconds = charge * 3600 / current
|
||||
if 0 < seconds < 10 * 24 * 3600:
|
||||
state['chargingTime' if charging else 'dischargingTime'] = seconds
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return state
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,18 @@ Description=Checkpoint Tundra clock once per minute
|
|||
OnBootSec=60
|
||||
OnUnitActiveSec=60
|
||||
""")
|
||||
write('etc/systemd/system/tundra-cpuidle.service', '''[Unit]
|
||||
Description=Enable Qualcomm CPU low-power states for Tundra
|
||||
After=systemd-modules-load.service tundra-hal.service
|
||||
Before=tundra-display.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStartPre=/usr/bin/python3 /etc/tundra/wait-hal.py
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/enable-cpuidle.py
|
||||
RemainAfterExit=yes
|
||||
''')
|
||||
shutil.copy2(Path(__file__).with_name('enable-cpuidle.py'),
|
||||
root/'etc/tundra/enable-cpuidle.py')
|
||||
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'):
|
||||
|
|
@ -75,7 +87,7 @@ OnUnitActiveSec=60
|
|||
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/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=suspend\nIdleActionSec=30s\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')
|
||||
|
|
@ -111,8 +123,8 @@ 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
|
||||
Wants=tundra-cpuidle.service
|
||||
After=tundra-hal.service dbus.service systemd-logind.service tundra-cpuidle.service
|
||||
[Service]
|
||||
User=droidian
|
||||
PAMName=tundra
|
||||
|
|
@ -122,6 +134,7 @@ Environment=XDG_SESSION_TYPE=wayland
|
|||
Environment=XDG_CURRENT_DESKTOP=Vulpes
|
||||
Environment=LANG=C.UTF-8
|
||||
Environment=VULPES_TUNDRA=1
|
||||
Environment=VULPES_DEVICE=sargo
|
||||
Environment=VULPES_TUNDRA_RADIO=1
|
||||
Environment=WLR_BACKENDS=hwcomposer,libinput
|
||||
Environment=EGL_PLATFORM=hwcomposer
|
||||
|
|
@ -144,11 +157,15 @@ TimeoutStopSec=15
|
|||
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)
|
||||
deadline=time.monotonic()+8
|
||||
while time.monotonic()<deadline:
|
||||
try:
|
||||
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True,timeout=.5)
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
if p.stdout.strip()=='running':break
|
||||
time.sleep(.5)
|
||||
else:raise RuntimeError('Display HAL did not start')
|
||||
time.sleep(.25)
|
||||
else:raise RuntimeError('Display HAL did not start within 8 seconds')
|
||||
''')
|
||||
# Cellular HAL and oFono must see the same rmnet interfaces. Display-only
|
||||
# diagnostics retain a separate network namespace. No Android network manager runs.
|
||||
|
|
@ -161,7 +178,6 @@ 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
|
||||
|
|
@ -173,6 +189,8 @@ 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','mobile-settings.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
|
||||
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
|
||||
shutil.copy2(Path(__file__).resolve().parents[1]/'tools/inspect-hardware.py',
|
||||
root/'etc/tundra/inspect-hardware.py')
|
||||
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
|
||||
Description=Tundra read-only battery and network state
|
||||
After=systemd-udev-trigger.service
|
||||
|
|
|
|||
|
|
@ -175,6 +175,39 @@ def active_wifi_uuid():
|
|||
except ValueError:return None
|
||||
|
||||
|
||||
def device_state():
|
||||
"""Return NetworkManager's live link state, even when its AP vanished."""
|
||||
result = nm('-g', 'GENERAL.STATE', 'device', 'show', DEVICE, timeout=5)
|
||||
if result.returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
match = re.match(r'\s*(\d+)', result.stdout)
|
||||
if not match:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
code = int(match.group(1))
|
||||
state = {20: 'unavailable', 30: 'disconnected', 40: 'connecting',
|
||||
50: 'connecting', 60: 'connecting', 70: 'connecting',
|
||||
80: 'connecting', 90: 'connecting', 100: 'connected',
|
||||
110: 'disconnecting', 120: 'failed'}.get(code, 'unknown')
|
||||
return state
|
||||
|
||||
|
||||
def current_network(profile_dir, observed=()):
|
||||
"""Resolve the active profile to a remembered SSID if scan results omit it."""
|
||||
if device_state() != 'connected':
|
||||
return None
|
||||
uid = active_wifi_uuid()
|
||||
if not uid or uid == HOTSPOT_UUID:
|
||||
return None
|
||||
for network in observed:
|
||||
if network.get('connected'):
|
||||
return network
|
||||
saved = next((row for row in saved_profiles(profile_dir) if row['id'] == uid), None)
|
||||
if not saved:
|
||||
return None
|
||||
return {'ssid': saved['ssid'], 'security': saved['security'],
|
||||
'relSignalStrength': 0, 'savedId': uid, 'connected': True}
|
||||
|
||||
|
||||
def hotspot_state(profile_dir):
|
||||
path=profile_dir/'vulpes-hotspot.nmconnection'
|
||||
result={'enabled':active_wifi_uuid()==HOTSPOT_UUID,'configured':False,'ssid':'Vulpes OS','security':'wpa2-psk','password':''}
|
||||
|
|
@ -269,11 +302,14 @@ def dispatch(request, profile_dir=PROFILE_DIR):
|
|||
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)
|
||||
state = device_state() if enabled else 'disabled'
|
||||
active = current_network(profile_dir, found) if state == 'connected' else None
|
||||
info={}
|
||||
if any(n['connected'] for n in found):
|
||||
if active:
|
||||
try:info=connection_info()
|
||||
except (WifiError,OSError,subprocess.TimeoutExpired):pass
|
||||
return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
|
||||
return {'enabled': enabled, 'state':state, 'network':active,
|
||||
'networks': found, 'connectionInformation':info}
|
||||
if operation == 'disconnect':
|
||||
if nm('device', 'disconnect', DEVICE).returncode:
|
||||
raise WifiError('WIFI_DISCONNECT_FAILED')
|
||||
|
|
@ -304,7 +340,10 @@ def dispatch(request, profile_dir=PROFILE_DIR):
|
|||
private_write(target, text)
|
||||
if nm('connection', 'load', str(target)).returncode:
|
||||
raise WifiError('WIFI_UNAVAILABLE')
|
||||
if nm('connection', 'up', 'uuid', uid).returncode:
|
||||
# NetworkManager may retain a failed activation after the AP goes out
|
||||
# of range. Explicitly drop that stale activation before reconnecting.
|
||||
nm('connection', 'down', 'uuid', uid)
|
||||
if nm('connection', 'up', 'uuid', uid, 'ifname', DEVICE).returncode:
|
||||
raise WifiError('WIFI_CONNECT_FAILED')
|
||||
connected = True
|
||||
return {'connected': True}
|
||||
|
|
|
|||
Loading…
Reference in a new issue