From d695ac7050b9fb9ac0677d7fdfdfd2f1779fa7bd Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 19 Aug 2024 02:55:11 -0700 Subject: [PATCH 1/4] Minor tweaks to make release-ready. Update copyright. Set the interpreter to use $PATH. Add requirements.txt. Add comments about where to get the correct mapping values. --- joymapper.py | 13 ++++++++++--- midimapper.py | 5 +++-- requirements.txt | 11 +++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 requirements.txt diff --git a/joymapper.py b/joymapper.py index bb2fb8c..d3585aa 100755 --- a/joymapper.py +++ b/joymapper.py @@ -1,6 +1,6 @@ -#!/home/krid/bin/pyvirtenv/bin/python3 +#!/usr/bin/env python3 # -# Copyright (C) 2022 Dirk Bergstrom . All Rights Reserved. +# Copyright (C) 2022-2024 Dirk Bergstrom . All Rights Reserved. # # Xlib code for keystroke generation originally by Joel Holveck # @@ -44,6 +44,12 @@ # ) # } # +# Where: +# is 1 (on/off button) or 2 (stick / trigger / throttle / etc) +# varies between manufacturers; use "jstest /dev/input/js0" +# The numbers shown below work for most, but not all, gamepads. +# is the value that triggers the behavior. +# # Where is one of: # # str => Emit this key (see keysymdef.h) @@ -71,7 +77,8 @@ # * 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. MAPPING = { (2, 8, 32767): Input('dpad-right', 'Right', 'Right'), (2, 8, -32767): Input('dpad-left', 'Left', 'Left'), diff --git a/midimapper.py b/midimapper.py index 4caab99..3ef3f14 100755 --- a/midimapper.py +++ b/midimapper.py @@ -1,4 +1,4 @@ -#!/home/krid/bin/pyvirtenv/bin/python3 +#!/usr/bin/env python3 # # Copyright (C) 2022-2024 Dirk Bergstrom . All Rights Reserved. # @@ -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')), diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8670f4f --- /dev/null +++ b/requirements.txt @@ -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 From 549b536032c4a57f89fbd8f90da79a0545ff49f1 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 19 Aug 2024 02:59:44 -0700 Subject: [PATCH 2/4] Make a few things easier to read. Use errno.ENODEV instead of hardcoded 19. Use a struct.Struct object instead of an inline format and a hardcoded size that are 50 lines apart. May as well use functools.cache instead of functools.lru_cache on somewhat more modern Python. --- joymapper.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/joymapper.py b/joymapper.py index d3585aa..1d794b4 100755 --- a/joymapper.py +++ b/joymapper.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import errno import functools import select import struct @@ -108,10 +109,14 @@ # What device are we looking at? 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() + @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 @@ -148,15 +153,15 @@ 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) sys.exit(0) @@ -170,7 +175,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) From 51b3510acbbfc0c7f170d7d782d8bc4cbc971d55 Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 19 Aug 2024 03:02:05 -0700 Subject: [PATCH 3/4] Make more configurable. In joymapper, let the user specify a different controller device than /dev/input/js0. In midimapper, let the user specify the name of the MIDI controller they want to communicate with. Clarify the port-wrangling code. As before, we still default to the first device, which is what ALSA (at least, the alsa_midi library) considers the most usable. I don't happen to have the controller that Dirk uses, so I haven't tested all of that code yet. --- joymapper.py | 18 ++++++++++---- midimapper.py | 67 +++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/joymapper.py b/joymapper.py index 1d794b4..9caa429 100755 --- a/joymapper.py +++ b/joymapper.py @@ -80,6 +80,8 @@ # # 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'), @@ -107,7 +109,7 @@ } # 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 @@ -116,6 +118,9 @@ class Program: + 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)) @@ -163,7 +168,7 @@ def handle_js(self): if not evbuf: # 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 *** @@ -205,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( @@ -221,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() diff --git a/midimapper.py b/midimapper.py index 3ef3f14..7a91497 100755 --- a/midimapper.py +++ b/midimapper.py @@ -142,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): @@ -358,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() @@ -377,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. @@ -410,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() From 1ebe149a9ac3a4cd00ad6d2748b62abf1f9e8a8b Mon Sep 17 00:00:00 2001 From: Joel Ray Holveck Date: Mon, 19 Aug 2024 03:12:04 -0700 Subject: [PATCH 4/4] Fix a testing-only comment-out I left in --- midimapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/midimapper.py b/midimapper.py index 7a91497..a16e320 100755 --- a/midimapper.py +++ b/midimapper.py @@ -367,7 +367,7 @@ def run(self): # 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, + available_ports = self.client.list_ports(input=True, output=True, include_midi_through=False) if len(available_ports) == 0: