sol-r1-linux-game-mappings/template/input_converter_daemon.py

252 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""
input_converter_daemon.py - TEMPLATE stateful input-converter daemon.
Copy into a new <game>/ folder and customize when a game exposes a
control only as a stateless "press to toggle" key, but the physical
Sol-R1 control involved is a REAL on/off switch (or otherwise has a
fixed physical position that can drift out of sync with in-game
state). joyful alone can't do this - it has no game-state feedback,
so it can't tell a stateless key apart from a real switch.
This only needs to exist for controls that need that feedback loop.
If every physical control on a game's mapping is a stateless
passthrough (buttons, triggers, continuous axes), delete this file and
the daemon block in launch-wrapper.sh - joyful by itself is enough.
How it works: polls two things continuously -
1. The physical control's position (via evdev, reading held button
state for a two-position switch)
2. The ACTUAL in-game state, however it's readable - by default this
template samples a screen pixel/region via `grim` (HUD color),
since most games have no other externally-readable state. Swap
out `read_external_state()` for a different method if the game
exposes state some other way (e.g. a log file, a memory read, an
API).
and sends a correction keypress ONLY when they disagree, via a virtual
uinput keyboard. Net effect: physical switch position always matches
game state.
Requirements:
sudo pacman -S grim # Hyprland/wlroots screen capture -
# only needed if using the default
# pixel-sampling read_external_state()
pip install evdev pillow pyyaml --break-system-packages
Permissions:
sudo usermod -aG input $USER # for reading the joystick + uinput
(re-login after this)
Usage:
# Step 1: calibrate - find the pixel/region + colors for each control
python3 input_converter_daemon.py --sample 200,850
# Step 2: fill in a config (see --dump-example-config), then run:
python3 input_converter_daemon.py --config config.yaml
"""
import argparse
import subprocess
import sys
import tempfile
import time
from pathlib import Path
try:
import yaml
from PIL import Image
from evdev import InputDevice, UInput, ecodes, list_devices
except ImportError:
sys.exit(
"Missing deps. Run:\n"
" pip install evdev pillow pyyaml --break-system-packages"
)
# --------------------------------------------------------------------
# External (in-game) state - default implementation reads a screen
# region via grim. Replace this section if the game offers a better
# signal than pixel color.
# --------------------------------------------------------------------
def grab_region(x: int, y: int, w: int, h: int) -> Image.Image:
"""Capture a screen region using grim and return a PIL Image."""
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
geometry = f"{x},{y} {w}x{h}"
subprocess.run(
["grim", "-g", geometry, tmp.name],
check=True,
stderr=subprocess.DEVNULL,
)
return Image.open(tmp.name).convert("RGB").copy()
def sample_pixel(x: int, y: int):
"""Grab a 1x1 region and return its RGB - used for calibration."""
img = grab_region(x, y, 1, 1)
return img.getpixel((0, 0))
def read_external_state(control_cfg: dict) -> bool:
"""True if the game's on-screen indicator reads as 'on'."""
x, y, w, h = control_cfg["region"]
img = grab_region(x, y, w, h)
pixels = list(img.getdata())
n = len(pixels)
avg = tuple(sum(p[i] for p in pixels) / n for i in range(3))
target_rgb = control_cfg["on_color"]
tolerance = control_cfg.get("tolerance", 30)
diff = sum(abs(avg[i] - target_rgb[i]) for i in range(3))
return diff <= tolerance
# --------------------------------------------------------------------
# Physical control state (evdev)
# --------------------------------------------------------------------
class PhysicalStateReader:
"""Tracks held button state for one physical device."""
def __init__(self, device_name_substr: str):
self.dev = self._find_device(device_name_substr)
self.state = {} # code -> 0/1, current held state
self.dev.grab = False # don't steal input from the game
@staticmethod
def _find_device(name_substr):
for path in list_devices():
d = InputDevice(path)
if name_substr.lower() in d.name.lower():
return d
sys.exit(f"No input device matching '{name_substr}' found.")
def poll(self):
"""Non-blocking drain of pending events, updating held state."""
try:
for event in self.dev.read():
if event.type == ecodes.EV_KEY:
self.state[event.code] = event.value
except BlockingIOError:
pass
def code_for(self, name: str) -> int:
return getattr(ecodes, name)
def is_held(self, code_name: str) -> bool:
return self.state.get(self.code_for(code_name), 0) == 1
# --------------------------------------------------------------------
# Virtual keyboard output (uinput)
# --------------------------------------------------------------------
def make_virtual_keyboard():
return UInput(name="input-converter-daemon-vkbd")
def tap_key(vkbd, key_name: str):
code = getattr(ecodes, key_name)
vkbd.write(ecodes.EV_KEY, code, 1)
vkbd.syn()
time.sleep(0.05)
vkbd.write(ecodes.EV_KEY, code, 0)
vkbd.syn()
# --------------------------------------------------------------------
# Main loop
# --------------------------------------------------------------------
def run(config_path: str, poll_interval: float):
cfg = yaml.safe_load(Path(config_path).read_text())
reader = PhysicalStateReader(cfg["device_name"])
vkbd = make_virtual_keyboard()
print("input_converter_daemon running. Ctrl+C to stop.\n")
try:
while True:
reader.poll()
for name, control in cfg["controls"].items():
desired_on = reader.is_held(control["input_on"])
# if neither position held (mid-flip / debounce), skip
if not desired_on and not reader.is_held(control["input_off"]):
continue
actual_on = read_external_state(control)
if desired_on != actual_on:
print(f"[{name}] physical={'ON' if desired_on else 'OFF'} "
f"game={'ON' if actual_on else 'OFF'} -> correcting")
tap_key(vkbd, control["toggle_key"])
time.sleep(0.3) # let the game UI update before re-checking
time.sleep(poll_interval)
except KeyboardInterrupt:
print("\nStopped.")
finally:
vkbd.close()
# --------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------
EXAMPLE_CONFIG = """\
# config.yaml - example, fill in with your real values
device_name: "TODO: Sol-R Base" # substring match against evdev device name
controls:
example_control:
input_on: "TODO_BTN_EXAMPLE_UP" # held while switch is flipped up
input_off: "TODO_BTN_EXAMPLE_DOWN" # held while switch is flipped down
toggle_key: "TODO_KEY_EXAMPLE" # the game's actual toggle keybind
region: [0, 0, 20, 20] # x, y, w, h of the HUD indicator
on_color: [0, 0, 0] # sampled color when the control is ON
tolerance: 30
# ... one block per physical switch that needs state reconciliation
"""
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--sample", metavar="X,Y",
help="calibration helper: print the RGB color at screen pixel X,Y"
)
parser.add_argument(
"--dump-example-config", action="store_true",
help="print an example config.yaml to stdout"
)
parser.add_argument("--config", help="path to config.yaml")
parser.add_argument(
"--interval", type=float, default=0.25,
help="poll interval in seconds (default 0.25)"
)
args = parser.parse_args()
if args.dump_example_config:
print(EXAMPLE_CONFIG)
return
if args.sample:
x, y = map(int, args.sample.split(","))
rgb = sample_pixel(x, y)
print(f"Pixel ({x},{y}) = RGB{rgb}")
return
if args.config:
run(args.config, args.interval)
return
parser.print_help()
if __name__ == "__main__":
main()