VulpesOS/tundra/runtime/buttons.py
OverSu b38dea0597 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.
2026-09-30 23:13:03 +02:00

156 lines
6.6 KiB
Python

#!/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')