224 lines
7.9 KiB
Python
224 lines
7.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
|
||
|
|
|
||
|
|
Reads every attached game controller via evdev and mirrors it as a virtual
|
||
|
|
uinput KEYBOARD, so gamepad input works in every app — including cross-origin
|
||
|
|
iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events.
|
||
|
|
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
|
||
|
|
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
|
||
|
|
|
||
|
|
Mapping (standard pad):
|
||
|
|
D-pad / left stick -> arrow keys
|
||
|
|
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
|
||
|
|
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
|
||
|
|
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
|
||
|
|
Start -> Enter Select -> Escape
|
||
|
|
|
||
|
|
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way
|
||
|
|
one is play/pause and one is fullscreen, which is fine for a TV.
|
||
|
|
|
||
|
|
Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node.
|
||
|
|
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import fcntl
|
||
|
|
import os
|
||
|
|
import select
|
||
|
|
import struct
|
||
|
|
import time
|
||
|
|
|
||
|
|
# ---- kernel constants ------------------------------------------------------
|
||
|
|
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
|
||
|
|
SYN_REPORT = 0
|
||
|
|
|
||
|
|
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
|
||
|
|
KEY_LEFTSHIFT, KEY_F = 42, 33
|
||
|
|
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
|
||
|
|
|
||
|
|
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
|
||
|
|
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
|
||
|
|
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
|
||
|
|
|
||
|
|
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
|
||
|
|
|
||
|
|
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
|
||
|
|
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
|
||
|
|
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
|
||
|
|
|
||
|
|
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
|
||
|
|
|
||
|
|
BUTTON_MAP = {
|
||
|
|
BTN_SOUTH: (KEY_ENTER,),
|
||
|
|
BTN_EAST: (KEY_ESC,),
|
||
|
|
BTN_NORTH: (KEY_SPACE,),
|
||
|
|
BTN_WEST: (KEY_F,),
|
||
|
|
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
|
||
|
|
BTN_TR: (KEY_TAB,),
|
||
|
|
BTN_START: (KEY_ENTER,),
|
||
|
|
BTN_SELECT: (KEY_ESC,),
|
||
|
|
BTN_DPAD_UP: (KEY_UP,),
|
||
|
|
BTN_DPAD_DOWN: (KEY_DOWN,),
|
||
|
|
BTN_DPAD_LEFT: (KEY_LEFT,),
|
||
|
|
BTN_DPAD_RIGHT: (KEY_RIGHT,),
|
||
|
|
}
|
||
|
|
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
|
||
|
|
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
|
||
|
|
|
||
|
|
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
|
||
|
|
|
||
|
|
|
||
|
|
def is_gamepad(fd) -> bool:
|
||
|
|
buf = bytearray(96)
|
||
|
|
try:
|
||
|
|
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
|
||
|
|
except OSError:
|
||
|
|
return False
|
||
|
|
def has(code):
|
||
|
|
return bool(buf[code // 8] & (1 << (code % 8)))
|
||
|
|
return has(BTN_SOUTH) or has(BTN_START)
|
||
|
|
|
||
|
|
|
||
|
|
class VirtualKeyboard:
|
||
|
|
def __init__(self):
|
||
|
|
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
|
||
|
|
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
|
||
|
|
for key in EMITTED_KEYS:
|
||
|
|
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
|
||
|
|
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
|
||
|
|
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
|
||
|
|
name = b"Archipelago Gamepad Keys"
|
||
|
|
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
|
||
|
|
setup += b"\0" * (64 * 4 * 4)
|
||
|
|
os.write(self.fd, setup)
|
||
|
|
fcntl.ioctl(self.fd, UI_DEV_CREATE)
|
||
|
|
|
||
|
|
def _emit(self, etype, code, value):
|
||
|
|
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
|
||
|
|
|
||
|
|
def set_key(self, key, pressed):
|
||
|
|
self._emit(EV_KEY, key, 1 if pressed else 0)
|
||
|
|
self._emit(EV_SYN, SYN_REPORT, 0)
|
||
|
|
|
||
|
|
def chord(self, keys, pressed):
|
||
|
|
seq = keys if pressed else tuple(reversed(keys))
|
||
|
|
for k in seq:
|
||
|
|
self._emit(EV_KEY, k, 1 if pressed else 0)
|
||
|
|
self._emit(EV_SYN, SYN_REPORT, 0)
|
||
|
|
|
||
|
|
|
||
|
|
class PadState:
|
||
|
|
"""Per-device axis state → synthetic arrow presses."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
|
||
|
|
self.abs_range = {} # axis -> (min, max) for sticks
|
||
|
|
|
||
|
|
def arrow_for(self, axis, value):
|
||
|
|
if axis in (ABS_HAT0X, ABS_HAT0Y):
|
||
|
|
if value < 0:
|
||
|
|
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
|
||
|
|
if value > 0:
|
||
|
|
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
|
||
|
|
return None
|
||
|
|
lo, hi = self.abs_range.get(axis, (-32768, 32767))
|
||
|
|
span = (hi - lo) or 1
|
||
|
|
norm = (2 * (value - lo) / span) - 1
|
||
|
|
if norm <= -STICK_THRESHOLD:
|
||
|
|
return KEY_LEFT if axis == ABS_X else KEY_UP
|
||
|
|
if norm >= STICK_THRESHOLD:
|
||
|
|
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def stick_range(fd, axis):
|
||
|
|
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
|
||
|
|
buf = bytearray(24)
|
||
|
|
try:
|
||
|
|
fcntl.ioctl(fd, 0x80184540 + axis, buf)
|
||
|
|
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
|
||
|
|
if hi > lo:
|
||
|
|
return (lo, hi)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
return (-32768, 32767)
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
os.system("modprobe uinput 2>/dev/null")
|
||
|
|
kbd = VirtualKeyboard()
|
||
|
|
pads = {} # path -> (fd, PadState)
|
||
|
|
last_scan = 0.0
|
||
|
|
|
||
|
|
while True:
|
||
|
|
now = time.monotonic()
|
||
|
|
if now - last_scan > 5:
|
||
|
|
last_scan = now
|
||
|
|
try:
|
||
|
|
names = sorted(os.listdir("/dev/input"))
|
||
|
|
except FileNotFoundError:
|
||
|
|
names = []
|
||
|
|
for name in names:
|
||
|
|
if not name.startswith("event"):
|
||
|
|
continue
|
||
|
|
path = "/dev/input/" + name
|
||
|
|
if path in pads:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
if is_gamepad(fd):
|
||
|
|
state = PadState()
|
||
|
|
for axis in (ABS_X, ABS_Y):
|
||
|
|
state.abs_range[axis] = stick_range(fd, axis)
|
||
|
|
pads[path] = (fd, state)
|
||
|
|
print(f"gamepad attached: {path}", flush=True)
|
||
|
|
else:
|
||
|
|
os.close(fd)
|
||
|
|
|
||
|
|
if not pads:
|
||
|
|
time.sleep(2)
|
||
|
|
continue
|
||
|
|
|
||
|
|
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
|
||
|
|
gone = []
|
||
|
|
for path, (fd, state) in list(pads.items()):
|
||
|
|
if fd not in readable:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
data = os.read(fd, INPUT_EVENT.size * 64)
|
||
|
|
except OSError:
|
||
|
|
gone.append(path)
|
||
|
|
continue
|
||
|
|
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
|
||
|
|
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
|
||
|
|
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
|
||
|
|
keys = BUTTON_MAP[code]
|
||
|
|
if len(keys) == 1:
|
||
|
|
kbd.set_key(keys[0], value == 1)
|
||
|
|
else:
|
||
|
|
kbd.chord(keys, value == 1)
|
||
|
|
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
|
||
|
|
want = state.arrow_for(code, value)
|
||
|
|
held = state.axis_keys.get(code)
|
||
|
|
if want != held:
|
||
|
|
if held is not None:
|
||
|
|
kbd.set_key(held, False)
|
||
|
|
if want is not None:
|
||
|
|
kbd.set_key(want, True)
|
||
|
|
state.axis_keys[code] = want
|
||
|
|
for path in gone:
|
||
|
|
fd, state = pads.pop(path)
|
||
|
|
for held in state.axis_keys.values():
|
||
|
|
if held is not None:
|
||
|
|
kbd.set_key(held, False)
|
||
|
|
try:
|
||
|
|
os.close(fd)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
print(f"gamepad detached: {path}", flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|