Add per-game Sol-R1 Linux input mapping system with reusable template
Restructures around one folder per game, each with its own joyful config, launch wrapper, and (if needed) a stateful daemon for physical switches that need game-state feedback. template/ generalizes that pattern (bare passthrough config, template wrapper, template daemon) for mapping new games. space-engineers/ is the first instance, built from the original mapping brief; find_codes.py is shared across games. All device codes/keybinds/HUD calibration are still TODO pending hardware access.main
commit
79c9ed94c4
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# sol-r1-linux-game-mappings
|
||||||
|
|
||||||
|
Custom input mappings for the Thrustmaster Sol-R1 HOTAS (grip + base)
|
||||||
|
on Linux (Arch/Hyprland), without Windows/T.A.R.G.E.T. Built on
|
||||||
|
[joyful](https://aur.archlinux.org/packages/joyful), a Go-based Linux
|
||||||
|
joystick remapper that turns physical Sol-R1 input into a virtual
|
||||||
|
gamepad via YAML rules.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `find_codes.py` - shared diagnostic tool. Run against the physical
|
||||||
|
hardware first, for any game, to get real evdev `BTN_*`/`ABS_*`
|
||||||
|
codes to fill into that game's config.
|
||||||
|
- `template/` - starting point for mapping a new game: a bare
|
||||||
|
passthrough joyful config (stick behaves normally, nothing remapped
|
||||||
|
yet), a template Steam launch-options wrapper, and a template
|
||||||
|
stateful input-converter daemon for controls that need game-state
|
||||||
|
feedback (real on/off switches mapped to a game's stateless toggle
|
||||||
|
key). See `template/README.md`.
|
||||||
|
- `<game>/` - one folder per game, each self-contained (its own
|
||||||
|
joyful config, launch wrapper, and daemon + config if it needs one).
|
||||||
|
Currently:
|
||||||
|
- `space-engineers/`
|
||||||
|
|
||||||
|
## Adding a new game
|
||||||
|
|
||||||
|
Copy `template/` to `<game-name>/` and follow `template/README.md`.
|
||||||
|
|
||||||
|
## Why a daemon at all
|
||||||
|
|
||||||
|
joyful is stateless - it has no idea what's happening in the game, so
|
||||||
|
it can only pass a control through or remap it. That's fine for
|
||||||
|
buttons, triggers, and continuous axes. It breaks down for a real
|
||||||
|
physical on/off switch mapped to a game action that's only exposed as
|
||||||
|
a stateless "press to toggle" key: the switch position can silently
|
||||||
|
drift out of sync with the game's actual state. Where that happens, a
|
||||||
|
small per-game daemon polls both the physical switch and the game's
|
||||||
|
actual state (usually by sampling a HUD pixel via `grim`, absent a
|
||||||
|
better signal) and only sends a correction keypress when they
|
||||||
|
disagree. See `template/input_converter_daemon.py` and
|
||||||
|
`space-engineers/switch_sync.py` for a worked example.
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
find_codes.py - print real evdev BTN_*/ABS_* codes for the Sol-R1
|
||||||
|
grip and base, so they can replace the TODO placeholders in any
|
||||||
|
game's joyful-config.yml / daemon config.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
pip install evdev --break-system-packages
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
sudo usermod -aG input $USER # for reading the joystick
|
||||||
|
(re-login after this)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Step 1: find the exact device name(s) - grip and base usually
|
||||||
|
# show up as two separate evdev devices.
|
||||||
|
python3 find_codes.py --list
|
||||||
|
|
||||||
|
# Step 2: watch a device and exercise every physical control on it
|
||||||
|
# (move every axis through its full range, press every button,
|
||||||
|
# flip every switch both ways, run every rotary knob both ways).
|
||||||
|
python3 find_codes.py --device "Sol-R"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import selectors
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
from evdev import InputDevice, ecodes, list_devices
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("Missing deps. Run:\n pip install evdev --break-system-packages")
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_devices():
|
||||||
|
devices = [InputDevice(path) for path in list_devices()]
|
||||||
|
if not devices:
|
||||||
|
print("No input devices found (check permissions: groups $USER).")
|
||||||
|
return
|
||||||
|
for dev in devices:
|
||||||
|
print(f"{dev.path}\t{dev.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_devices(name_substr: str):
|
||||||
|
matches = [
|
||||||
|
dev for dev in (InputDevice(path) for path in list_devices())
|
||||||
|
if name_substr.lower() in dev.name.lower()
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
|
sys.exit(
|
||||||
|
f"No input device matching '{name_substr}' found. "
|
||||||
|
f"Run --list to see available device names."
|
||||||
|
)
|
||||||
|
if len(matches) > 1:
|
||||||
|
print(f"Multiple devices match '{name_substr}', watching all:")
|
||||||
|
for d in matches:
|
||||||
|
print(f" {d.path}\t{d.name}")
|
||||||
|
print()
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def code_name(ev_type: int, code: int) -> str:
|
||||||
|
"""Resolve a numeric code back to its BTN_*/ABS_*/KEY_* name(s)."""
|
||||||
|
names = ecodes.bytype.get(ev_type, {}).get(code)
|
||||||
|
if names is None:
|
||||||
|
return str(code)
|
||||||
|
return names if isinstance(names, str) else "/".join(names)
|
||||||
|
|
||||||
|
|
||||||
|
def watch(devices):
|
||||||
|
print("Exercise every physical control now (axes through full "
|
||||||
|
"range, buttons pressed, switches flipped both ways, knobs "
|
||||||
|
"turned both ways). Ctrl+C to stop.\n")
|
||||||
|
|
||||||
|
sel = selectors.DefaultSelector()
|
||||||
|
for dev in devices:
|
||||||
|
sel.register(dev, selectors.EVENT_READ, data=dev)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
for key, _ in sel.select():
|
||||||
|
dev = key.data
|
||||||
|
for event in dev.read():
|
||||||
|
if event.type == ecodes.EV_KEY:
|
||||||
|
name = code_name(ecodes.EV_KEY, event.code)
|
||||||
|
state = {0: "UP", 1: "DOWN", 2: "HOLD"}.get(
|
||||||
|
event.value, f"value={event.value}"
|
||||||
|
)
|
||||||
|
print(f"[{dev.name}] {name} ({event.code}) {state}")
|
||||||
|
elif event.type == ecodes.EV_ABS:
|
||||||
|
name = code_name(ecodes.EV_ABS, event.code)
|
||||||
|
print(f"[{dev.name}] {name} ({event.code}) = {event.value}")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list", action="store_true", help="list all evdev input devices and exit"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device", metavar="NAME",
|
||||||
|
help="substring match against evdev device name(s) to watch",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
list_all_devices()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.device:
|
||||||
|
watch(find_devices(args.device))
|
||||||
|
return
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Space Engineers
|
||||||
|
|
||||||
|
Custom input scheme for the Thrustmaster Sol-R1 (grip + base) in Space
|
||||||
|
Engineers, on Arch/Hyprland, without Windows/T.A.R.G.E.T. Fixes the
|
||||||
|
"throttle behaves like a trigger" bug and adds a full custom mapping
|
||||||
|
via joyful plus a stateful daemon (`switch_sync.py`) for the 4 tilt
|
||||||
|
switches, which need game-state feedback that joyful can't provide.
|
||||||
|
|
||||||
|
**Status: draft.** No real evdev codes, SE keybinds, or HUD
|
||||||
|
calibration have been captured yet - every config file here is full of
|
||||||
|
`TODO_*` placeholders. See `mapping-brief.md` for the full design
|
||||||
|
rationale, the complete mapping table, and known caveats/open items.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `mapping-brief.md` - design notes: stack decisions, full
|
||||||
|
physical-control -> SE-function mapping table, caveats, next steps.
|
||||||
|
- `joyful-config.yml` - joyful rule config (**draft, unverified field
|
||||||
|
names** - check against the installed version's
|
||||||
|
`examples/ruletypes.yml` first).
|
||||||
|
- `switch_sync.py` - the daemon: reconciles physical switch position
|
||||||
|
against actual in-game HUD state via `grim` screen-color sampling,
|
||||||
|
and only sends a toggle keypress when they disagree.
|
||||||
|
- `switch_sync-config.yaml` - config for the 4 tilt switches.
|
||||||
|
- `launch-wrapper.sh` - Steam launch-options wrapper (`%command%`)
|
||||||
|
that starts/stops joyful and `switch_sync.py` around the game
|
||||||
|
session, cleaning up on exit.
|
||||||
|
|
||||||
|
(`find_codes.py`, the diagnostic tool used in step 2 below, lives at
|
||||||
|
the repo root - it's shared across all games.)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```
|
||||||
|
yay -S joyful
|
||||||
|
sudo pacman -S grim
|
||||||
|
pip install evdev pillow pyyaml --break-system-packages
|
||||||
|
sudo usermod -aG input $USER # for reading the joystick + uinput
|
||||||
|
# re-login after this
|
||||||
|
```
|
||||||
|
2. Find real evdev codes:
|
||||||
|
```
|
||||||
|
python3 ../find_codes.py --list
|
||||||
|
python3 ../find_codes.py --device "<grip or base device name>"
|
||||||
|
```
|
||||||
|
Exercise every physical control (axes through full range, buttons,
|
||||||
|
both positions of every switch, both directions of every knob), and
|
||||||
|
replace every `TODO_*` code in `joyful-config.yml` and
|
||||||
|
`switch_sync-config.yaml` with what you observe.
|
||||||
|
3. In Space Engineers (Options -> Controls -> Controllers), confirm
|
||||||
|
the actual keybinds for the jetpack/helmet/flashlight/parking
|
||||||
|
toggles - don't assume defaults - and fill in `toggle_key` for each
|
||||||
|
switch in `switch_sync-config.yaml`.
|
||||||
|
4. Calibrate the HUD regions/colors:
|
||||||
|
```
|
||||||
|
python3 switch_sync.py --sample X,Y
|
||||||
|
```
|
||||||
|
for each indicator in-game, and fill in `region`/`on_color` in
|
||||||
|
`switch_sync-config.yaml`.
|
||||||
|
5. In Steam, set this game's launch options to:
|
||||||
|
```
|
||||||
|
/path/to/sol-r1-linux-game-mappings/space-engineers/launch-wrapper.sh %command%
|
||||||
|
```
|
||||||
|
Launch options are per-machine and not synced by Steam - repeat
|
||||||
|
this step on every machine. Keeping this repo checked out at the
|
||||||
|
same path on every machine keeps the launch-options string itself
|
||||||
|
identical too.
|
||||||
|
|
||||||
|
See `mapping-brief.md` for the full mapping table and caveats:
|
||||||
|
SE-version-dependent joystick support, the dampener 3-state
|
||||||
|
limitation, HUD-detection fragility (breaks on Tab/resolution/UI-scale
|
||||||
|
changes), and the unverified joyful rule schema.
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
# joyful-config.yml - joyful config for Thrustmaster Sol-R1
|
||||||
|
# (grip + base) -> Space Engineers.
|
||||||
|
#
|
||||||
|
# DRAFT / UNVERIFIED: joyful's exact per-rule-type field names below
|
||||||
|
# were not checked against `examples/ruletypes.yml` in the installed
|
||||||
|
# version - do that before trusting this file. See mapping-brief.md
|
||||||
|
# "Known caveats".
|
||||||
|
#
|
||||||
|
# Every TODO_* below is a placeholder. Run find_codes.py (repo root)
|
||||||
|
# against the actual hardware and replace every one before use.
|
||||||
|
|
||||||
|
devices:
|
||||||
|
base:
|
||||||
|
match: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
|
||||||
|
grip:
|
||||||
|
match: "TODO: Sol-R Grip"
|
||||||
|
|
||||||
|
modes:
|
||||||
|
space_engineers:
|
||||||
|
devices: [base, grip]
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# Throttle (base) -> Move Forward/Backward.
|
||||||
|
# Original bug: SE read the raw HID throttle axis like a trigger.
|
||||||
|
# Straight axis passthrough fixes it.
|
||||||
|
- device: base
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_THROTTLE
|
||||||
|
output: ABS_Y
|
||||||
|
|
||||||
|
# Main stick (grip) X/Y -> View/look/rotate
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_STICK_X
|
||||||
|
output: ABS_RX
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_STICK_Y
|
||||||
|
output: ABS_RY
|
||||||
|
|
||||||
|
# Ministick (grip) -> Strafe horizontal/vertical
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_MINISTICK_X
|
||||||
|
output: ABS_Z
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_MINISTICK_Y
|
||||||
|
output: ABS_RZ
|
||||||
|
|
||||||
|
# Dual-position trigger stage 1/2 -> Primary/secondary click (use/fire, alt-fire)
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_TRIGGER_STAGE1
|
||||||
|
output: BTN_TRIGGER
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_TRIGGER_STAGE2
|
||||||
|
output: BTN_THUMB
|
||||||
|
|
||||||
|
# 2 orange buttons (top of stick) -> Dampeners on/off (Z) + Relative
|
||||||
|
# dampers (Ctrl+Z). Key emulation, not gamepad buttons - Relative
|
||||||
|
# Dampers isn't confirmed as a standalone bindable action, it's a
|
||||||
|
# modifier on the toggle key.
|
||||||
|
- device: grip
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_ORANGE_1
|
||||||
|
output: [KEY_Z]
|
||||||
|
- device: grip
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_ORANGE_2
|
||||||
|
output: [KEY_LEFTCTRL, KEY_Z]
|
||||||
|
|
||||||
|
# Rotary knob 1 (base) -> Switch toolbar (next/prev, default `,`/`.`).
|
||||||
|
# ASSUMES the knob reports as BTN detent pulses, not an ABS axis -
|
||||||
|
# confirm with find_codes.py. If it's an ABS axis instead, this
|
||||||
|
# needs joyful's segmented axis-to-button rule type instead.
|
||||||
|
- device: base
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_KNOB1_CW
|
||||||
|
output: [KEY_DOT]
|
||||||
|
- device: base
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_KNOB1_CCW
|
||||||
|
output: [KEY_COMMA]
|
||||||
|
|
||||||
|
# Rotary knob 2 (base) -> Hotbar slot select, same pulse assumption
|
||||||
|
# as knob 1 above.
|
||||||
|
- device: base
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_KNOB2_CW
|
||||||
|
output: [TODO_KEY_HOTBAR_NEXT]
|
||||||
|
- device: base
|
||||||
|
type: key
|
||||||
|
input: TODO_BTN_KNOB2_CCW
|
||||||
|
output: [TODO_KEY_HOTBAR_PREV]
|
||||||
|
|
||||||
|
# 4-button group (base, left side) -> Jetpack/Helmet/Flashlight/
|
||||||
|
# Parking toggles, bound in SE's own control menu (gamepad button
|
||||||
|
# passthrough, not key emulation).
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_JETPACK
|
||||||
|
output: BTN_TRIGGER_HAPPY1
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_HELMET
|
||||||
|
output: BTN_TRIGGER_HAPPY2
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_FLASHLIGHT
|
||||||
|
output: BTN_TRIGGER_HAPPY3
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_PARKING
|
||||||
|
output: BTN_TRIGGER_HAPPY4
|
||||||
|
|
||||||
|
# NOTE: the 4 tilt switches (base, top) are intentionally NOT mapped
|
||||||
|
# here - they're handled by switch_sync.py instead of joyful,
|
||||||
|
# because they need game-state feedback to avoid desync. See
|
||||||
|
# switch_sync-config.yaml.
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# launch-wrapper.sh - starts joyful + switch_sync, runs the game,
|
||||||
|
# cleans up when it exits (however it exits).
|
||||||
|
#
|
||||||
|
# Steam launch options: /path/to/space-engineers/launch-wrapper.sh %command%
|
||||||
|
# (per-machine, not synced by Steam - re-paste after cloning on a new
|
||||||
|
# machine; keeping the repo checked out at the same path everywhere
|
||||||
|
# keeps the launch-options string itself identical too.)
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||||
|
|
||||||
|
JOYFUL_CONFIG="$REPO_DIR/joyful-config.yml"
|
||||||
|
SYNC_CONFIG="$REPO_DIR/switch_sync-config.yaml"
|
||||||
|
|
||||||
|
joyful --config "$JOYFUL_CONFIG" &
|
||||||
|
JOYFUL_PID=$!
|
||||||
|
|
||||||
|
python3 "$REPO_DIR/switch_sync.py" --config "$SYNC_CONFIG" &
|
||||||
|
SYNC_PID=$!
|
||||||
|
|
||||||
|
# clean up even if this script gets killed/terminated, not just on normal exit
|
||||||
|
trap 'kill "$JOYFUL_PID" "$SYNC_PID" 2>/dev/null' EXIT
|
||||||
|
|
||||||
|
# "$@" is the substituted real launch command (Proton + the game)
|
||||||
|
"$@"
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Thrustmaster Sol-R 1 -> Space Engineers input mapping (Linux/Arch/Hyprland)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Fix "throttle behaves like a trigger" bug and build a full custom input
|
||||||
|
scheme for the Sol-R1 (grip + base) in Space Engineers, on Arch/Hyprland,
|
||||||
|
without Windows/T.A.R.G.E.T.
|
||||||
|
|
||||||
|
## Stack decided on
|
||||||
|
- **joyful** (AUR: `yay -S joyful`) - Go-based Linux joystick remapper,
|
||||||
|
creates a virtual gamepad device from physical Sol-R inputs via YAML
|
||||||
|
rules in `~/.config/joyful/`. Chosen over input-remapper because it
|
||||||
|
natively supports split-axis and segmented-axis-to-button mappings,
|
||||||
|
which input-remapper doesn't (see upstream issue #900).
|
||||||
|
- **switch_sync.py** (custom, in this repo/outputs) - a separate stateful
|
||||||
|
daemon for switches that need to reconcile physical position against
|
||||||
|
actual in-game state (bypasses joyful, which can't do this since it
|
||||||
|
has no game-state feedback).
|
||||||
|
- **Steam launch options wrapper script** - starts/stops joyful and
|
||||||
|
switch_sync around the actual game session via `%command%`, instead of
|
||||||
|
a Hyprland-IPC window-watcher (Proton WM_CLASS is unreliable for this).
|
||||||
|
NOTE: Steam launch options are per-machine (stored in
|
||||||
|
`userdata/<id>/config/localconfig.vdf`, not synced) - the wrapper
|
||||||
|
script path should be identical across machines (keep it in dotfiles),
|
||||||
|
but the launch-options string itself must be re-pasted into Steam on
|
||||||
|
each machine.
|
||||||
|
|
||||||
|
## Diagnostic tools (already written, in outputs/)
|
||||||
|
- `find_codes.py` - run this first on every physical control to get real
|
||||||
|
evdev BTN_*/ABS_* codes. Nothing below has real codes yet - they're
|
||||||
|
all `TODO:` placeholders until this is run.
|
||||||
|
- `switch_sync.py` - screen-reads HUD state via `grim` region capture +
|
||||||
|
color sampling, compares against physical switch position (evdev), and
|
||||||
|
taps a virtual key only when they disagree. Has `--sample X,Y` for
|
||||||
|
pixel-color calibration and `--dump-example-config`.
|
||||||
|
|
||||||
|
## Current mapping plan
|
||||||
|
|
||||||
|
| Physical control | SE function | Mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| Throttle (base) | Move Forward/Backward | joyful axis passthrough (was original bug: SE read raw HID axis like a trigger) |
|
||||||
|
| Main stick (grip) X/Y | View/look/rotate | joyful axis passthrough |
|
||||||
|
| Ministick (grip) | Strafe horizontal/vertical | joyful axis passthrough |
|
||||||
|
| Dual-position trigger, stage 1/2 | Primary/secondary click (use/fire, alt-fire) | joyful button passthrough |
|
||||||
|
| 2 orange buttons (top of stick) | Dampeners on/off (Z) + Relative dampers (Ctrl+Z) | joyful, KEY_Z / [KEY_LEFTCTRL, KEY_Z] key emulation (not gamepad buttons - Relative Dampers isn't confirmed as a standalone bindable action, it's a modifier on the toggle key) |
|
||||||
|
| Rotary knob 1 (base) | Switch toolbar (next/prev, default `,`/`.`) | joyful, detent pulses -> KEY_COMMA/KEY_DOT - ASSUMES knob reports as BTN pulses, not an ABS axis; confirm with find_codes.py |
|
||||||
|
| Rotary knob 2 (base) | Hotbar slot select | joyful, same pulse assumption as above; if it's an ABS axis instead, needs joyful's segmented axis-to-button feature |
|
||||||
|
| 4-button group (base, left side) | Jetpack / Helmet / Flashlight / Parking toggles | joyful button passthrough to virtual gamepad buttons, bound in SE's own control menu |
|
||||||
|
| 4 tilt switches (base, top), 2 buttons each | Same 4 toggles as above, but as REAL on/off switches | switch_sync.py - NOT joyful, because this needs game-state feedback to avoid desync |
|
||||||
|
|
||||||
|
## Known caveats / open items
|
||||||
|
- SE's native joystick support only got real per-binding customization in
|
||||||
|
update 1.209; before that (and possibly still, depending on version) it
|
||||||
|
treated joysticks through an Xbox-controller-shaped abstraction. Check
|
||||||
|
Options -> Controls -> Controllers on the current version before
|
||||||
|
assuming full flexibility.
|
||||||
|
- Dampeners are a stateless toggle (Z) + contextual modifier (Ctrl+Z,
|
||||||
|
only works if dampers are off AND you're looking at a moving target in
|
||||||
|
range). No true 3-state action exists in SE.
|
||||||
|
- `switch_sync.py`'s HUD detection is pixel-color based and fragile:
|
||||||
|
breaks on HUD detail mode changes (Tab), resolution/UI scale changes,
|
||||||
|
or camera view changes. Needs recalibration after any of those.
|
||||||
|
- joyful's exact per-rule-type YAML field names (beyond the top-level
|
||||||
|
devices/modes/rules structure and evdev keycode naming) were not fully
|
||||||
|
verified against `examples/ruletypes.yml` in the actual installed
|
||||||
|
version - check that file before trusting rule blocks verbatim.
|
||||||
|
- None of the joyful config blocks have real BTN_*/ABS_* codes yet - all
|
||||||
|
placeholders pending find_codes.py output on the actual hardware.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
1. Run find_codes.py against every physical control, replace all TODOs.
|
||||||
|
2. Confirm rotary knobs are pulse (BTN) vs continuous (ABS) - determines
|
||||||
|
which joyful rule type to use.
|
||||||
|
3. Verify SE's actual keybinds for helmet/flashlight/parking in Options
|
||||||
|
before finalizing switch_sync.py's toggle_key values.
|
||||||
|
4. Calibrate switch_sync.py's HUD regions/colors for jetpack, helmet,
|
||||||
|
light, parking indicators.
|
||||||
|
5. Write the Steam launch-options wrapper script, add to dotfiles repo.
|
||||||
|
6. Repeat launch-options paste on each machine (desktop, Framework 12,
|
||||||
|
ThinkPad T440p) - not synced by Steam.
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# switch_sync-config.yaml - switch_sync.py config for the Sol-R1's 4
|
||||||
|
# tilt switches (jetpack/helmet/flashlight/parking), kept in sync with
|
||||||
|
# actual in-game state instead of desyncing like a stateless toggle key.
|
||||||
|
#
|
||||||
|
# Every TODO placeholder here needs a real value before this is usable
|
||||||
|
# (see README "Setup" and mapping-brief.md "Next steps"):
|
||||||
|
# - input_on/input_off: run find_codes.py against the base to get
|
||||||
|
# real BTN_* codes for each switch's two held positions.
|
||||||
|
# - toggle_key: confirm the actual SE keybind in Options -> Controls
|
||||||
|
# -> Controllers first - do not assume a default.
|
||||||
|
# - region/on_color: run `switch_sync.py --sample X,Y` against the
|
||||||
|
# live HUD indicator to calibrate.
|
||||||
|
|
||||||
|
device_name: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
|
||||||
|
|
||||||
|
switches:
|
||||||
|
jetpack:
|
||||||
|
input_on: "TODO_BTN_JETPACK_UP"
|
||||||
|
input_off: "TODO_BTN_JETPACK_DOWN"
|
||||||
|
toggle_key: "TODO_KEY_JETPACK_TOGGLE"
|
||||||
|
region: [0, 0, 20, 20] # TODO: calibrate with --sample
|
||||||
|
on_color: [0, 0, 0] # TODO: calibrate with --sample
|
||||||
|
tolerance: 30
|
||||||
|
|
||||||
|
helmet:
|
||||||
|
input_on: "TODO_BTN_HELMET_UP"
|
||||||
|
input_off: "TODO_BTN_HELMET_DOWN"
|
||||||
|
toggle_key: "TODO_KEY_HELMET_TOGGLE"
|
||||||
|
region: [0, 0, 20, 20]
|
||||||
|
on_color: [0, 0, 0]
|
||||||
|
tolerance: 30
|
||||||
|
|
||||||
|
flashlight:
|
||||||
|
input_on: "TODO_BTN_FLASHLIGHT_UP"
|
||||||
|
input_off: "TODO_BTN_FLASHLIGHT_DOWN"
|
||||||
|
toggle_key: "TODO_KEY_FLASHLIGHT_TOGGLE"
|
||||||
|
region: [0, 0, 20, 20]
|
||||||
|
on_color: [0, 0, 0]
|
||||||
|
tolerance: 30
|
||||||
|
|
||||||
|
parking:
|
||||||
|
input_on: "TODO_BTN_PARKING_UP"
|
||||||
|
input_off: "TODO_BTN_PARKING_DOWN"
|
||||||
|
toggle_key: "TODO_KEY_PARKING_TOGGLE"
|
||||||
|
region: [0, 0, 20, 20]
|
||||||
|
on_color: [0, 0, 0]
|
||||||
|
tolerance: 30
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Template - adding a new game
|
||||||
|
|
||||||
|
Copy this whole folder to `../<game-name>/` and work through it top to
|
||||||
|
bottom.
|
||||||
|
|
||||||
|
1. **`joyful-config.yml`** - bare 1:1 passthrough (every physical
|
||||||
|
control mapped straight through, no remapping, no key emulation).
|
||||||
|
Run `find_codes.py` (repo root) against the hardware and fill in
|
||||||
|
every `TODO_*`. Get the game running with this first, so you have a
|
||||||
|
known-good baseline before customizing anything.
|
||||||
|
2. Customize the mapping for what the game actually needs: axis
|
||||||
|
remaps, key emulation for toggles, buttons bound in the game's own
|
||||||
|
control menu, etc. See `../space-engineers/` for a worked example
|
||||||
|
(including the "throttle read as a trigger" class of bug this
|
||||||
|
exists to fix).
|
||||||
|
3. **Do you need `input_converter_daemon.py` at all?** Only if some
|
||||||
|
physical control is a real on/off switch (not a momentary button)
|
||||||
|
*and* the game only exposes the equivalent action as a stateless
|
||||||
|
toggle key. If every control you're mapping is a stateless
|
||||||
|
passthrough, delete `input_converter_daemon.py`,
|
||||||
|
`TODO-daemon-config.yaml` (once created), and the daemon block in
|
||||||
|
`launch-wrapper.sh` - joyful alone is enough, and you're done after
|
||||||
|
step 2.
|
||||||
|
4. If you do need it: rename the example config
|
||||||
|
(`input_converter_daemon.py --dump-example-config > <game>-daemon-config.yaml`),
|
||||||
|
fill in real evdev codes (`find_codes.py`), confirm the game's
|
||||||
|
actual toggle keybinds in its own settings menu (never assume
|
||||||
|
defaults), and calibrate HUD regions/colors with
|
||||||
|
`input_converter_daemon.py --sample X,Y`. Point
|
||||||
|
`launch-wrapper.sh`'s `DAEMON_CONFIG` at it.
|
||||||
|
5. **`launch-wrapper.sh`** - set as the Steam launch options:
|
||||||
|
`/path/to/<game>/launch-wrapper.sh %command%`. Per-machine, not
|
||||||
|
synced by Steam - repeat on every machine.
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/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()
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# joyful-config.yml - TEMPLATE: bare passthrough config.
|
||||||
|
#
|
||||||
|
# Copy this into a new <game>/ folder and customize. As-is, every
|
||||||
|
# physical control is mapped straight through 1:1 (axis->axis,
|
||||||
|
# button->button), no remapping and no key emulation - i.e. the
|
||||||
|
# Sol-R1 behaves like a normal, unmodified joystick/gamepad. Start
|
||||||
|
# here, confirm the game plays sanely with stock behavior, then only
|
||||||
|
# add `key`-type rules or axis remaps for the specific controls that
|
||||||
|
# particular game actually needs changed (e.g. a throttle axis a game
|
||||||
|
# misreads as a trigger - see space-engineers/ for a worked example).
|
||||||
|
#
|
||||||
|
# UNVERIFIED: joyful's exact per-rule-type field names below were not
|
||||||
|
# checked against `examples/ruletypes.yml` in the installed version -
|
||||||
|
# check that file before trusting this.
|
||||||
|
#
|
||||||
|
# Every TODO_* is a placeholder. Run find_codes.py (repo root) against
|
||||||
|
# the actual hardware and replace every one before use.
|
||||||
|
|
||||||
|
devices:
|
||||||
|
base:
|
||||||
|
match: "TODO: Sol-R Base" # evdev device name substring, see find_codes.py --list
|
||||||
|
grip:
|
||||||
|
match: "TODO: Sol-R Grip"
|
||||||
|
|
||||||
|
modes:
|
||||||
|
default:
|
||||||
|
devices: [base, grip]
|
||||||
|
|
||||||
|
rules:
|
||||||
|
# --- base: throttle axis ---
|
||||||
|
- device: base
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_THROTTLE
|
||||||
|
output: ABS_Y
|
||||||
|
|
||||||
|
# --- grip: main stick + ministick axes ---
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_STICK_X
|
||||||
|
output: ABS_X
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_STICK_Y
|
||||||
|
output: ABS_Y
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_MINISTICK_X
|
||||||
|
output: ABS_Z
|
||||||
|
- device: grip
|
||||||
|
type: axis
|
||||||
|
input: TODO_ABS_MINISTICK_Y
|
||||||
|
output: ABS_RZ
|
||||||
|
|
||||||
|
# --- grip: trigger + orange buttons ---
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_TRIGGER_STAGE1
|
||||||
|
output: BTN_TRIGGER
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_TRIGGER_STAGE2
|
||||||
|
output: BTN_THUMB
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_ORANGE_1
|
||||||
|
output: BTN_THUMB2
|
||||||
|
- device: grip
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_ORANGE_2
|
||||||
|
output: BTN_TOP
|
||||||
|
|
||||||
|
# --- base: rotary knobs (assumed BTN detent pulses - confirm with
|
||||||
|
# find_codes.py; if either is an ABS axis instead, use joyful's
|
||||||
|
# segmented axis-to-button rule type there instead) ---
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_KNOB1_CW
|
||||||
|
output: BTN_TRIGGER_HAPPY1
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_KNOB1_CCW
|
||||||
|
output: BTN_TRIGGER_HAPPY2
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_KNOB2_CW
|
||||||
|
output: BTN_TRIGGER_HAPPY3
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_KNOB2_CCW
|
||||||
|
output: BTN_TRIGGER_HAPPY4
|
||||||
|
|
||||||
|
# --- base: 4-button group ---
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_1
|
||||||
|
output: BTN_TRIGGER_HAPPY5
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_2
|
||||||
|
output: BTN_TRIGGER_HAPPY6
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_3
|
||||||
|
output: BTN_TRIGGER_HAPPY7
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_GROUP_4
|
||||||
|
output: BTN_TRIGGER_HAPPY8
|
||||||
|
|
||||||
|
# --- base: 4 tilt switches, 2 held-position buttons each (8 codes) ---
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH1_UP
|
||||||
|
output: BTN_TRIGGER_HAPPY9
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH1_DOWN
|
||||||
|
output: BTN_TRIGGER_HAPPY10
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH2_UP
|
||||||
|
output: BTN_TRIGGER_HAPPY11
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH2_DOWN
|
||||||
|
output: BTN_TRIGGER_HAPPY12
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH3_UP
|
||||||
|
output: BTN_TRIGGER_HAPPY13
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH3_DOWN
|
||||||
|
output: BTN_TRIGGER_HAPPY14
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH4_UP
|
||||||
|
output: BTN_TRIGGER_HAPPY15
|
||||||
|
- device: base
|
||||||
|
type: button
|
||||||
|
input: TODO_BTN_SWITCH4_DOWN
|
||||||
|
output: BTN_TRIGGER_HAPPY16
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# launch-wrapper.sh - TEMPLATE Steam launch-options wrapper.
|
||||||
|
#
|
||||||
|
# Copy into a new <game>/ folder and customize. Always starts joyful;
|
||||||
|
# optionally starts a companion input-converter daemon if this game
|
||||||
|
# needs one (see input_converter_daemon.py and its README section on
|
||||||
|
# when a daemon is actually required vs. joyful alone being enough).
|
||||||
|
# Runs the game, cleans up on exit however it happens.
|
||||||
|
#
|
||||||
|
# Steam launch options: /path/to/<game>/launch-wrapper.sh %command%
|
||||||
|
# (per-machine, not synced by Steam - re-paste after cloning on a new
|
||||||
|
# machine; keeping the repo checked out at the same path everywhere
|
||||||
|
# keeps the launch-options string itself identical too.)
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||||
|
|
||||||
|
JOYFUL_CONFIG="$REPO_DIR/joyful-config.yml"
|
||||||
|
|
||||||
|
joyful --config "$JOYFUL_CONFIG" &
|
||||||
|
JOYFUL_PID=$!
|
||||||
|
|
||||||
|
# --- daemon: delete this whole block (and DAEMON_PID below) if this
|
||||||
|
# game doesn't need game-state feedback and joyful alone is enough ---
|
||||||
|
DAEMON_CONFIG="$REPO_DIR/TODO-daemon-config.yaml"
|
||||||
|
python3 "$REPO_DIR/input_converter_daemon.py" --config "$DAEMON_CONFIG" &
|
||||||
|
DAEMON_PID=$!
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# clean up even if this script gets killed/terminated, not just on normal exit
|
||||||
|
trap 'kill "$JOYFUL_PID" "$DAEMON_PID" 2>/dev/null' EXIT
|
||||||
|
|
||||||
|
# "$@" is the substituted real launch command (Proton + the game)
|
||||||
|
"$@"
|
||||||
Loading…
Reference in New Issue