Here is a quick single-file example. Note that it needs access to both the TTY and the /dev/uinput devices, so for testing purposes, I recommend running it as root.
Whenever it reads '+', it will generate a Volume Up keypress; whenever it reads '-', it will generate a Volume Down keypress.
Specify a serial port or a TTY device, and the vendor:product/name for the synthetic keyboard device, on the command line. For example, if you run
sudo ./binary $(tty) 1234:5678/Examplethen you can press + and - on the terminal to change the volume.
While the daemon is running, you can observe it in the input event device list. For example,
ls -laF /dev/input/ and
sudo evtest .
Type
sudo killall -TERM binaryto exit the daemon. (The daemon sets the serial port or TTY to raw mode, so when reading from the same terminal, Ctrl+C no longer generates an INT signal; that's why you cannot exit it by simply pressing Ctrl+C when running the above command. If you run it in a terminal using some other tty or serial port, then Ctrl+C will work just fine.)
// SPDX-License-Identifier: CC0-1.0
//
// Compile using for example
// gcc -Wall -O2 thisfile.c -o binary
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#include <linux/uinput.h>
#include <string.h>
#include <signal.h>
#include <stdio.h>
#include <errno.h>
// Signal handling for graceful restart
static volatile sig_atomic_t restart = 0;
void handle_restart(int signum)
{
(void)signum;
restart = 1;
}
int install_restart(int signum)
{
struct sigaction act;
memset(&act, 0, sizeof act);
sigemptyset(&act.sa_mask);
act.sa_handler = handle_restart;
act.sa_flags = 0; // No SA_RESTART flag!
return sigaction(signum, &act, NULL);
}
// Signal handling for graceful exit
static volatile sig_atomic_t done = 0;
void handle_done(int signum)
{
(void)signum;
done = 1;
}
int install_done(int signum)
{
struct sigaction act;
memset(&act, 0, sizeof act);
sigemptyset(&act.sa_mask);
act.sa_handler = handle_done;
act.sa_flags = 0; // No SA_RESTART flag!
return sigaction(signum, &act, NULL);
}
// Helper function, since signal delivery can cause an EINTR "error". Async-signal safe, too.
int writeall(int fd, const void *buf, size_t len)
{
const int saved_errno = errno;
int err = 0;
const unsigned char *const end = (const unsigned char *)buf + len;
const unsigned char *src = (const unsigned char *)buf;
while (src < end) {
ssize_t n = write(fd, src, (size_t)(end - src));
if (n > 0) {
src += n;
} else
if (n != -1) {
err = EIO;
break;
} else
if (errno != EINTR) {
err = errno;
break;
}
}
errno = saved_errno;
return err;
}
// Emit a keypress and release
void keypress(int fd, int code)
{
struct input_event ev[2];
// Timestamp is ignored
ev[0].time.tv_sec = 0;
ev[0].time.tv_usec = 0;
ev[1].time = ev[0].time;
// Keypress
ev[0].type = EV_KEY;
ev[0].code = code;
ev[0].value = 1; // Keypress
ev[1].type = EV_SYN;
ev[1].code = SYN_REPORT;
ev[1].value = 0;
writeall(fd, ev, sizeof ev);
// Key release
ev[0].value = 0; // Release
writeall(fd, ev, sizeof ev);
}
int parse_usbid(const char *src, struct uinput_setup *to)
{
unsigned long val;
const char *end;
if (!src || !*src || !to)
return -1;
memset(to, 0, sizeof *to);
to->id.bustype = 0;
to->id.vendor = 0;
to->id.product = 0;
to->id.version = 0;
// Vendor
end = src;
errno = 0;
val = strtoul(src, (char **)&end, 16);
if (errno || end == src || *end != ':' || val > 0xFFFF)
return -1;
to->id.vendor = val;
// Product
src = ++end;
errno = 0;
val = strtoul(src, (char **)&end, 16);
if (errno || end == src || val > 0xFFFF)
return -1;
to->id.product = val;
// Version (optional)
if (*end == ':') {
src = ++end;
errno = 0;
val = strtoul(src, (char **)&end, 0);
if (errno || end == src || val > 0xFFFF)
return -1;
to->id.version = val;
}
// Name (optional)
if (*end == '/') {
src = ++end;
end = src + strlen(src);
if ((size_t)(end - src) >= UINPUT_MAX_NAME_SIZE)
return -1;
strncpy(to->name, src, UINPUT_MAX_NAME_SIZE - 1);
}
if (*end)
return -1;
return 0;
}
int main(int argc, char *argv[])
{
const char *arg0 = (argc > 0 && argv && argv[0] && argv[0][0]) ? argv[0] : "(this)";
struct uinput_setup setup;
struct termios oldsettings, settings;
int uinputfd, devfd = -1;
// Check command-line arguments
if (argc != 3 || !strcmp(argv[1], "-h") || !strcmp(argv[1], "--help")) {
fprintf(stderr, "\n");
fprintf(stderr, "Usage: %s [ -h | --help ]\n", arg0);
fprintf(stderr, " %s DEVICE-PATH VENDOR:PRODUCT[:VERSION][/NAME]\n", arg0);
fprintf(stderr, "\n");
return EXIT_FAILURE;
}
if (parse_usbid(argv[2], &setup)) {
fprintf(stderr, "%s: Invalid synthetic USB HID device identifier.\n", argv[2]);
return EXIT_FAILURE;
}
// Install signal handlers
if (install_done(SIGINT) ||
install_done(SIGTERM) ||
install_restart(SIGHUP) ||
install_restart(SIGUSR1)) {
fprintf(stderr, "Cannot install signal handlers: %s.\n", strerror(errno));
return EXIT_FAILURE;
}
// Construct new input event device
uinputfd = open("/dev/uinput", O_RDWR | O_CLOEXEC);
if (uinputfd == -1) {
fprintf(stderr, "/dev/uinput: %s.\n", strerror(errno));
return EXIT_FAILURE;
}
// List event types and events this can produce
ioctl(uinputfd, UI_SET_EVBIT, EV_KEY);
ioctl(uinputfd, UI_SET_KEYBIT, KEY_VOLUMEDOWN);
ioctl(uinputfd, UI_SET_KEYBIT, KEY_VOLUMEUP);
// Create the new synthetic input device
ioctl(uinputfd, UI_DEV_SETUP, &setup);
ioctl(uinputfd, UI_DEV_CREATE);
while (1) {
// Close already open serial port
if (devfd != -1) {
tcflush(devfd, TCIOFLUSH);
tcsetattr(devfd, TCSANOW, &oldsettings);
close(devfd);
}
if (done)
break;
// Open serial port or TTY device,
devfd = open(argv[1], O_RDWR | O_CLOEXEC);
if (devfd == -1) {
fprintf(stderr, "%s: Cannot open: %s.\n", argv[1], strerror(errno));
close(uinputfd);
return EXIT_FAILURE;
}
// obtain its current configuration,
if (tcgetattr(devfd, &oldsettings) == -1) {
fprintf(stderr, "%s: Not a TTY or serial port: %s.\n", argv[1], strerror(errno));
close(devfd);
close(uinputfd);
return EXIT_FAILURE;
}
// and configure it.
settings = oldsettings;
settings.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON);
settings.c_oflag &= ~(OPOST | OCRNL | ONOCR | ONLRET);
settings.c_cflag &= ~(CSIZE | PARENB);
settings.c_cflag |= CS8 | CREAD;
settings.c_lflag &= ~(ISIG | ICANON | ECHO | ECHOE | ECHOK | ECHONL | TOSTOP | IEXTEN);
settings.c_cc[VMIN] = 1;
settings.c_cc[VTIME] = 0;
if (tcsetattr(devfd, TCSANOW, &settings) == -1) {
fprintf(stderr, "%s: Cannot configure: %s.\n", argv[1], strerror(errno));
tcflush(devfd, TCIOFLUSH);
tcsetattr(devfd, TCSANOW, &oldsettings);
close(devfd);
close(uinputfd);
return EXIT_FAILURE;
}
// We have now restarted.
restart = 0;
while (!done && !restart) {
unsigned char buf[512];
ssize_t n = read(devfd, buf, sizeof buf);
if (n > 0) {
unsigned char *const end = buf + n;
unsigned char *src = buf;
while (src < end) {
switch (*(src++)) {
case '+':
keypress(uinputfd, KEY_VOLUMEUP);
break;
case '-':
keypress(uinputfd, KEY_VOLUMEDOWN);
break;
}
}
} else
if (n != -1) {
// End of input from serial device; reopen.
break;
} else
if (errno != EINTR) {
// Other error from serial device; reopen.
break;
}
}
}
close(uinputfd);
return EXIT_SUCCESS;
}
Majority of the code is signal handling (TERM and INT will gracefully exit, HUP and USR1 causes it to reopen the serial device), and serial device handling.
Note that because all signal handlers have been installed without the SA_RESTART flag, signal delivery to the userspace handler will interrupt any blocking I/O calls (
read() and
write() in particular) with
errno==EINTR. The way the inner loop is written, this program mostly sits quietly waiting (blocking) on a
read() from the serial port, with a signal delivery interrupting that. However, the signal could also arrive at a later time; particularly during
writeall() (writing events to the uinput device), and we want to complete those.
This does have a short race window wrt. signals and the blocking read: a signal could be delivered after the flag has been checked but just before the blocking
read(). We could avoid that by having the signal handler call
tcsetattr() to make the serial device non-blocking (by setting
.c_cc[VMIN]=0,
.c_cc[VTIME]=0,
tcsetattr() being async-signal safe), but generally, standard POSIX signals are not queued and do not have any guarantees, so such short race windows are generally considered acceptable. (Correspondingly, service management daemons are expected to send at least two TERM signals with a delay in between to tell a service to exit, before KILLing the service forcefully. The only downside of killing this program abruptly is that the serial port termios settings are not restored to originals.)
The
uinput stuff is quite trivial:
- Open /dev/uinput (write-only suffices, I recommend close-on-exec so you don't accidentally leak the open descriptor)
- For each event type it can produce, call ioctl(fd, UI_SET_EVBIT, EV_type);
- For each event it can produce, call ioctl(fd, UI_SET_KEYBIT, type_event);
- Construct a struct uinput_setup, with struct input_id as the id member defining the bus (BUS_USB), vendor:product, optional version, and the name member defining the device name (as if detected internally by the linux kernel) –– the name is NOT the device node name, but describes the device as if obtained from the USB descriptor or parsed by the Linux kernel.
Then, call ioctl(fd, UI_DEV_SETUP, &setup); - Create the device node using ioctl(fd, UI_DEV_CREATE);
Note that this is asynchronous: the device node may not exist yet when the call returns, because the udev daemon takes a fraction of a second to create the device node, assign its properties, and so on. You can apply udev rules to this synthetic device exactly like it was a physical USB HID device. - From this point forwards, you emit USB HID events by writing one or more struct input_event structures to fd (the uinput device descriptor above).
- When you close fd (the uinput device descriptor), the synthetic device is disconnected and removed (by udev) automatically.
If you generate multiple devices, you need to open uinput separately (dup() won't suffice) for each generated device.
It really is this simple. The Linux Kernel
uinput module documentation describes the interface in even more detail, as well as the
Linux Input Subsystem userspace API; you'll also want to check the
include/linux/input.h,
include/linux/input-event-codes.h, and
include/linux/uinput.h header files exported from the Linux kernel and available as
<linux/input.h> (which auto-includes
<linux/input-event-codes.h>) and
<input/uinput.h> for userspace programs in C.
There are two ways of starting such daemons. One is to have
udev start and stop them whenever the corresponding device (serial port device) is connected. The other is to have the daemon run always; when the device node does not exist, use
inotify to wait for
IN_CREATE,
IN_ATTRIB,
IN_MOVED_TO events in
/dev/., attempting to open the device whenever such events occur for the entry with the same name. When the device has been successfully opened, the inotify descriptor (and its watch) can be closed. This consumes minimum resources, and very, very little CPU time, because such events in
/dev/. occur only when new devices are attached.
Sure, you could use the
udev API instead, and become dependent on systemd-udevd; I don't want to, as I want the same to work with for example eudevd, and therefore use the inotify way.
If you do an X-less appliance, with
mpv or
vlc running on raw accelerated framebuffer (DRI+VA_API or whatever), put each one on a separate console (/dev/tty
N). For example, in most Ubuntu variants, X runs on /dev/tty7, and a login (via agetty) on /dev/tty1. The
chvt utility changes the foreground terminal, very similar to how LeftAlt+F
N or Ctrl+LeftAlt+F
N changes to /dev/tty
N.
You can then have all of the appliance applications running at the same time, and switch between them: both physical USB HID and uinput keypresses above will switch the foreground terminal.
If you use X and fullscreen applications, use a minimal window manager with multiple desktops, and start each application on a separate desktop. Depending on the window manager, there will be some keypresses to switch to a specific desktop. So, for multimedia controls, if you want to target a specific fullscreen X application, send first the desktop key shortcut, wait for the X server to start to act on the event (50ms should be plenty), and then send the multimedia keypress.
Whatever way you generate events, and regardless of whether they are X or Linux Input subsystem events, do remember that each keystroke is actually a pair of events: a keypress, and a key release. For the Linux Input subsystem events, events are grouped, with ALWAYS a
EV_SYN,
SYN_REPORT as the last event in a group. So, to generate say Shift+F1, you generate KEY_LEFTSHIFT:1, SYN_REPORT, KEY_F1:1, SYN_REPORT, KEY_F1:0, KEY_LEFTSHIFT:0, SYN_REPORT sequence. (For keypresses, there are three
.values: 0=release, 1=press, 2=autorepeat. The Linux kernel and X servers will handle autorepeat for you, though.)