SpaceMouse support in web applications on Linux

Published .

This article illustrates how to diagnose and correct a “quirky” input device on Linux. It will explore how input devices are handled throughout the software stack from web browsers down to USB HID report descriptors, highlighting the roles of udev, the kernel, and even BPF. It will also evaluate previous attempts to address the issue and propose two fixes that are hopefully clean enough to be accepted upstream.

Introduction

3Dconnexion produces 3D input peripherals commonly used in CAD, 3D modeling, geospatial analysis, and medical diagnostics applications. The company provides a proprietary driver and SDK for Windows and macOS, but their official Linux support is basically useless. Fortunately, their USB devices follow the HID specifications for multi-axis controllers and are fully supported by Linux’s input subsystem via the evdev interface. The spacenav project has implemented open-source replacements for the driver daemon and SDK, allowing the devices to be used in native applications like Blender.

But web-based applications are not able to communicate with the driver daemon. While the spacenav project alludes to a proprietary websocket protocol being used by some apps, a simpler solution is to leverage the Gamepad API. On Windows, these controllers indeed show up as 6-axis gamepads in major browsers. But on Linux, my SpaceNavigator was not recognized by either Firefox or Chrome. Let’s walk through the diagnosis and explore potential fixes.

The web browser

We’ll start at the top of the stack and investigate how a web browser (specifically, Firefox) decides which input devices to expose via the gamepad API. Taking a look at LinuxGamepad.cpp, we find the relevant-sounding function IsDeviceGamepad():

bool LinuxGamepadService::IsDeviceGamepad(struct udev_device* aDev) {
  if (!mUdev.udev_device_get_property_value(aDev, "ID_INPUT_JOYSTICK")) {
    return false;
  }
  // ...
}

So we need our device to have the udev property ID_INPUT_JOYSTICK set to 1. We can query its properties using udevadm info -q property -n /dev/input/by-id/usb-3Dconnexion_SpaceNavigator-event-if00 to confirm that ID_INPUT_JOYSTICK is currently not set. Next stop: udev.

The device manager

udev is a userspace daemon that responds to devices being plugged into or unplugged from a Linux computer. It is configured with a large selection of rules that can match against and modify device properties. It would be straightforward to add a rule that matches the SpaceNavigator vendor and product IDs and marks the device as a joystick:

SUBSYSTEM=="input", ATTRS{idVendor}=="046D", ATTRS{idProduct}=="C626", ENV{ID_INPUT_JOYSTICK}="1"

If we do this, the SpaceNavigator will indeed show up in the Gamepad API (after clicking one of its buttons), but it will not report any of its axes. Furthermore, other devices (like actual gamepads) don’t need this kind of special treatment in udev rules—they are marked as joysticks automatically. The logic used to determine whether or not a device is a joystick isn’t obvious from udev’s rules and hwdb, and that’s because it’s handled by the input_id builtin in “60-input-id.rules”. udev is maintained as part of the systemd project, and the logic we’re looking for is in udev-builtin-input_id.c:

if (num_joystick_buttons > 0 || num_joystick_axes > 0)
        is_joystick = true;

A button is considered a “joystick button” if its event code (defined in linux/input-event-codes.h) is in certain ranges associated with triggers, d-pads, and other joystick/gamepad functionality. Similarly, an axis is considered a “joystick axis” if its event code represents absolute rotation or a number of other input types associated with simulators and games. It’s important to clarify that these event categories are only used as a heuristic for tagging a device with an appropriate default input type; consumers like web browsers will still be able to act on events outside of these ranges (for example, gamepads will often have translational axes as well as rotational ones; it’s just that udev does not consider the presence of an absolute translational axis to be sufficient to categorize a device as a “joystick”).

So what events codes are produced by the SpaceNavigator? Running evemu-describe shows the following relevant events:

#   Event type 1 (EV_KEY)
#     Event code 256 (BTN_0)
#     Event code 257 (BTN_1)
#   Event type 2 (EV_REL)
#     Event code 0 (REL_X)
#     Event code 1 (REL_Y)
#     Event code 2 (REL_Z)
#     Event code 3 (REL_RX)
#     Event code 4 (REL_RY)
#     Event code 5 (REL_RZ)

So we see two buttons, but they have a generic type, rather than anything joystick-specific (which is no surprise). We also see our 6 axes, including 3 rotational axes, but they are marked as “relative” rather than “absolute”. This is why they don’t satisfy the joystick heuristic, and it’s also why Firefox did not show any axes (but did show buttons) even when we forced ID_INPUT_JOYSTICK to be 1. Taking another look at Firefox’s code, we see it only cares about absolute axes.

But are these event types appropriate? Unlike a mouse, which can only detect motion, the SpaceNavigator can directly sense the tilt and displacement of its “knob”. It would make more sense for Linux to treat these as absolute axes. So how were these event types determined?

The HID report descriptor

As a USB human interface device, the SpaceNavigator describes its capabilities to its host computer using an HID report descriptor. In Linux, we can see its contents via the device’s report_descriptor file under sysfs. (Note: this convenient file does not report the raw descriptor as sent by the device, but we can compare it with, say, a Wireshark capture to show that it has not been tampered with. Spoiler: we will be tampering with it soon.) The raw report looks like this:

05 01 09 08 a1 01 a1 00 85 01 16 a2 fe 26 5e 01
36 88 fa 46 78 05 55 0c 65 11 09 30 09 31 09 32
75 10 95 03 81 06 c0 a1 00 85 02 09 33 09 34 09
35 75 10 95 03 81 06 c0 a1 02 85 03 05 01 05 09
19 01 29 02 15 00 25 01 35 00 45 01 75 01 95 02
81 02 95 0e 81 03 c0 a1 02 85 04 05 08 09 4b 15
00 25 01 95 01 75 01 91 02 95 01 75 07 91 03 c0
06 00 ff 09 01 a1 02 15 80 25 7f 75 08 09 3a a1
02 85 05 09 20 95 01 b1 02 c0 a1 02 85 06 09 21
95 01 b1 02 c0 a1 02 85 07 09 22 95 01 b1 02 c0
a1 02 85 08 09 23 95 07 b1 02 c0 a1 02 85 09 09
24 95 07 b1 02 c0 a1 02 85 0a 09 25 95 07 b1 02
c0 a1 02 85 0b 09 26 95 01 b1 02 c0 a1 02 85 13
09 2e 95 01 b1 02 c0 a1 02 85 19 09 31 95 04 b1
02 c0 c0 c0

Parsing the report reveals these contents:

0x05, 0x01,        // Usage Page (Generic Desktop Ctrls)
0x09, 0x08,        // Usage (Multi-axis Controller)
0xA1, 0x01,        // Collection (Application)
0xA1, 0x00,        //   Collection (Physical)
0x85, 0x01,        //     Report ID (1)
0x16, 0xA2, 0xFE,  //     Logical Minimum (-350)
0x26, 0x5E, 0x01,  //     Logical Maximum (350)
0x36, 0x88, 0xFA,  //     Physical Minimum (-1400)
0x46, 0x78, 0x05,  //     Physical Maximum (1400)
0x55, 0x0C,        //     Unit Exponent (-4)
0x65, 0x11,        //     Unit (System: SI Linear, Length: Centimeter)
0x09, 0x30,        //     Usage (X)
0x09, 0x31,        //     Usage (Y)
0x09, 0x32,        //     Usage (Z)
0x75, 0x10,        //     Report Size (16)
0x95, 0x03,        //     Report Count (3)
0x81, 0x06,        //     Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
0xC0,              //   End Collection
0xA1, 0x00,        //   Collection (Physical)
0x85, 0x02,        //     Report ID (2)
0x09, 0x33,        //     Usage (Rx)
0x09, 0x34,        //     Usage (Ry)
0x09, 0x35,        //     Usage (Rz)
0x75, 0x10,        //     Report Size (16)
0x95, 0x03,        //     Report Count (3)
0x81, 0x06,        //     Input (Data,Var,Rel,No Wrap,Linear,Preferred State,No Null Position)
0xC0,              //   End Collection

// ...

0xC0,              // End Collection

// 228 bytes

The relevant data is at offsets 36-37 (for the X, Y, and Z axes) and 53-54 (for the Rx, Ry, and Rz axes). The bytes 0x81 0x06 indicate a relative input item, whereas we would expect 0x81 0x02 for an absolute one (see Device class definition for HID, page 30). So the device is indeed reporting that its axes are relative rather than absolute. This is arguably a firmware bug. (Note: a Stack Overflow post claims that the report descriptor has been fixed in newer products such as the SpaceMouse Wireless.)

It’s certainly not the first device to have errors in its HID report descriptor, though. Linux drivers are full of code to work around similar device “quirks.” In fact, Linux already attempts to address this very issue!

The broken fix

Linux 2.6.33 (released February 2010) contains commit 24985cf68612:

HID: support Logitech/3DConnexion SpaceTraveler and SpaceNavigator
These devices wrongly report their axes as relative instead of absolute.

Fix this in up report descriptor of the device before it enters the parser.

With that change, the Linux HID driver for Logitech devices recognizes the SpaceNavigator product ID (0xC626) and overwrites the first two 0x81 0x06 input items with 0x81 0x02, as desired. Unfortunately, it does so at offsets of 32 and 49, which are 4 bytes before they occur in our unit’s report descriptor. Perhaps 3Dconnexion revised the firmware in a subsequent production run (my SpaceNavigator was purchased in 2016, and there is some evidence that that the fix worked for others around 2010—see comments in spacenavig.c). Regardless, this fixup is ineffective for my device, and I’m not the only one affected.

The relabsd project exists specifically to translate relative axes into absolute ones in order to use more devices as gamepads (they mention the SpaceNavigator as a candidate device, and SDL as a potential consumer). This project consists of a userspace daemon that reads events via evdev, translates them as necessary, and then injects them into a new virtual device. While their approach is flexible and effective, such runtime translation would not be necessary (at least for the SpaceNavigator) if the kernel fix were working as intended.

Patching the kernel to apply the fix to my variant of the SpaceNavigator should be straightforward and hopefully unobjectionable (and my intent is to do exactly that). A quick fix could look something like this:

if (drv_data->quirks & LG_RDESC_REL_ABS) {
	if (*rsize >= 51 &&
			rdesc[32] == 0x81 && rdesc[33] == 0x06 &&
			rdesc[49] == 0x81 && rdesc[50] == 0x06) {
		hid_info(hdev,
			 "fixing up rel/abs in Logitech report descriptor\n");
		rdesc[33] = rdesc[50] = 0x02;
	} else if (*rsize >= 55 &&
			rdesc[36] == 0x81 && rdesc[37] == 0x06 &&
			rdesc[53] == 0x81 && rdesc[54] == 0x06) {
		hid_info(hdev,
			 "fixing up rel/abs in Logitech report descriptor\n");
		rdesc[37] = rdesc[54] = 0x02;
	}
}

(A more robust fix would parse the descriptor in order to find the input items associated with the axes of interest, but that doesn’t seem to be the norm for fixups like this.) However, it will be a long time before such a fix is deployed broadly in professional work environments. It would be nice if there were a way to apply such fixes without recompiling one’s kernel.

Fixing with a packet filter

It is possible to extend much of the Linux kernel’s functionality at runtime using eBPF, and the input subsystem is no exception. HID-BPF provides a way to fixup report descriptors using eBPF, and the udev-hid-bpf project provides a framework for running such eBPF programs from udev rules.

An eBPF program suitable for inclusion in udev-hid-bpf could look like this:

#define VID_LOGITECH 0x046D
#define PID_SPACENAVIGATOR 0xC626

HID_BPF_CONFIG(
	HID_DEVICE(BUS_USB, HID_GROUP_ANY, VID_LOGITECH, PID_SPACENAVIGATOR)
);

/*
 * The 3Dconnexion SpaceNavigator 3D Mouse is a multi-axis controller with 6
 * axes (grouped as X,Y,Z and Rx,Ry,Rz).  Axis data is absolute, but the report
 * descriptor erroneously declares it to be relative.  We fix the report
 * descriptor to mark both axis collections as absolute.
 *
 * The kernel attempted to fix this in commit 24985cf68612 (HID: support
 * Logitech/3DConnexion SpaceTraveler and SpaceNavigator), but the descriptor
 * data offsets are incorrect for at least some SpaceNavigator units.
 */

SEC(HID_BPF_RDESC_FIXUP)
int BPF_PROG(hid_fix_rdesc, struct hid_bpf_ctx *hctx)
{
	__u8 *data = hid_bpf_get_data(hctx, 0 /* offset */, 4096 /* size */);

	if (!data) {
		return 0; /* EPERM check */
	}

	/* Offset of Input item in X,Y,Z and Rx,Ry,Rz collections. */
	const u8 offsets[] = {36, 53};

	for (int idx = 0; idx < ARRAY_SIZE(offsets); idx++) {
		u8 offset = offsets[idx];

		/* if Input (Data,Var,Rel) , make it Input (Data,Var,Abs) */
		if (data[offset] == 0x81 && data[offset + 1] == 0x06) {
			data[offset + 1] = 0x02;
		}
	}

	return 0;
}

HID_BPF_OPS(logitech_spacenavigator) = {
	.hid_rdesc_fixup = (void *)hid_fix_rdesc,
};

SEC("syscall")
int probe(struct hid_bpf_probe_args *ctx)
{
	/* Ensure report descriptor has expected size. */
	ctx->retval = ctx->rdesc_size != 228;
	if (ctx->retval) {
		ctx->retval = -EINVAL;
	}

	return 0;
}

Testing

Kernel patch tested on a Raspberry Pi 5 (6.6 kernel). SpaceNavigator was accessible via Gamepad API in both Firefox and Chromium; no other changes were necessary.

udev-hid-bpf fixup tested on an x86 laptop running Linux Mint 22 (6.8 kernel). SpaceNavigator was accessible via Gamepad API in both Firefox and Google Chrome; no other changes were necessary.