Saturday, November 28, 2020

So you want to write a udev rule

I too once thought as you did.

It began with wanting to run a program every time a keyboard was plugged in. "That should be simple," I thought. "I'll just write a udev rule." And so it began.

But what should the udev rule trigger on? What "KERNEL"? What "SUBSYSTEM"? What even is a keyboard?

Like all great systemd mysteries, this one has an unsatisfying solution. Just plug in your keyboard and run

$ sudo udevadm info -a -n /dev/input/event19


  looking at device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1:1.0/0003:413C:2107.0009/input/input39/event19':

  looking at parent device '/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1:1.0/0003:413C:2107.0009/input/input39':
    ATTRS{capabilities/key}=="1000000000007 ff9f207ac14057ff febeffdfffefffff fffffffffffffffe"

(with many, many lines omitted for brevity)

Then guess which of those attributes makes a keyboard. Is it the ATTRS{capabilities/ev} The ATTRS{idProduct} No, it must be the ATTRS{bcdDevice}.

I'll spare you the 23 permutations I tried. In the end, there is nothing that identifies a keyboard.

(Well, that's not quite true. A USB keyboard can be identified by bInterfaceClass and bInterfaceSubClass. But my laptop has a builtin as well as USB keyboard, and who knows, maybe someday I'll have PS/2 keyboard for good measure. So I wanted a more accurate solution.)

You see, Linux input devices are very general things. What you humans call a "keyboard", Linux sees as an input device that happens to have a lot of keys. But it's not the only device with keys: your power button has a key. Your lid switch has a key or two. Your media buttons might have a few keys as well. The difference is your keyboard has a lot of keys.

Miraculously, putting a * in the KERNEL parameter is a valid wildcard, so the following rule matches any input device:

KERNEL=="event*", ACTION=="add", RUN+="/usr/bin/totalmapper remap --only-if-keyboard --dev-file %N"

Then it's up to the invoked program to decide whether the input device is keyboardy enough to qualify.

Ok, but how do you do that? Will it involve an obscure ioctl on the device file?

Mabye, but there's another way. In /proc/bus/input/devices is a list of your input devices along with a bitmask specifying which keys they have (the B: KEY= line):

I: Bus=0019 Vendor=0000 Product=0001 Version=0000
N: Name="Power Button"
P: Phys=PNP0C0C/button/input0
S: Sysfs=/devices/LNXSYSTM:00/LNXSYBUS:00/PNP0C0C:00/input/input0
U: Uniq=
H: Handlers=kbd event0 
B: EV=3
B: KEY=10000000000000 0

I: Bus=0011 Vendor=0001 Product=0001 Version=ab54
N: Name="AT Translated Set 2 keyboard"
P: Phys=isa0060/serio0/input0
S: Sysfs=/devices/platform/i8042/serio0/input/input2
U: Uniq=
H: Handlers=sysrq kbd event2 leds 
B: EV=120013
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe
B: MSC=10
B: LED=7

Count up the 1 bits, and then decide how many keys is enough to count as a keyboard (10 was enough to exclude my lid switch, but 5 was too few).

This worked great, until a new problem arose.

The invoked program was supposed to keep running so that it could keep remapping keys. But it would quickly die.

"Ok," I thought. "udev must not like blocking processes. I'll just fork() a child."

But the process kept dying.

Ah, of course! I need to signal(SIGHUP, SIG_IGN) to ignore the signal generated when the parent terminates.

But the process kept dying.

Oh, that's it! I need to redirect stdout and stderr so the process doesn't get a broken pipe!

But the process kept dying.

It was late at night when I stumbled across this beautiful sentence from the udev manpage:

Starting daemons or other long-running processes is not allowed; the forked processes, detached or not, will be unconditionally killed after the event handling has finished.


Well, that settles that. It's "not allowed." So what's the solution?

Helpfully, the same manpage suggests using the udev rule to start a systemd service. And it turns out it's possible to make a "template" systemd service using an '@' sign in the filename as a wildcard. So, I made the following udev rule:

KERNEL=="event*", ACTION=="add", TAG+="systemd", ENV{SYSTEMD_WANTS}="totalmapper@%N.service"

And the following service in /etc/systemd/system/totalmapper@.service:

[Unit] StopWhenUnneeded=true Description=Totalmapper [Service] Type=simple User=nobody Group=input ExecStart=/usr/bin/totalmapper remap --layout-file /etc/totalmapper.json --only-if-keyboard --dev-file %I

(The %I flag provides the template parameter, which in this case was the device file.)

It was at this point that my problems shifted from annoying to catastrophic. You see, the keyboard remapping utility works by creating a synthetic keyboard device through /dev/uinput that it can use to send key events. But udev picks up on that keyboard device, and starts a another systemd service for it, starting another keyboard mapper, and so on...

How many input devices can you have on Linux? I think the answer is 1000. At least, it stopped making new keyboad devices after that.

Ok. So. In addition to checking whether the device is a keyboard, the mapping tool needs to check whether it is a physical device or synthetic, and only remap it if it's a physical device. But how do you do that?

Well, back in our trusty /proc/bus/input/devices is a line S: Sysfs=, which gives the path to the device under /sys. Non-physical keyboards have a sysfs path that begins /devices/virtual.

And now it works.

Remapping keys

Keyboards are more complicated than they first appear.

For example, suppose I want to map Shift + A to =.

I press Shift. A Shift keycode should be generated (the mapping isn't triggered yet). I press A. Now:

  • The A keycode should not be generated—the mapping consumes it.
  • The = keycode should be generated, because the mapping produced it.

But wait—there's another step. Shift is still down, and Shift + = gives a '+' sign. So before the = keycode is generated, the Shift needs to be released.

Now I release the A key. The = keycode should be released. But the Shift (physical key) is still down. Should the Shift keycode be reinstated now that it's no longer consumed by the mapping?

No, that's (for some reason) not how keyboards behave. Only pressing a key can cause a new keycode to be pressed, even where key combinations are used.

Try it with your Fn key: Hold down Fn, then press F1, then release Fn. This does not trigger an F1 keycode.

After puzzling over this logic for awhile, I eventually created a tool that let's you remap keys using rules like the following:

  { "from": [ "CAPSLOCK" ], "to": [] },
  { "from": [ "CAPSLOCK", "J" ], "to": [ "LEFT" ] },
  { "from": [ "CAPSLOCK", "I" ], "to": [ "UP" ] },
  { "from": [ "CAPSLOCK", "K" ], "to": [ "DOWN" ] },
  { "from": [ "CAPSLOCK", "L" ], "to": [ "RIGHT" ] },
  { "from": [ "CAPSLOCK", "H" ], "to": [ "HOME" ] },
  { "from": [ "CAPSLOCK", "SEMICOLON" ], "to": [ "END" ] },
  { "from": [ "CAPSLOCK", "U" ], "to": [ "PAGEUP" ] },
  { "from": [ "CAPSLOCK", "M" ], "to": [ "PAGEDOWN" ] },
  { "from": [ "CAPSLOCK", "N" ], "to": [ "LEFTCTRL", "LEFT" ] },
  { "from": [ "CAPSLOCK", "COMMA" ], "to": [ "LEFTCTRL", "RIGHT" ] }

Don't you wish any key could be a modifier? Now it can.