sol-r1-linux-game-mappings/space-engineers/switch_sync.py

246 lines
7.8 KiB
Python
Executable File

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