Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 34 additions & 14 deletions joymapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/home/krid/bin/pyvirtenv/bin/python3
#!/usr/bin/env python3
#
# Copyright (C) 2022 Dirk Bergstrom <dirk@otisbean.com>. All Rights Reserved.
# Copyright (C) 2022-2024 Dirk Bergstrom <dirk@otisbean.com>. All Rights Reserved.
#
# Xlib code for keystroke generation originally by Joel Holveck <joelh@piquan.org>
#
Expand All @@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import errno
import functools
import select
import struct
Expand Down Expand Up @@ -44,6 +45,12 @@
# )
# }
#
# Where:
# <typ> is 1 (on/off button) or 2 (stick / trigger / throttle / etc)
# <number> varies between manufacturers; use "jstest /dev/input/js0"
# The numbers shown below work for most, but not all, gamepads.
# <value> is the value that triggers the behavior.
#
# Where <key spec> is one of:
#
# str => Emit this key (see keysymdef.h)
Expand Down Expand Up @@ -71,7 +78,10 @@
# * The joystick drivers supposedly support some level of auto-repeat. You
# need to set an ioctl to turn it on. Joel knows how...
#
# See /usr/include/X11/keysymdef.h for keycodes
# See /usr/include/X11/keysymdef.h (from x11proto-core-dev) for keycodes.
# There's also some multimedia keys in XF86keysym.h.
#
# FIXME: Read from a config file
MAPPING = {
(2, 8, 32767): Input('dpad-right', 'Right', 'Right'),
(2, 8, -32767): Input('dpad-left', 'Left', 'Left'),
Expand Down Expand Up @@ -99,12 +109,19 @@
}

# What device are we looking at?
CONTROLLER_DEVICE = '/dev/input/js0'
DEFAULT_CONTROLLER_DEVICE = '/dev/input/js0'

# Format of the packets we get from /dev/input/js0
# See below where we call .unpack
INPUT_STRUCT = struct.Struct('IhBB')


class Program:

@functools.lru_cache()
def __init__(self, controller):
self.controller = controller

@functools.cache()
def keysym2code(self, key):
rv = self.display.keysym_to_keycode(Xlib.XK.string_to_keysym(key))
if not rv: # I think it returns 0, but maybe None
Expand Down Expand Up @@ -141,17 +158,17 @@ def handle_x(self):
def handle_js(self):
evbuf = None
try:
evbuf = self.jsdev.read(8)
evbuf = self.jsdev.read(INPUT_STRUCT.size)
except OSError as ose:
if ose.errno == 19:
# 19 == No such device == disconnected
if ose.errno == errno.ENODEV:
# No such device == disconnected
pass
else:
raise
if not evbuf:
# In practice we only get here in the OSError case
# In practice we only get here in the ENODEV case
logging.info("Controller disconnected or unavailable at '%s'.",
CONTROLLER_DEVICE)
self.controller)
sys.exit(0)

# *** THIS IS WHERE WE DISPATCH JOYSTICK STUFF TO X STUFF ***
Expand All @@ -163,7 +180,7 @@ def handle_js(self):
# To see the names of X keysyms, see:
# X11/keysymdef.h
# The Python keysymdef modules are based on the preceding #ifdef.
_time, value, typ, number = struct.unpack('IhBB', evbuf)
_time, value, typ, number = INPUT_STRUCT.unpack(evbuf)
inp = MAPPING.get((typ, number, value))
if inp:
logging.info("Controller event %s => %s", inp.control, inp.desc)
Expand Down Expand Up @@ -193,8 +210,8 @@ def run(self):
if ext is None:
raise Exception("Cannot get XTEST extension")

self.jsdev = open(CONTROLLER_DEVICE, 'rb')
logging.info("Mapping inputs from %s", CONTROLLER_DEVICE)
self.jsdev = open(self.controller, 'rb')
logging.info("Mapping inputs from %s", self.controller)

while True:
(ready_to_read, _, _) = select.select(
Expand All @@ -209,10 +226,13 @@ def run(self):

if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--controller", action="store",
default=DEFAULT_CONTROLLER_DEVICE,
help="Listen to this input")
parser.add_argument("--debug", "-d", action="store_true",
help="Show debug info and unused controller inputs")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")

Program().run()
Program(controller=args.controller).run()
72 changes: 57 additions & 15 deletions midimapper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/home/krid/bin/pyvirtenv/bin/python3
#!/usr/bin/env python3
#
# Copyright (C) 2022-2024 Dirk Bergstrom <dirk@otisbean.com>. All Rights Reserved.
#
Expand Down Expand Up @@ -78,7 +78,8 @@
# key combo `Alt-3`, immediately followed by the `right-arrow` key, and print
# "Flag Green" to the console.
#
# See /usr/include/X11/keysymdef.h for keycodes
# See /usr/include/X11/keysymdef.h (from x11proto-core-dev) for keycodes.
# There's also some multimedia keys in XF86keysym.h.
NOTE_MAPPING = {
0: Button("push-controller-1", Action('Delete', 'Delete')),
8: Button("button-1", Action([('Meta_L', '3'), 'Right'], 'Flag Green')),
Expand Down Expand Up @@ -141,17 +142,16 @@
Action([('Control_L', 'Alt_L', 'E')], 'Fit to Window')),
}

# What device are we looking at?
CONTROLLER_DEVICE = "X-TOUCH MINI"


class Program:
"""The action all happens here"""

control_default = 64

def __init__(self, dry_run) -> None:
def __init__(self, dry_run, controller, client_name) -> None:
self.dry_run = dry_run
self.controller = controller
self.client_name = client_name
self.state = {}

def init_knobs(self):
Expand Down Expand Up @@ -357,13 +357,50 @@ def run(self):
raise Exception("Cannot get XTEST extension")

# Connect to the MIDI device.
# TODO This is kind of cargo-culted from the library's example code.
# I need to understand this stuff better.
self.client = SequencerClient(CONTROLLER_DEVICE)
self.port = self.client.create_port("inout")
self.port.connect_from(self.client.list_ports()[0])
self.port.connect_to(self.client.list_ports()[0])
logging.info("Mapping inputs from %s", CONTROLLER_DEVICE)
# TODO This is kind of cargo-culted from the library's example code,
# and tweaked a tad by Joel. I need to understand this stuff better.

# Create a client, that is, a connection to the ALSA MIDI system.
self.client = SequencerClient(self.client_name)

# Pick which remote port we want to talk to. Only select
# devices that can send and receive, and ignore MIDI THRU
# ports. The default flags already ignore system, no-export,
# and unconnectable ports.
available_ports = self.client.list_ports(input=True, output=True,
include_midi_through=False)

if len(available_ports) == 0:
logging.error("No MIDI ports available")
sys.exit(1)

if self.controller is None:
# The list is sorted so that the "most usable" ones come
# first.
controller_port = available_ports[0]
else:
for port in available_ports:
if self.controller in (port.name, port.client_name):
controller_port = port
break
else:
available_port_names = []
for port in available_ports:
available_port_names += [port.client_name, port.name]
logging.error("Specified controller %r is not available. "
"Choose from: %r",
self.controller, available_port_names)
sys.exit(1)

# I guess we should tell the user what's up.
logging.info("Mapping inputs from %s", controller_port.name)

# Create a port, something that can send and receive events.
self.port = self.client.create_port(f"{self.client_name} port")
# Set our port up to receive events from the controller...
self.port.connect_from(controller_port)
# ...and to send events to the controller.
self.port.connect_to(controller_port)

self.init_knobs()

Expand All @@ -376,7 +413,7 @@ def run(self):
# For now we gracefully exit.
# TODO Consider waiting for a reconnection?
logging.info("Controller '%s' disconnected or unavailable.",
CONTROLLER_DEVICE)
self.controller)
sys.exit(0)
elif event.type == NoteOnEvent.type:
# This is a button press; we trigger on the button release event.
Expand Down Expand Up @@ -409,8 +446,13 @@ def run(self):
help="Show debug info and unused controller inputs")
parser.add_argument("--dry-run", "-n", action="store_true",
help="Don't actually emit X keystrokes")
parser.add_argument("--client-name", action="store", default="midimapper",
help="Name to give our ALSA MIDI endpoint")
parser.add_argument("--controller", "-c", action="store",
help="MIDI controller name to listen to")
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format="%(asctime)s %(levelname)s: %(message)s")

Program(args.dry_run).run()
Program(args.dry_run, controller=args.controller,
client_name=args.client_name).run()
11 changes: 11 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# This is the One True Xlib; see https://stackoverflow.com/a/74716290
# It’s installed to the system Python on Debian and Ubuntu as
# python3-xlib, but in PyPi, that's also the name of a different
# library.
python-xlib~=0.33

# The ALSA MIDI library is used for midimapper.py; you can skip it for
# joymapper.py. You'll need ALSA installed, but it's very common. On
# Debian or Ubuntu, you can install ALSA as libasound2; you might also
# need libasound2-dev.
alsa_midi~=1.0.2