This post is about getting a program that runs in Node.js to react to input directly from a device such as a keyboard or remote control (and it’s probably applicable to a mouse as well) on a GNU/Linux operating system.
Device handles
When a program gets input from a keyboard, it’s often through something like readline that gets a whole chunk of text at a time, assembled by the operating system or some other lower software level. But sometimes you want to react immediately when some key is pressed or released, or maybe you want to react on keypresses on an infrared remote control. In both cases, what you need is to read from something that looks like a file, but is actually a device handle, located under /dev/input in the filesystem.
The first thing to figure out is which of those “files” corresponds to your device. A device usually gets the same number each time you connect it to particular computer (at least if you connect devices in the same order). For example, my numeric keypad with a USB chord becomes /dev/input0 when I plug it into my Raspberry Pi running Raspbian, and /dev/input13 on my Intel NUC with Ubuntu. The handle may turn up in /dev/input when you connect the device (so then it’s obvious which one it is), but some handles are there already, before the devices are connected. Some devices also show up by-id with symlinks to the device handle, which also make them easy to identify. For instance, this is one way that ls -l shows my numeric keypad on the Pi:
lrwxrwxrwx 1 root root 9 jul 7 14:28 /dev/input/by-id/usb-05a4_USB_Compliant_Keypad-event-kbd -> ../event0
If you’re like me, your first instinct is to cat /dev/input/event0 to see if you can read the “file” and what it contains. You might get a permission denied, which happened to me as the klipspringer user on the Ubuntu machine. I fixed that by adding the klipspringer user to the input group with sudo usermod -a -G input klipspringer.
When I cat the numeric keypad device handle, nothing shows up at first, but it’s not like it’s an empty file. The terminal stays in cat, waiting for something to appear. And when I press a key, something immediately bursts out, like this:
pi@becky:~ $ cat /dev/input/event0
???`?_
S???`?_
E???`?_
b???`?_
R???`?_
???`?~
???`?~
S???`?~
E???`?~
???`??
b???`??
R???`??
S???`??
E???`??
E???`8 ???`8
So what we need to do to react to keypresses is to read from that “file” in our program, and correctly interpret what shows up.
Input event records
Somewhere on your system, probably in /usr/include/linux/, you should have a file input.h, which contains:
struct input_event { struct timeval time; __u16 type; __u16 code; __s32 value; };
A struct is the primitive C equivalent of an object, and what shows up at the device handle should be interpreted as a sequence of struct input_event records. As you can see, each event has a timestamp, one 16-bit unsigned integer called type, one 16-bit unsigned code, and one 32-bit signed value. If we were coding in C, we could just allocate a struct input_event, fill it up with bytes that came from the device handle, and read those four fields of the struct. But since we are using Javascript, we have to do a little work to form the byte stream from the device handle into sensible values.
Let’s start with time. That’s another nested struct, so we have to dig a little deeper. In /usr/include/x86_64-linux-gnu/bits/types/struct_timeval.h, I find this:
/* A time value that is accurate to the nearest microsecond but also has a range of years. */ struct timeval { __time_t tv_sec; /* Seconds. */ __suseconds_t tv_usec; /* Microseconds. */ };
So a timeval consists of two fields, both of which are integers (although that may not be immediately obvious in the code), one for seconds and one for microseconds. So how many bits are these integers? It turns out that’s not generally defined, but differs between systems! For instance, my Raspberry Pi uses 32 bits for the seconds, but the NUC with Ubuntu uses 64 bits. So the number of bytes that appear from the device handle when I press a key is different for these two computers.
To find out how large the values are on a specific system, it’s easiest to use a little C program. The file eventsizes.c in the Klipspringer code looks like this:
#include <linux/input.h> #include <stdio.h> int main(int argc, char *argv[]) { struct input_event ev; ev.time.tv_sec = 1; printf("[%ld,%ld,%ld,%ld,%ld,%ld,%ld,%ld,%ld,%ld,%ld,%d]\n", sizeof ev, (char *) &ev.time.tv_sec - (char *) &ev, sizeof ev.time.tv_sec, (char *) &ev.time.tv_usec - (char *) &ev, sizeof ev.time.tv_usec, (char *) &ev.type - (char *) &ev, sizeof ev.type, (char *) &ev.code - (char *) &ev, sizeof ev.code, (char *) &ev.value - (char *) &ev, sizeof ev.value, !*((char *) &ev.time.tv_sec)); return 0; }
When compiled and run, this prints twelve values in a format that can be interpreted as a JSON array, and therefore easily read in Javascript. The values are:
- the total number of bytes in a struct input_event,
- the offset of the seconds field (
time.tv_sec) in the struct, - the number of bytes in the seconds field,
- the offset of the microseconds field (
time.tv_usec) in the struct, - the number of bytes in the microseconds field,
- the offset of the type field in the struct,
- the number of bytes in the type field,
- the offset of the code field (
time.tv_secs) in the struct, - the number of bytes in the code field,
- the offset of the value field (
time.tv_secs) in the struct, - the number of bytes in the value field, and
- the endianness of the integer values, i.e., if the bytes of an integer are left-to-right or right-to-left, which differs between computer architectures. This is detected by putting a 1 in the seconds field,
ev.time.tv_sec = 1;, reading the first byte of it, and negating it logically. That results in a 0 for little endian and 1 for big endian.
The Klipspringer platform compiles and runs eventsizes.c as part of the build process, and places the output in eventsizes.json in the lib directory, where it can later be read by main.js.
Gettting input event values into Javascript
If you’ve put the output from eventsizes.c in a file called eventsizes.json, like Klipspringer does, you can read it into twelve constants in Node.js like this:
const [eventSize, secOff, secSize, usecOff, usecSize, typeOff, typeSize, codeOff, codeSize, valueOff, valueSize, bigE] = JSON.parse(fs.readFileSync('eventsizes.json'));
(I used a destructuring assignment there, a fairly new syntactic construct in Javascript.)
So now we have the sizes of the components of events in our program. What we need to do now is:
- read from the device handle, eventSize bytes at a time, into a buffer,
- use the event component sizes and offsets to pick out time, type, code, and value from the byte buffer, and
- identify, from those values, the events we want to have the program react to, in whichever way we want, and call the code for the reactions.
I’m going to show you how this is done in the Klipspringer hub, working backwards from point 3. Klipspringer’s general device input code is in device_input.js. There’s a lookup table tbl that maps any combination of type, code, and value to a function that is to handle events that are signified by these three values. So that’s essentially step 3, except that in order to know what to put in the table, you also need to know what type, code, and value mean, but we get to that later.
To pick out integer values the right way from the byte buffer, there’s the following function that reads out an integer consisting of size bytes beginning att index off of the buffer b, when big endianness, bigE, is either true or false. Let’s not worry about some integers being declared as signed, all the values are going to be nonnegative anyway.
const read = (b, off, size, bigE) => { let v = 0; let p, d; if (bigE) { p = off; d = 1; } else { p = off+size-1; d = -1; } for (let i = 0; i < size; i++) { v = (v << 8) + b.readUInt8(p); p += d; } return v; }
To read bytes from a device handle, we treat it as a file that is read as a read stream. (I tried reading eventSize bytes at a time with read in fs/promises, but I couldn’t get it to work correctly. Apparently it’s better to read it as a stream.) The following is my code to read from device (the device handle as a string), and when at least eventSize bytes have been read, pick out type, code, and value, and check in tbl if there’s any function registered to be called for that particular combination. If there is, the time value is also picked out, and the function is called. Don’t worry about the first parameter that’s filled in with getIntf(), that’s a Klipspringer specific thing. The following four parameters provide the function with all the information of the event.
let saved = 0, evOff = 0; const saveBuf = Buffer.alloc(eventSize); createReadStream(device).on('data', chunk => { let off = 0; while (saved+off+eventSize <= chunk.length) { let b, p; if (saved) { chunk.copy(saveBuf, saved, off, eventSize-saved); b = saveBuf; p = 0; } else { b = chunk; p = off; } const type = read(b, p+typeOff, typeSize, bigE); const code = read(b, p+codeOff, codeSize, bigE); const value = read(b, p+valueOff, valueSize, bigE); log.debug([type, code, value]); const f = tbl[k(type, code, value)]; if (f) { const sec = read(b, p+secOff, secSize, bigE); const usec = read(b, p+usecOff, usecSize, bigE); f(getIntf(), type, code, value, sec*1000+usec/1000); } off += eventSize-saved; saved = 0; } if (saved+off < chunk.length) { chunk.copy(saveBuf, saved, off, chunk.length-off); saved += chunk.length-off; } });
The timestamp of an event consists, as we’ve seen, of a combination of seconds (sec) and microseconds (usec), and as usual in Unix systems, this is to be interpreted as how much time had passed sine the beginning of 1970 when the event happened. Javascript usually represents this as milliseconds in floating point number form, and that’s why the code above does sec*1000+usec/1000 to produce a value that’s compatible with, for instance, the values you get from Date.now().
Interpreting the event fields
Now, we also need to know what the values we find for type, code, and value mean. You can, and probably need to, experiment to see what your specific input device generates, but some general information is useful to interpret it.
Again, we turn to the header files. In /usr/include/linux/input-event-codes.h, there are hundreds of possible values of key codes translated into reasonably understandable named constants, and some of them have explanatory comments.
If key events are what we are looking for, we’re interested only in events for which type equals EV_KEY which is 1. When type is 1, code is a key code, which is one of all those key code constants: KEY_BACKSPACE (14), KEY_TAB (15), or KEY_ENTER (28), etc.
The value part of the event is not documented in input-event-codes.h, but from my experiments it seems clear that when a key is pressed, it generates a key event with value 1, and when it’s released you get an event with value 0. Between these events, if it’s a repeating key and it’s held down, you get a sequence of events with value 2.
Remote control example
An IR remote control appears just like a keyboard once it’s configured. It just typically uses different kinds of key codes, like KEY_NEXTSONG (163), KEY_STOPCD (166), KEY_FASTFORWARD (208), etc. I recently configured the Rega remote that I’ve never been able to use the CD buttons on before (because I don’t have a Rega CD player) to use with Klipspringer. I won’t go into details, but refer you to the blog post that helped me do it, which does a fine job of explaining how it’s done using ir-keytable. I’ll just briefly write down, for my own memory if nothing else, the important steps I had to take to make it work:
- Install software:
sudo apt install ir-keytable - Allow the klipspringer user access to the device handles by appending it to the input group list:
sudo usermod -a -G input klipspringer - Run
sudo ir-keytable -v -t -p necto obtain the scancode for each of the keys on the remote by pressing them one at a time. Decide on suitable key codes to match the scancodes, and create the file/etc/rc_keymaps/rega_mini_remotewhich, incidentally, ended up looking like this. - Put the following command in
/etc/rc.localto write the scancode/keycode table on reboot:/usr/bin/ir-keytable -c -p nec -w /etc/rc_keymaps/rega_mini_remote - Use
libinput list-devicesto find out that what device handle to use for the remote (in my case /dev/input/event3). - If you don’t want your desktop (but just your own program) to react to remote keypresses, click here to find out how to make the desktop ignore the remote.
Finally, the module remote.js uses device_input.js to set Klipspringer up to react to some of the keys of my remote. Your remote probably has different keys, that you want to react to in other ways.