mirror of
https://github.com/archr-linux/Arch-R.git
synced 2026-03-31 14:41:55 -07:00
System daemons: hotkeys (volume/brightness), automount, bluetooth agent, memory manager, sleep/suspend, USB gadget mode, save-config persistence. Boot splash: initramfs SVG renderer (fbsplash + svg_parser) for 0.7s splash. Panel tools: generate-panel-dtbos.sh rewrite, convert-panel.py for ROCKNIX panel data extraction, archr-dtbo.py for runtime overlay management. Input: archr-gptokeyb.c gamepad-to-keyboard mapper via uinput. Launch wrappers: emulationstation.sh and retroarch-launch.sh updated for KMS/DRM + Mesa 26 Panfrost environment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
506 lines
18 KiB
Python
506 lines
18 KiB
Python
#!/usr/bin/python3
|
|
"""
|
|
Arch R - Hotkey Daemon
|
|
Listens for input events and handles:
|
|
- KEY_VOLUMEUP/KEY_VOLUMEDOWN → ALSA volume adjust (from gpio-keys-vol)
|
|
- MODE + VOL_UP/VOL_DOWN → brightness adjust
|
|
- MODE + B → screenshot
|
|
- MODE + X → WiFi toggle
|
|
- SELECT + START (hold 1s) → kill running game
|
|
- Headphone jack insertion → audio path toggle (from rk817 codec)
|
|
|
|
Volume device (gpio-keys-vol) is grabbed exclusively.
|
|
Gamepad device (gpio-keys or archr-singleadc-joypad) is monitored passively.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import subprocess
|
|
import re
|
|
import select
|
|
|
|
try:
|
|
import evdev
|
|
from evdev import ecodes
|
|
except ImportError:
|
|
print("ERROR: python-evdev not installed. Install with: pacman -S python-evdev")
|
|
sys.exit(1)
|
|
|
|
# Volume step (percentage per key press)
|
|
VOL_STEP = 5
|
|
# Brightness step (percentage per key press)
|
|
BRIGHT_STEP = 5
|
|
# Minimum interval between volume/brightness actions (seconds)
|
|
# adc-keys autorepeat fires at ~30Hz — throttle to ~3 events/sec
|
|
VOL_THROTTLE = 0.3
|
|
# Minimum brightness percentage (prevent black screen)
|
|
BRIGHT_MIN = 5
|
|
# Brightness persistence file
|
|
BRIGHT_SAVE = "/home/archr/.config/archr/brightness"
|
|
VOL_SAVE = "/home/archr/.config/archr/volume"
|
|
|
|
# ALSA simple mixer control name for rk817 codec volume.
|
|
# Depends on machine driver:
|
|
# BSP kernel (rk817-sound): "DAC" (from "DAC Playback Volume")
|
|
# Mainline (simple-audio-card): "Master" (from "Master Playback Volume")
|
|
# Detected at startup by detect_alsa_controls().
|
|
ALSA_VOL_CTRL = "Master" # default for mainline, overridden at startup
|
|
# Speaker/headphone switch control:
|
|
# BSP: "Playback Path" (enum: OFF/SPK/HP/...)
|
|
# Mainline: "Playback Mux" (enum: HP/SPK)
|
|
ALSA_PATH_CTRL = "Playback Mux" # default for mainline, overridden at startup
|
|
|
|
# rk817 codec volume range: ALSA reports [0, 255] but codec rejects values > 252
|
|
# Writing > 252 causes "Volume out of range" and can ZERO the volume!
|
|
# Use percentage clamping: 0-98% stays within [0, 249] (safe margin)
|
|
VOL_MAX_PCT = 98
|
|
VOL_MIN_PCT = 0
|
|
|
|
# Kill switch: hold SELECT+START for this many seconds to kill running game
|
|
KILL_HOLD_TIME = 1.0
|
|
# Game PID file (written by retroarch-launch.sh and other wrappers)
|
|
GAME_PIDFILE = "/tmp/.archr-game-pid"
|
|
# Screenshot directory
|
|
SCREENSHOT_DIR = "/home/archr/screenshots"
|
|
|
|
|
|
# Log to BOOT partition (FAT32) — persistent across reboots, readable from PC
|
|
# /tmp is tmpfs and lost on power off, making debugging impossible
|
|
LOGFILE = "/boot/archr-hotkeys.log"
|
|
|
|
|
|
def detect_alsa_controls():
|
|
"""Detect ALSA volume and path control names at startup.
|
|
BSP kernel uses 'DAC' + 'Playback Path'.
|
|
Mainline simple-audio-card uses 'Master' + 'Playback Mux'."""
|
|
global ALSA_VOL_CTRL, ALSA_PATH_CTRL
|
|
try:
|
|
r = subprocess.run("amixer scontrols", shell=True,
|
|
capture_output=True, text=True, timeout=5)
|
|
controls = r.stdout
|
|
if "'Master'" in controls:
|
|
ALSA_VOL_CTRL = "Master"
|
|
elif "'DAC'" in controls:
|
|
ALSA_VOL_CTRL = "DAC"
|
|
if "'Playback Mux'" in controls:
|
|
ALSA_PATH_CTRL = "Playback Mux"
|
|
elif "'Playback Path'" in controls:
|
|
ALSA_PATH_CTRL = "Playback Path"
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def log(msg):
|
|
"""Append to log file for debugging."""
|
|
try:
|
|
with open(LOGFILE, "a") as f:
|
|
f.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
|
f.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run_cmd(cmd):
|
|
"""Run a shell command, log output for debugging."""
|
|
try:
|
|
result = subprocess.run(cmd, shell=True, capture_output=True, timeout=5, text=True)
|
|
if result.returncode != 0:
|
|
log(f"CMD FAIL [{result.returncode}]: {cmd}")
|
|
if result.stderr:
|
|
log(f" stderr: {result.stderr.strip()}")
|
|
else:
|
|
if result.stdout:
|
|
log(f" stdout: {result.stdout.strip()[:200]}")
|
|
return result.returncode
|
|
except Exception as e:
|
|
log(f"CMD ERROR: {cmd} -> {e}")
|
|
return -1
|
|
|
|
|
|
def get_volume_pct():
|
|
"""Read current DAC volume percentage from sysfs-style amixer output."""
|
|
try:
|
|
r = subprocess.run(
|
|
f"amixer sget '{ALSA_VOL_CTRL}'",
|
|
shell=True, capture_output=True, text=True, timeout=3
|
|
)
|
|
if r.returncode == 0:
|
|
m = re.search(r'\[(\d+)%\]', r.stdout)
|
|
if m:
|
|
return int(m.group(1))
|
|
except Exception:
|
|
pass
|
|
return -1
|
|
|
|
|
|
def set_volume_pct(pct):
|
|
"""Set DAC volume to exact percentage (with clamping for rk817 codec safety)."""
|
|
pct = max(VOL_MIN_PCT, min(VOL_MAX_PCT, pct))
|
|
rc = run_cmd(f"amixer -q sset '{ALSA_VOL_CTRL}' {pct}%")
|
|
if rc != 0:
|
|
log(f"VOL set {pct}% failed, fallback numid=8")
|
|
# Convert percentage to raw value (0-249 safe range for codec max 252)
|
|
raw = (pct * 249) // 100
|
|
run_cmd(f"amixer cset numid=8 {raw},{raw}")
|
|
return pct
|
|
|
|
|
|
def save_volume():
|
|
"""Save current volume percentage for persistence across reboots."""
|
|
try:
|
|
r = subprocess.run(
|
|
f"amixer sget '{ALSA_VOL_CTRL}'",
|
|
shell=True, capture_output=True, text=True, timeout=3
|
|
)
|
|
if r.returncode == 0:
|
|
m = re.search(r'\[(\d+)%\]', r.stdout)
|
|
if m:
|
|
os.makedirs(os.path.dirname(VOL_SAVE), exist_ok=True)
|
|
with open(VOL_SAVE, "w") as f:
|
|
f.write(m.group(1))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def volume_up():
|
|
cur = get_volume_pct()
|
|
if cur < 0:
|
|
cur = 80 # assume default if read fails
|
|
new = min(cur + VOL_STEP, VOL_MAX_PCT)
|
|
log(f"VOL+ {cur}% -> {new}%")
|
|
set_volume_pct(new)
|
|
save_volume()
|
|
|
|
|
|
def volume_down():
|
|
cur = get_volume_pct()
|
|
if cur < 0:
|
|
cur = 80
|
|
new = max(cur - VOL_STEP, VOL_MIN_PCT)
|
|
log(f"VOL- {cur}% -> {new}%")
|
|
set_volume_pct(new)
|
|
save_volume()
|
|
|
|
|
|
def get_brightness_pct():
|
|
"""Read current brightness as percentage from sysfs."""
|
|
try:
|
|
with open("/sys/class/backlight/backlight/brightness") as f:
|
|
cur = int(f.read().strip())
|
|
with open("/sys/class/backlight/backlight/max_brightness") as f:
|
|
mx = int(f.read().strip())
|
|
return (cur * 100) // mx if mx > 0 else 50
|
|
except Exception:
|
|
return 50
|
|
|
|
|
|
def save_brightness():
|
|
"""Save current brightness value for persistence across reboots."""
|
|
try:
|
|
with open("/sys/class/backlight/backlight/brightness") as f:
|
|
val = f.read().strip()
|
|
os.makedirs(os.path.dirname(BRIGHT_SAVE), exist_ok=True)
|
|
with open(BRIGHT_SAVE, "w") as f:
|
|
f.write(val)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def brightness_up():
|
|
log("BRIGHT+ brightnessctl s +3%")
|
|
run_cmd(f"brightnessctl -q s +{BRIGHT_STEP}%")
|
|
save_brightness()
|
|
|
|
|
|
def brightness_down():
|
|
if get_brightness_pct() <= BRIGHT_MIN:
|
|
return
|
|
log(f"BRIGHT- brightnessctl s {BRIGHT_STEP}%-")
|
|
run_cmd(f"brightnessctl -q s {BRIGHT_STEP}%-")
|
|
# Clamp: if we went below minimum, set to minimum
|
|
if get_brightness_pct() < BRIGHT_MIN:
|
|
run_cmd(f"brightnessctl -q s {BRIGHT_MIN}%")
|
|
save_brightness()
|
|
|
|
|
|
def speaker_toggle(headphone_in):
|
|
if headphone_in:
|
|
run_cmd(f"amixer -q sset '{ALSA_PATH_CTRL}' HP")
|
|
else:
|
|
run_cmd(f"amixer -q sset '{ALSA_PATH_CTRL}' SPK")
|
|
|
|
|
|
def kill_running_game():
|
|
"""Kill the currently running game/emulator (not ES)."""
|
|
# Try pidfile first (set by retroarch-launch.sh and other wrappers)
|
|
if os.path.exists(GAME_PIDFILE):
|
|
try:
|
|
with open(GAME_PIDFILE) as f:
|
|
pid = int(f.read().strip())
|
|
os.kill(pid, 9)
|
|
log(f"KILL: killed PID {pid} from pidfile")
|
|
try:
|
|
os.remove(GAME_PIDFILE)
|
|
except OSError:
|
|
pass
|
|
return True
|
|
except (ValueError, ProcessLookupError):
|
|
pass
|
|
|
|
# Fallback: kill known emulator process names
|
|
for proc in ['retroarch', 'drastic', 'ppsspp', 'mupen64plus', 'flycast',
|
|
'desmume', 'picodrive', 'mednafen', 'archr-gptokeyb']:
|
|
rc = run_cmd(f"pkill -9 -x {proc}")
|
|
if rc == 0:
|
|
log(f"KILL: killed {proc}")
|
|
return True
|
|
|
|
log("KILL: no game process found")
|
|
return False
|
|
|
|
|
|
def take_screenshot():
|
|
"""Capture framebuffer to PNG."""
|
|
ts = time.strftime('%Y%m%d_%H%M%S')
|
|
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
|
out = f"{SCREENSHOT_DIR}/screenshot_{ts}.png"
|
|
# Try fbgrab first (produces PNG)
|
|
rc = run_cmd(f"fbgrab {out}")
|
|
if rc != 0:
|
|
# Fallback: raw framebuffer dump
|
|
raw = f"{SCREENSHOT_DIR}/screenshot_{ts}.raw"
|
|
run_cmd(f"cp /dev/fb0 {raw}")
|
|
log(f"SCREENSHOT: raw capture → {raw}")
|
|
else:
|
|
log(f"SCREENSHOT: {out}")
|
|
|
|
|
|
def toggle_wifi():
|
|
"""Toggle WiFi radio on/off via NetworkManager."""
|
|
try:
|
|
r = subprocess.run("nmcli radio wifi", shell=True,
|
|
capture_output=True, text=True, timeout=5)
|
|
state = r.stdout.strip()
|
|
if state == "enabled":
|
|
run_cmd("nmcli radio wifi off")
|
|
log("WIFI: disabled")
|
|
else:
|
|
run_cmd("nmcli radio wifi on")
|
|
log("WIFI: enabled")
|
|
except Exception as e:
|
|
log(f"WIFI toggle error: {e}")
|
|
|
|
|
|
def find_devices():
|
|
"""Find and categorize input devices by capabilities (not just name).
|
|
Works with gpio-keys, adc-keys, and archr-singleadc-joypad."""
|
|
vol_dev = None # device with KEY_VOLUMEUP (grab: exclusive volume control)
|
|
pad_dev = None # device with BTN_SOUTH (no grab: monitor passively)
|
|
sw_dev = None # headphone jack (switch events)
|
|
|
|
for path in evdev.list_devices():
|
|
try:
|
|
dev = evdev.InputDevice(path)
|
|
caps = dev.capabilities()
|
|
key_caps = caps.get(ecodes.EV_KEY, [])
|
|
|
|
# Volume device: has KEY_VOLUMEUP
|
|
if ecodes.KEY_VOLUMEUP in key_caps and not vol_dev:
|
|
vol_dev = dev
|
|
# Gamepad: has BTN_SOUTH or BTN_DPAD_UP (but NOT volume keys)
|
|
elif (ecodes.BTN_SOUTH in key_caps or ecodes.BTN_DPAD_UP in key_caps) and not pad_dev:
|
|
pad_dev = dev
|
|
|
|
# Headphone jack switch events (from rk817 or similar codec)
|
|
if ecodes.EV_SW in caps and not sw_dev:
|
|
sw_dev = dev
|
|
|
|
except Exception:
|
|
continue
|
|
|
|
return vol_dev, pad_dev, sw_dev
|
|
|
|
|
|
def main():
|
|
print("Arch R Hotkey Daemon starting...")
|
|
|
|
# Detect ALSA control names (Master vs DAC, Playback Mux vs Playback Path)
|
|
detect_alsa_controls()
|
|
print(f" ALSA volume: '{ALSA_VOL_CTRL}', path: '{ALSA_PATH_CTRL}'")
|
|
|
|
# Wait for input devices to appear
|
|
# adc-keys (vol_dev) loads instantly (built-in), but singleadc-joypad (pad_dev)
|
|
# is a module that loads later. Wait for BOTH, with a timeout for pad_dev.
|
|
vol_dev, pad_dev, sw_dev = None, None, None
|
|
for attempt in range(30):
|
|
vol_dev, pad_dev, sw_dev = find_devices()
|
|
if vol_dev and pad_dev:
|
|
break
|
|
# After 10s, start with just vol_dev (pad_dev may not exist on all boards)
|
|
if vol_dev and attempt >= 10:
|
|
log(f"WARN: pad_dev not found after {attempt}s, starting without gamepad")
|
|
break
|
|
time.sleep(1)
|
|
|
|
if not vol_dev:
|
|
print("ERROR: Volume input device (gpio-keys-vol) not found!")
|
|
sys.exit(1)
|
|
|
|
# Grab volume device exclusively (we handle volume events)
|
|
vol_dev.grab()
|
|
print(f" Volume: {vol_dev.name} ({vol_dev.path}) [grabbed]")
|
|
|
|
# Monitor gamepad passively for MODE button (brightness hotkey)
|
|
devices = [vol_dev]
|
|
if pad_dev:
|
|
# DO NOT grab — ES needs this device for gamepad input
|
|
print(f" Gamepad: {pad_dev.name} ({pad_dev.path}) [passive]")
|
|
devices.append(pad_dev)
|
|
else:
|
|
print(" Gamepad: not found yet (will rescan)")
|
|
|
|
if sw_dev and sw_dev not in devices:
|
|
print(f" Switch: {sw_dev.name} ({sw_dev.path}) [passive]")
|
|
devices.append(sw_dev)
|
|
|
|
# Track button states
|
|
mode_held = False
|
|
select_held = False
|
|
start_held = False
|
|
kill_combo_start = 0.0 # monotonic time when SELECT+START both held
|
|
# Throttle: last time a volume/brightness action was executed
|
|
last_vol_action = 0.0
|
|
# Rescan timer: if pad_dev was not found at startup, try again periodically
|
|
last_rescan = time.monotonic()
|
|
|
|
print("Hotkey daemon ready.")
|
|
# Clear previous log on fresh start
|
|
try:
|
|
with open(LOGFILE, "w") as f:
|
|
f.write(f"{time.strftime('%H:%M:%S')} === Daemon started (fresh) ===\n")
|
|
except Exception:
|
|
pass
|
|
log(f" vol_dev: {vol_dev.name} ({vol_dev.path})")
|
|
if pad_dev:
|
|
log(f" pad_dev: {pad_dev.name} ({pad_dev.path})")
|
|
else:
|
|
log(" pad_dev: NOT FOUND (will rescan every 5s)")
|
|
|
|
# Startup amixer diagnostic — confirm volume control works from daemon context
|
|
log("--- Startup ALSA diagnostic ---")
|
|
log(f" vol_ctrl='{ALSA_VOL_CTRL}' path_ctrl='{ALSA_PATH_CTRL}'")
|
|
r = subprocess.run(f"amixer sget '{ALSA_VOL_CTRL}' 2>&1", shell=True, capture_output=True, text=True, timeout=5)
|
|
log(f" amixer sget '{ALSA_VOL_CTRL}' rc={r.returncode}")
|
|
for line in r.stdout.strip().split('\n'):
|
|
log(f" {line}")
|
|
if r.stderr.strip():
|
|
log(f" stderr: {r.stderr.strip()}")
|
|
# Volume NOT set here — user's saved volume is restored by emulationstation.sh
|
|
log("--- End ALSA diagnostic ---")
|
|
|
|
try:
|
|
while True:
|
|
# Rescan for pad_dev if not found yet (module may load late)
|
|
if not pad_dev and time.monotonic() - last_rescan >= 5.0:
|
|
last_rescan = time.monotonic()
|
|
_, new_pad, new_sw = find_devices()
|
|
if new_pad:
|
|
pad_dev = new_pad
|
|
devices.append(pad_dev)
|
|
log(f"RESCAN: pad_dev found: {pad_dev.name} ({pad_dev.path})")
|
|
if new_sw and new_sw not in devices:
|
|
sw_dev = new_sw
|
|
devices.append(sw_dev)
|
|
log(f"RESCAN: sw_dev found: {sw_dev.name} ({sw_dev.path})")
|
|
|
|
# Fast poll when kill combo is pending, otherwise idle
|
|
timeout = 0.1 if kill_combo_start > 0 else 2.0
|
|
r, _, _ = select.select(devices, [], [], timeout)
|
|
|
|
# Check kill combo hold timer
|
|
if kill_combo_start > 0:
|
|
if select_held and start_held:
|
|
if time.monotonic() - kill_combo_start >= KILL_HOLD_TIME:
|
|
kill_running_game()
|
|
kill_combo_start = 0
|
|
else:
|
|
kill_combo_start = 0 # one was released
|
|
|
|
for dev in r:
|
|
try:
|
|
for event in dev.read():
|
|
if event.type == ecodes.EV_KEY:
|
|
key = event.code
|
|
val = event.value # 1=press, 0=release, 2=repeat
|
|
keyname = ecodes.KEY.get(key, ecodes.BTN.get(key, f"?{key}"))
|
|
valname = {0: "UP", 1: "DOWN", 2: "REPEAT"}.get(val, f"?{val}")
|
|
log(f"KEY: {keyname}({key}) {valname} dev={dev.name} mode={mode_held}")
|
|
|
|
# Track MODE button from gamepad (passive)
|
|
if key == ecodes.BTN_MODE:
|
|
mode_held = (val >= 1)
|
|
|
|
# Track SELECT for kill combo
|
|
elif key == ecodes.BTN_SELECT:
|
|
select_held = (val >= 1)
|
|
if select_held and start_held and kill_combo_start == 0:
|
|
kill_combo_start = time.monotonic()
|
|
|
|
# Track START for kill combo
|
|
elif key == ecodes.BTN_START:
|
|
start_held = (val >= 1)
|
|
if select_held and start_held and kill_combo_start == 0:
|
|
kill_combo_start = time.monotonic()
|
|
|
|
# Volume keys (grabbed): accept press + repeat,
|
|
# but throttle to max ~3 events/sec (300ms interval).
|
|
# MUST come before MODE combos — the generic mode_held
|
|
# handler would swallow volume keys otherwise.
|
|
elif key == ecodes.KEY_VOLUMEUP and val in (1, 2):
|
|
now = time.monotonic()
|
|
if now - last_vol_action >= VOL_THROTTLE:
|
|
last_vol_action = now
|
|
if mode_held:
|
|
brightness_up()
|
|
else:
|
|
volume_up()
|
|
|
|
elif key == ecodes.KEY_VOLUMEDOWN and val in (1, 2):
|
|
now = time.monotonic()
|
|
if now - last_vol_action >= VOL_THROTTLE:
|
|
last_vol_action = now
|
|
if mode_held:
|
|
brightness_down()
|
|
else:
|
|
volume_down()
|
|
|
|
# MODE combos (on initial press only, non-volume keys)
|
|
elif mode_held and val == 1:
|
|
if key == ecodes.BTN_EAST: # B button
|
|
take_screenshot()
|
|
elif key == ecodes.BTN_NORTH: # X button
|
|
toggle_wifi()
|
|
|
|
# Headphone jack switch
|
|
elif event.type == ecodes.EV_SW:
|
|
if event.code == ecodes.SW_HEADPHONE_INSERT:
|
|
speaker_toggle(event.value == 1)
|
|
|
|
except OSError:
|
|
# Device disconnected
|
|
pass
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
try:
|
|
vol_dev.ungrab()
|
|
except Exception:
|
|
pass
|
|
print("Hotkey daemon stopped.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|