Skip to content

Instantly share code, notes, and snippets.

@rumly111
Last active March 16, 2026 03:33
Show Gist options
  • Select an option

  • Save rumly111/e8cc616088ef8e0a83387b6bb15e673f to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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()
@rumly111
Copy link
Copy Markdown
Author

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.

@rumly111
Copy link
Copy Markdown
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