246 lines
7.8 KiB
Python
Executable File
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()
|