#!/usr/bin/env python3 """ switch_sync.py - make a physical toggle switch behave like a REAL switch in Space Engineers, instead of a stateless "press to toggle" key. Problem: SE only exposes toggles (jetpack/helmet/light/parking) as a single press-to-flip key. A physical on/off switch has no way to know which state the game is currently in, so flipping it can desync (e.g. switch says ON, game is actually OFF, because someone toggled it some other way in between). Fix: this script polls two things continuously - 1. The physical switch position (via evdev, reading held button state) 2. The ACTUAL in-game state (by sampling a pixel/region on screen) and sends the toggle keypress ONLY when they disagree, via a virtual uinput keyboard. Net effect: switch position always matches game state. Requirements: sudo pacman -S grim # Hyprland/wlroots screen capture 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 toggle python3 switch_sync.py --sample 200,850 # Step 2: fill in config.yaml (see example below), then run: python3 switch_sync.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" ) # -------------------------------------------------------------------- # Screen capture (Hyprland/wlroots via grim) # -------------------------------------------------------------------- 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 region_matches_color(x, y, w, h, target_rgb, tolerance): """Average the region's color and compare to target within tolerance.""" 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)) diff = sum(abs(avg[i] - target_rgb[i]) for i in range(3)) return diff <= tolerance # -------------------------------------------------------------------- # Physical switch state (evdev) # -------------------------------------------------------------------- class SwitchReader: """Tracks held button state for one or more physical devices.""" 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="switch-sync-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 = SwitchReader(cfg["device_name"]) vkbd = make_virtual_keyboard() print("switch_sync running. Ctrl+C to stop.\n") try: while True: reader.poll() for name, sw in cfg["switches"].items(): desired_on = reader.is_held(sw["input_on"]) # if neither position held (mid-flip / debounce), skip if not desired_on and not reader.is_held(sw["input_off"]): continue region = sw["region"] # [x, y, w, h] actual_on = region_matches_color( *region, target_rgb=sw["on_color"], tolerance=sw.get("tolerance", 30), ) if desired_on != actual_on: print(f"[{name}] switch={'ON' if desired_on else 'OFF'} " f"game={'ON' if actual_on else 'OFF'} -> correcting") tap_key(vkbd, sw["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: "Sol-R Base" # substring match against evdev device name switches: jetpack: input_on: "BTN_TRIGGER_HAPPY9" # held while switch is flipped up input_off: "BTN_TRIGGER_HAPPY10" # held while switch is flipped down toggle_key: "KEY_SPACE" # SE's actual jetpack toggle bind region: [1830, 980, 20, 20] # x, y, w, h of the HUD indicator on_color: [255, 200, 0] # sampled color when jetpack is ON tolerance: 30 helmet: input_on: "BTN_TRIGGER_HAPPY11" input_off: "BTN_TRIGGER_HAPPY12" toggle_key: "KEY_H" # example, use your real bind region: [1830, 1010, 20, 20] on_color: [255, 200, 0] tolerance: 30 # ... light / parking follow the same pattern """ 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()