Release Vulpes OS 2.7-preview.24

This commit is contained in:
OverSu 2026-11-04 12:52:20 +01:00
commit 97ef99f827
135 changed files with 7717 additions and 2524 deletions

View 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())

View file

@ -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)

View file

@ -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()

View 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()

View file

@ -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

View file

@ -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

View file

@ -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}