Last active
March 16, 2026 03:33
-
-
Save rumly111/e8cc616088ef8e0a83387b6bb15e673f to your computer and use it in GitHub Desktop.
Convert joystick to keyboard events. I made it for roguelikes. Infra Arcana bindings.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| PyJoy2Key : translate joystick input to keyboard events. | |
| Author : Joseph Botosh <[email protected]> | |
| License: GPL | |
| Requirements: pygame or pygame_sdl2; xlib, uinput | |
| Optimized for XBox360 controller, with roguelike games in mind. | |
| Hint: if the keypresses come too fast, try the command | |
| xset r rate 400 10 | |
| """ | |
| try: | |
| import pygame | |
| except ImportError: | |
| import pygame_sdl2 | |
| pygame_sdl2.import_as_pygame() | |
| import pygame | |
| import atexit | |
| import logging | |
| import os | |
| import string | |
| import sys | |
| from collections import deque | |
| # config for XBOX360 controller | |
| STICKS = [(0, 1), (3, 4)] | |
| TRIGGERS = (2, 5) | |
| BUTTONS = ('A', 'B', 'X', 'Y', 'LBUMPER', 'RBUMPER', 'BACK', 'START', | |
| 'GUIDE', 'LSTICK', 'RSTICK') | |
| BUTTON_MAPPINGS = {'A': 'enter', 'B': 'escape', 'X': 'tab', 'Y': 'k', | |
| 'START': 'a', 'BACK': 'm', | |
| 'LBUMPER': 'x', 'RBUMPER': 'f', | |
| 'LSTICK': 'period', 'RSTICK': 'C', | |
| 'GUIDE': 'i'} | |
| TRIGGER_MAPPINGS = ('z', 'r') | |
| HAT_MAPPINGS = ('up', 'right', 'down', 'left') | |
| STICK_MAPPINGS = [None, None] | |
| STICK_MAPPINGS[0] = (('kp7', 'up', 'kp9'), | |
| ('left', 'period', 'right'), | |
| ('kp1', 'down', 'kp3')) | |
| STICK_MAPPINGS[1] = (('c', 'g', 't'), | |
| ('M', 'C', 'v'), | |
| ('n', 'd', 'u')) | |
| # STICK_MAPPINGS[0] = (('Home', 'Up', 'Page_Up'), | |
| # ('Left', '', 'Right'), | |
| # ('End', 'Down', 'Page_Down')) | |
| # for internal use, better not mess with it | |
| STICK_MAPPINGS_INTERNAL = [[''] * 16, [''] * 16] | |
| # left stick | |
| STICK_MAPPINGS_INTERNAL[0][0b0001] = STICK_MAPPINGS[0][0][1] # UP | |
| STICK_MAPPINGS_INTERNAL[0][0b0010] = STICK_MAPPINGS[0][1][2] # RIGHT | |
| STICK_MAPPINGS_INTERNAL[0][0b0100] = STICK_MAPPINGS[0][2][1] # DOWN | |
| STICK_MAPPINGS_INTERNAL[0][0b1000] = STICK_MAPPINGS[0][1][0] # LEFT | |
| STICK_MAPPINGS_INTERNAL[0][0b0011] = STICK_MAPPINGS[0][0][2] # UP+RIGHT | |
| STICK_MAPPINGS_INTERNAL[0][0b0110] = STICK_MAPPINGS[0][2][2] # DOWN+RIGHT | |
| STICK_MAPPINGS_INTERNAL[0][0b1100] = STICK_MAPPINGS[0][2][0] # DOWN+LEFT | |
| STICK_MAPPINGS_INTERNAL[0][0b1001] = STICK_MAPPINGS[0][0][0] # UP+LEFT | |
| # right stick | |
| STICK_MAPPINGS_INTERNAL[1][0b0001] = STICK_MAPPINGS[1][0][1] # UP | |
| STICK_MAPPINGS_INTERNAL[1][0b0010] = STICK_MAPPINGS[1][1][2] # RIGHT | |
| STICK_MAPPINGS_INTERNAL[1][0b0100] = STICK_MAPPINGS[1][2][1] # DOWN | |
| STICK_MAPPINGS_INTERNAL[1][0b1000] = STICK_MAPPINGS[1][1][0] # LEFT | |
| STICK_MAPPINGS_INTERNAL[1][0b0011] = STICK_MAPPINGS[1][0][2] # UP+RIGHT | |
| STICK_MAPPINGS_INTERNAL[1][0b0110] = STICK_MAPPINGS[1][2][2] # DOWN+RIGHT | |
| STICK_MAPPINGS_INTERNAL[1][0b1100] = STICK_MAPPINGS[1][2][0] # DOWN+LEFT | |
| STICK_MAPPINGS_INTERNAL[1][0b1001] = STICK_MAPPINGS[1][0][0] # UP+LEFT | |
| # input_backend = "uinput" # "x11" | |
| input_backend = "x11" # "x11" | |
| uinput_device = None | |
| if input_backend == "uinput": | |
| import uinput | |
| else: | |
| from Xlib import XK, X | |
| from Xlib.display import Display | |
| from Xlib.ext.xtest import fake_input | |
| def init(): | |
| loglevel = logging.INFO | |
| if sys.argv[-1] in ['-d', '--debug']: | |
| loglevel = logging.DEBUG | |
| logging.basicConfig(format="[%(levelname)s][%(asctime)s.%(msecs)03d] %(message)s", | |
| level=loglevel, datefmt="%H:%M:%S") | |
| logging.debug('init') | |
| pygame.init() | |
| pygame.joystick.init() | |
| def quit(): | |
| logging.debug('quitting') | |
| pygame.joystick.quit() | |
| uinput_device.destroy() | |
| def show_joystick_info(): | |
| if pygame.joystick.get_init(): | |
| logging.info('Available joysticks:') | |
| joysticks = [pygame.joystick.Joystick(i) for i in range(pygame.joystick.get_count())] | |
| for i, joy in enumerate(joysticks): | |
| joy.init() | |
| jid = joy.get_id() | |
| logging.info(f'Joystick{jid}.name={joy.get_name()}') | |
| logging.info(f'Joystick{jid}.numbuttons={joy.get_numbuttons()}') | |
| logging.info(f'Joystick{jid}.numaxes={joy.get_numaxes()}') | |
| logging.info(f'Joystick{jid}.numhats={joy.get_numhats()}') | |
| x11_event_map = { | |
| "control": 'Control_L', | |
| "shift": 'Shift_L', | |
| "up": 'Up', | |
| "down": 'Down', | |
| "left": 'Left', | |
| "right": 'Right', | |
| "enter": 'Return', | |
| "kp0": 'KP_0', | |
| "kp1": 'KP_1', | |
| "kp2": 'KP_2', | |
| "kp3": 'KP_3', | |
| "kp4": 'KP_4', | |
| "kp5": 'KP_5', | |
| "kp6": 'KP_6', | |
| "kp7": 'KP_7', | |
| "kp8": 'KP_8', | |
| "kp9": 'KP_9', | |
| } | |
| def send_keyboard_event_x11(disp, keys, pressed, mods=()): | |
| """ | |
| Simulate keyboard event. | |
| key_name can be simple name like "a", "C", "Page_Up", "Return" etc, | |
| or it can be something like "Shift_L+A", "Control_L+Alt_L+Delete". | |
| If the corresponding key_name is single-character and uppercase, | |
| pressing `shift` is simulated as well. | |
| mods is a list of names like "Control_L", "Shift_L", "Alt_R" etc | |
| pressed=True for X.KeyPress, X.KeyRelease otherwise | |
| disp: display | |
| """ | |
| if not keys: | |
| return | |
| def keycode(name: str) -> int: | |
| name = x11_event_map.get(name, name) | |
| if len(name) > 1 and '_' not in name: | |
| name = name.capitalize() | |
| sym = XK.string_to_keysym(name) | |
| code = disp.keysym_to_keycode(sym) | |
| return code | |
| mods = set(mods) | |
| *_mods, keys = keys.split('+') | |
| mods.update(_mods) | |
| if len(keys) == 1 and keys.isupper(): | |
| mods.add('Shift_L') | |
| ev_name = 'KeyPress' if pressed else 'KeyRelease' | |
| logging.info(f'KBD: {ev_name} {keys=} mods={tuple(mods)}') | |
| xev = X.KeyPress if pressed else X.KeyRelease | |
| root = disp.get_input_focus().focus | |
| for mod in mods: | |
| fake_input(disp, xev, keycode(mod), root=root) | |
| fake_input(disp, xev, keycode(keys), root=root) | |
| disp.sync() | |
| uinput_event_map = {} | |
| if input_backend == "uinput": | |
| uinput_event_map = { | |
| "control": uinput.KEY_LEFTCTRL, | |
| "shift": uinput.KEY_LEFTSHIFT, | |
| "alt": uinput.KEY_LEFTALT, | |
| "tab": uinput.KEY_TAB, | |
| "enter": uinput.KEY_ENTER, | |
| "return": uinput.KEY_ENTER, | |
| "escape": uinput.KEY_ESC, | |
| "esc": uinput.KEY_ESC, | |
| "space": uinput.KEY_SPACE, | |
| "comma": uinput.KEY_COMMA, | |
| "dot": uinput.KEY_DOT, | |
| "period": uinput.KEY_DOT, | |
| "slash": uinput.KEY_SLASH, | |
| "kp0": uinput.KEY_KP0, | |
| "kp1": uinput.KEY_KP1, | |
| "kp2": uinput.KEY_KP2, | |
| "kp3": uinput.KEY_KP3, | |
| "kp4": uinput.KEY_KP4, | |
| "kp5": uinput.KEY_KP5, | |
| "kp6": uinput.KEY_KP6, | |
| "kp7": uinput.KEY_KP7, | |
| "kp8": uinput.KEY_KP8, | |
| "kp9": uinput.KEY_KP9, | |
| "up": uinput.KEY_UP, | |
| "down": uinput.KEY_DOWN, | |
| "left": uinput.KEY_LEFT, | |
| "right": uinput.KEY_RIGHT, | |
| } | |
| for ch in string.ascii_lowercase + string.digits: | |
| uinput_event_map[ch] = uinput._CHAR_MAP[ch] | |
| def send_keyboard_event_uinput(device, keys: str, pressed: int|bool, mods=()): | |
| if not keys: | |
| return | |
| mods = set(mods) | |
| *_mods, keys = keys.split('+') | |
| mods.update(_mods) | |
| if len(keys) == 1 and keys.isupper(): | |
| mods.add('shift') | |
| keys = keys.lower() | |
| def event(key): | |
| return uinput_event_map.get(key, uinput.KEY_RESERVED) | |
| if type(pressed) is bool: | |
| pressed = int(pressed) | |
| if mods: | |
| match pressed: | |
| case 1: | |
| for mod in mods: | |
| device.emit(event(mod), pressed, syn=False) | |
| device.emit(event(keys), pressed) | |
| case 0: | |
| device.emit(event(keys), pressed, syn=False) | |
| for i, mod in enumerate(mods): | |
| device.emit(event(mod), 0, syn=(i+1==len(mods))) | |
| case _: | |
| events = [event(mod) for mod in mods] + [event(keys)] | |
| device.emit_combo(events) | |
| else: | |
| match pressed: | |
| case 0|1: | |
| device.emit(event(keys), pressed) | |
| case _: | |
| device.emit_click(event(keys)) | |
| ev = {1:'Press', 0:'Release'}.get(pressed, 'PressAndRelease') | |
| logging.info(f'KBD: {ev} {keys=} mods={tuple(mods)}') | |
| def send_keyboard_event(d, keys, pressed, mods=()): | |
| match input_backend: | |
| case "x11": | |
| send_keyboard_event_x11(d, keys, pressed, mods) | |
| case "uinput": | |
| send_keyboard_event_uinput(d, keys, pressed, mods) | |
| def event_loop(): | |
| clock = pygame.time.Clock() | |
| d = None | |
| if input_backend == "x11": | |
| d = Display(os.getenv('DISPLAY')) | |
| elif input_backend == "uinput": | |
| uinput_fd = uinput.fdopen() | |
| if os.getuid() == 0: | |
| print("dropping privileges") | |
| os.setgroups([]) | |
| os.setgid(1000) | |
| os.setuid(1000) | |
| os.umask(0o77) | |
| d = uinput.Device(uinput_event_map.values(), fd=uinput_fd) | |
| global uinput_device | |
| uinput_device = d | |
| triggers_state = [False, False] | |
| trigger_threshold = 0.7 | |
| hat_state = 0 | |
| sticks_state = [0, 0] | |
| sticks_threshold = 0.55 | |
| error_margin = 0.15 | |
| joystick = pygame.joystick.Joystick(0) | |
| joystick.init() | |
| axis_cache = [0] * joystick.get_numaxes() | |
| SCHEDULER = pygame.USEREVENT+1 | |
| pygame.time.set_timer(SCHEDULER, 50) | |
| sticks_event_queues = [deque(maxlen=3), deque(maxlen=3)] | |
| sticks_delay = 50 | |
| done = False | |
| while not done: | |
| dt = clock.tick(60) | |
| for event in pygame.event.get(): | |
| # print(f'{event=}') | |
| if event.type == pygame.QUIT: | |
| print('QUIT') | |
| done = True | |
| elif event.type == SCHEDULER: | |
| # print(f'SCHEDULER {pygame.time.get_ticks()=}') | |
| ticks = pygame.time.get_ticks() | |
| for queue in sticks_event_queues: | |
| if queue and queue[0]['tick'] <= ticks: | |
| task = queue.popleft() | |
| key_name = task['key'] | |
| pressed = task['pressed'] | |
| send_keyboard_event(d, key_name, pressed) | |
| elif event.type in (pygame.JOYBUTTONDOWN, pygame.JOYBUTTONUP): | |
| # print(f'JOYBUTTON??[{BUTTONS[event.button]}]') | |
| bname = BUTTONS[event.button] | |
| if bname in BUTTON_MAPPINGS: | |
| key_name = BUTTON_MAPPINGS[bname] | |
| pressed = event.type == pygame.JOYBUTTONDOWN | |
| send_keyboard_event(d, key_name, pressed) | |
| elif event.type == pygame.JOYHATMOTION: | |
| # print(f'JOYHATMOTION[{event.hat}] = {event.value}') | |
| def val2bits(value): | |
| horz, vert = value | |
| result = 0b0000 | |
| match vert: | |
| case 1: | |
| result |= 0b0001 | |
| case -1: | |
| result |= 0b0100 | |
| match horz: | |
| case 1: | |
| result |= 0b0010 | |
| case -1: | |
| result |= 0b1000 | |
| return result | |
| value = val2bits(event.value) | |
| changes = hat_state ^ value | |
| hat_state = value | |
| for i, key_name in enumerate(HAT_MAPPINGS): | |
| if (changes >> i) & 0b0001: | |
| pressed = bool((hat_state >> i) & 0b0001) | |
| send_keyboard_event(d, key_name, pressed) | |
| elif event.type == pygame.JOYAXISMOTION: | |
| # print(f'JOYAXISMOTION[{event.axis}] = {event.value}') | |
| axis, value = event.axis, event.value | |
| axis_cache[axis] = value | |
| ticks = pygame.time.get_ticks() | |
| if axis in TRIGGERS: | |
| idx = TRIGGERS.index(axis) | |
| pressed = triggers_state[idx] | |
| key_name = TRIGGER_MAPPINGS[idx] | |
| if not pressed and value > trigger_threshold + error_margin: | |
| triggers_state[idx] = True | |
| send_keyboard_event(d, key_name, True) | |
| elif pressed and value < trigger_threshold - error_margin: | |
| triggers_state[idx] = False | |
| send_keyboard_event(d, key_name, False) | |
| for idx, st in enumerate(STICKS): | |
| if axis in st: | |
| horiz = axis == st[0] | |
| vert = axis == st[1] | |
| old_state = sticks_state[idx] | |
| new_state = old_state | |
| queue = sticks_event_queues[idx] | |
| if horiz: | |
| if not bool(old_state & 0b0010) and value > sticks_threshold+error_margin: | |
| new_state |= 0b0010 # turn on bit __x_ | |
| elif bool(old_state & 0b0010) and value < sticks_threshold-error_margin: | |
| new_state &= 0b1101 # turn off bit __x_ | |
| elif not bool(old_state & 0b1000) and value < -sticks_threshold-error_margin: | |
| new_state |= 0b1000 # turn on bit x___ | |
| elif bool(old_state & 0b1000) and value > -sticks_threshold+error_margin: | |
| new_state &= 0b0111 # turn off bit x___ | |
| if vert: | |
| if not bool(old_state & 0b0100) and value > sticks_threshold+error_margin: | |
| new_state |= 0b0100 # turn on bit _x__ | |
| elif bool(old_state & 0b0100) and value < sticks_threshold-error_margin: | |
| new_state &= 0b1011 # turn off bit _x__ | |
| elif not bool(old_state & 0b0001) and value < -sticks_threshold-error_margin: | |
| new_state |= 0b0001 # turn on bit ___x | |
| elif bool(old_state & 0b0001) and value > -sticks_threshold+error_margin: | |
| new_state &= 0b1110 # turn off bit ___x | |
| sticks_state[idx] = new_state | |
| if old_state != new_state: | |
| old_key_name = STICK_MAPPINGS_INTERNAL[idx][old_state] | |
| new_key_name = STICK_MAPPINGS_INTERNAL[idx][new_state] | |
| need_append_queue = True | |
| if queue: | |
| q_state = queue[-1]['state'] | |
| q_pressed = queue[-1]['pressed'] | |
| # note: LURD = Left|Up|Right|Down | |
| # detect center -> LURD -> diagonal transition | |
| if q_pressed and new_state in (0b0011, 0b0110, 0b1100, 0b1001): | |
| queue[-1]['key'] = new_key_name | |
| queue[-1]['state'] = new_state | |
| need_append_queue = False | |
| # detect diagonal -> LURD -> center transition | |
| elif q_pressed and not bool(new_state) and len(queue) > 1: | |
| queue.pop() | |
| need_append_queue = False | |
| if need_append_queue: | |
| if old_key_name: | |
| queue.append({'tick': ticks+sticks_delay, 'key': old_key_name, | |
| 'pressed': False, 'state': old_state}) | |
| if new_key_name: | |
| queue.append({'tick': ticks+sticks_delay, 'key': new_key_name, | |
| 'pressed': True, 'state': new_state}) | |
| logging.debug(f'h:{axis_cache[st[0]]:8.05f},v:{axis_cache[st[1]]:8.05f} ' | |
| f'ns:{new_state:04b} os:{old_state:04b} q:{queue}') | |
| def main(): | |
| atexit.register(quit) | |
| init() | |
| show_joystick_info() | |
| event_loop() | |
| quit() | |
| if __name__ == '__main__': | |
| main() |
Author
Author
the new default key binding work with "Infra Arcana" :D
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
added uinput support. tried to keep xlib working, hopefully it still does.
in theory if /dev/uinput has permission issues, you can run the script with "sudo", and it automatically lowers it's uid to 'nobody' after opening /dev/uinput, didn't test it.