#!/usr/bin/env python3 """ input_converter_daemon.py - TEMPLATE stateful input-converter daemon. Copy into a new / 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()