Dominykas Svetikas

The Mouse That Made Me Write a Kernel Module

· Dominykas Svetikas · English (Current)

I recently bought a new wireless mouse. They’re great until they die in the middle of something important. That’s why battery status indicators are so useful. Unfortunately, my system didn’t show the battery level at all. Not entirely surprising, but it sparked an idea: why not write some code to make it work?

On KDE, battery status is handled by UPower, which in turn reads from the /sys/class/power_supply/ directory. My mouse wasn’t showing up there, so I decided to dig deeper.

Reverse Engineering the Protocol

To start, I knew almost nothing about how mice communicate with the system, other than that they use polling and that I could see raw data in /dev/hidrawX.

After some research, I discovered that I could capture raw USB packets using Wireshark and usbmon.

usbmon is a Linux kernel module that lets you monitor USB traffic. After enabling it with modprobe usbmon, I opened Wireshark, selected the appropriate usbmon interface and filtered for my mouse’s device address. This let me watch the exact packets my system was exchanging with the mouse.

Initially, I didn’t observe the mouse sending anything that looked like battery information on its own. But then I visited the manufacturer’s website for configuring the mouse, which does display the battery status.

As soon as I selected the mouse on the site, Wireshark lit up with new traffic — clearly not just movement or clicks. I decided to dig deeper.

Inspecting the Website

Now this part I’m more familiar with. After looking up how websites can communicate with HID devices, I added a breakpoint to intercept when the site sends a report:

const originalSendReport = HIDDevice.prototype.sendReport;
HIDDevice.prototype.sendReport = function(reportId, data) {
    debugger;
    return originalSendReport.apply(this, arguments);
};

And boom — we’re in:

Triggered debugger statement in Chrome

Triggered debugger statement in Chrome

Now this is where the real fun begins: finding the packets responsible for battery status.

The JavaScript found on the site was more obfuscated than just minified. One of the common obfuscation tricks it used is mapping numeric indices to strings via a lookup function. For example, this line:

_0x2d3ce7[_0x229c31(0x1d3)]

…is equivalent to something like:

_0x2d3ce7["battery"]

…after resolving the index through a function like:

function decode(index) {
    return stringArray[index - 0x151];
}

After the usual debugger shenanigans, I came across this line:

Debugger currently stepping on a function that does something with battery

Debugger currently stepping on a function that does something with the battery

Very promising! Now we just need to trace back to the packet that fills this battery data. Eventually, I found the request packet:

17-byte HID request packet structure

The request packet structure

And the corresponding response:

17-byte HID response packet structure

The response packet structure

Both are 17 bytes long. I assume the bytes at the end (before the checksum) are just padding. It’s also worth mentioning that the mouse does not send battery data automatically, it must be explicitly queried.

Now that we have the protocol, let’s put this boat in the water, and find out if it leaks by writing a small utility to fetch battery status.

Writing a Go Utility

With the protocol in hand, I decided to write a small utility in Go to query the battery status. I used the github.com/karalabe/hid library, which provides a simple interface for communicating with HID devices.

Here’s the core function that sends the request and parses the response:

func getBatteryLevel(device *hid.Device) (level int, err error) {
	// Send the actual request
	_, err = device.Write(batteryRequestReport)
	if err != nil {
		return 0, fmt.Errorf("failed to send report: %w", err)
	}

	time.Sleep(50 * time.Millisecond) // didn't consistently work without

	// Read whatever packet we've gotten
	response := make([]byte, packetSize)
	_, err = device.Read(response)
	if err != nil {
		return 0, fmt.Errorf("failed to read response: %w", err)
	}

	// Make sure it's the packet we need
	if len(response) == packetSize && response[0] == batteryQueryResponseId {
		batteryLevel := response[6]
		batteryCharge := response[7]
		batteryVoltage := (int(response[8]) << 8) | int(response[9])

		fmt.Println(len(response), response)
		log.Printf("Battery Level: %d%%\n", batteryLevel)
		log.Printf("Battery Charge: %d\n", batteryCharge)
		log.Printf("Battery Voltage: %d mV\n", batteryVoltage)
		return batteryLevel, nil
	}
	return 0, fmt.Errorf("unexpected response: %x", response)
}

The *hid.Device pointer is obtained via enumeration:

devices := hid.Enumerate(vendorID, productID)

To my surprise, enumeration returned three entries. At the time, I didn’t realize each entry represented a different interface of the same physical device.

I tried the first interface — it failed for unknown reason. I tried the second one — and voila, it worked:

Successful battery status query

Successful battery status query

When I tried the third interface, my mouse stopped working completely and I had to reconnect the dongle to get it back.

That seemed strange, but I now had a working proof-of-concept. The last step was to turn this into a daemon that maintains its own /sys directory — and I thought I was almost done.

Or so I thought.

Driver Shenanigans

As I unfortunately found out, the /sys directory is part of sysfs, and it’s maintained exclusively by the kernel. That means if I want to put my own entries there I’d need to write a kernel driver.

Unfortunate.

I had originally hoped I could just create a user-space daemon that updates a file in /sys/class/power_supply/, and UPower would pick it up. But sysfs doesn’t work that way — it’s not just a fancy filesystem, it’s a kernel interface. You can’t write to it from user space unless the kernel explicitly allows it.

I had no idea how one even writes a kernel driver. Luckily for me, there was a really useful resource — the Linux Device Drivers, Third Edition book.

In the classic programmer fashion, I took the sample code from the book, pasted it and slightly modified it.

And I was immediately reminded why I dislike the entire C/C++ toolchain:

clangd complaining about missing headers

clangd complaining about missing headers

Eventually, I managed to resolve this issue with compile_commands.json file that was generated with Bear.

After building the module and loading it with insmod (and removing it with rmmod), I was greeted with “Hello” and “Goodbye” in dmesg:

Working hello world in dmesg

Working hello world in dmesg.

Testing Sysfs

Next, I wanted to see how to create a sysfs directory from a kernel module. Turns out, it’s not that hard:

static struct class *battery_class;
static struct device *battery_device;

static ssize_t status_show(struct device *dev, struct device_attribute *attr, char *buf) {
    return sprintf(buf, "Sneezing\n");
}

static ssize_t capacity_show(struct device *dev, struct device_attribute *attr, char *buf) {
    return sprintf(buf, "75\n");
}

// I LOVE MACROS...NOT
static DEVICE_ATTR_RO(status);
static DEVICE_ATTR_RO(capacity);

static struct attribute *battery_attrs[] = {
    &dev_attr_status.attr,
    &dev_attr_capacity.attr,
    NULL,
};

static struct attribute_group attr_group = {
    .attrs = battery_attrs,
};

static int hello_init(void) {
    battery_class = class_create("super-custom-god");
    if (IS_ERR(battery_class)) {
        pr_err("Failed to create class\n");
        return PTR_ERR(battery_class);
    }

    battery_device = device_create(battery_class, NULL, 0, NULL, "mybattery");
    if (IS_ERR(battery_device)) {
        class_destroy(battery_class);
        pr_err("Failed to create device\n");
        return PTR_ERR(battery_device);
    }

    int err = sysfs_create_group(&battery_device->kobj, &attr_group);
    if (err) {
        device_destroy(battery_class, 0);
        class_destroy(battery_class);
        pr_err("Failed to create sysfs group\n");
        return err;
    }
    return 0;
}

static void hello_exit(void) {
    sysfs_remove_group(&battery_device->kobj, &attr_group);
    device_destroy(battery_class, 0);
    class_destroy(battery_class);
}

For that piece of C, we get the following goodness:

Working custom sysfs directory

Working custom sysfs directory

Alright, now that I knew I wouldn’t be completely shafted, I moved on to writing an actual HID driver.

Writing the Actual Driver

Now let’s write a real driver targeting a specific HID device. I leaned heavily on Chapter 13 of the LDD3 book and studied the Linux HID driver source tree, especially:

Registering Supported Devices

First, I had to specify the vendor and product IDs of the devices I wanted this driver to handle:

static const struct hid_device_id vxe_devices[] = {
    { HID_USB_DEVICE(0x3554, 0xf58a) }, // wireless dongle
    { HID_USB_DEVICE(0x3554, 0xf58c) }, // wired
    { } // required: terminator
};

To make our driver discoverable by the kernel and allow it to be auto-loaded when the device is connected, we need to export the device list using this macro:

MODULE_DEVICE_TABLE(hid, vxe_devices);

Registering the Driver

This part tells the kernel: “Here’s my driver, and here’s the list of devices I support”:

static struct hid_driver vxe_driver = {
    .name = "vxe-dragonfly-r1-pro-max",
    .id_table = vxe_devices,
    .probe = vxe_probe,
};
module_hid_driver(vxe_driver);

The .key = value syntax is called a designated initializer. It lets you initialize specific fields in a struct by name, much like Go’s Struct{Field: value} syntax.

Here’s what each field means:

  • .name: A human-readable name for the driver.
  • .id_table: The list of supported devices from earlier.
  • .probe: The function that gets called when a matching device is plugged in.

The module_hid_driver() macro wraps everything up and registers the driver with the HID subsystem.

Device Setup and Teardown

The vxe_probe() function is where the real work begins. It’s called when the kernel finds a device that matches the id_table.

Here we call hid_parse() to parse the HID report descriptor and then hid_hw_start() to initialize the device and start I/O.

If anything fails, we return an error code. If it all works, we’re golden.

static int vxe_probe(struct hid_device *hdev, const struct hid_device_id *id) {
    struct usb_interface *intf = to_usb_interface(hdev->dev.parent);
    int ifnum = intf->cur_altsetting->desc.bInterfaceNumber;
    int ret = 0;

    pr_info("vxe_probe: HID device probing started\n");
    pr_info("vxe_probe: HID device found with Vendor ID: 0x%04x, Product ID: 0x%04x on If=%d\n",
            id->vendor, id->product, ifnum);

    // Parse the HID descriptor
    ret = hid_parse(hdev);
    if (ret) {
        pr_err("vxe_probe: hid_parse failed with error %d\n", ret);
        return ret;
    }

    // Start the HID hardware for I/O operations
    ret = hid_hw_start(hdev, HID_CONNECT_DEFAULT);
    if (ret) {
        pr_err("vxe_probe: hid_hw_start failed with error %d\n", ret);
        return ret;
    }
    pr_info("vxe_probe: device started successfully\n");
    return 0;
}

After confirming that the probe worked, I tried unloading the module to install a new version and…

Kernel oops

Kernel oops

My first kernel oops — not just any oops, but one I caused entirely on my own, with my own driver. A rite of passage.

The culprit? I forgot to call hid_hw_stop() inside .remove when unloading the driver. Turns out, the kernel doesn’t like it when you leave devices half-initialized.

Anime girl going ah, eto, bleh

A sharp reminder: we’re working with C, and the kernel doesn’t forgive.

Receiving Raw Events

I wrote the following function to receive raw events from the HID device:

static int vxe_raw_event(
	struct hid_device *hdev,
    struct hid_report *report,
    u8 *data, int size
) {
    // Detect battery information packet
    if (size == 17 && data[0] == 0x08 && data[1] == 0x04) {
        pr_info("vxe_raw_event: === Detected a 17-byte battery data packet! ===\n");
        pr_info("vxe_raw_event: ");
        for (int i = 0; i < size; i++) {
            pr_cont("%02x ", data[i]);
        }
        pr_cont("\n");
        parse_battery_data(data, size);
    }
    return 0;
}

And as you can see, it successfully detects the battery information packets that the website requests:

Successfully captured packet with battery information

Successfully captured packet with battery information.

But when I try using my Go utility to send the query, my driver suddenly stops working. I ran cat /sys/kernel/debug/usb/devices and saw this:

/sys/kernel/debug/usb/devices output where Driver=(none)

/sys/kernel/debug/usb/devices output

Notice how the there’s Driver=(none), it should either be my driver or the default usbhid driver.

The Go library I’m using for HID communication relies on hidapi, a popular cross-platform library. By default, hidapi itself utilizes libusb on Linux systems.

This detail is crucial because libusb needs exclusive control over a device to communicate with it. If the Linux kernel’s default driver is already attached to the device, libusb can’t simply take over. It first has to detach that kernel driver using a function called libusb_detach_kernel_driver(). By extension, there goes my driver. I hate to admit how much time I spent debugging this.

That is the reason my mouse stopped working when I tried sending the query across all 3 interfaces.

Rough.

Running Periodic Tasks

To periodically query the mouse for battery status, I needed a way to schedule recurring tasks inside the kernel. This is where timers and workqueues come in (see chapter 7 of the book).

  • Timers let you schedule a callback to run after a delay.
  • Workqueues let you run code in process context (i.e., it’s safe to sleep or call blocking functions).

In my case, I used a timer to trigger a battery status request every few seconds, and a workqueue to actually send the request.

I’m not going to bother explaining why a workqueue is needed — let’s just say, there were more oopsies.

Here’s the work handler:

static void vxe_battery_work_handler(struct work_struct *work) {
    // Get the containing vxe_mouse structure from the work_struct
    struct vxe_mouse *vxe_dev = container_of(work, struct vxe_mouse, battery_request_work);
    struct hid_device *hdev = vxe_dev->hdev;
    // Send a request for battery status.
    int ret = hid_hw_raw_request(
        hdev,
        battery_status_request[0],   // Report ID
        battery_status_request,      // Data buffer
        BATTERY_STATUS_REQUEST_SIZE, // Size of data
        HID_OUTPUT_REPORT,           // Report type
        HID_REQ_SET_REPORT           // Request type (SET for sending)
    );         
    if (ret < 0) {
        pr_err("vxe_battery_work_handler: Failed to send battery status request: %d\n", ret);
    } else {
        pr_info("vxe_battery_work_handler: Battery status request sent.\n");
    }
}

And here’s how it’s wired up in vxe_probe():

  • Allocate and initialize the vxe_mouse structure. This will store a pointer to the hid_device.
  • Set up the workqueue and timer.
  • Start the timer with a short delay to trigger the first request.

Also note the check for interface == 1 — that’s the same interface I was using in the Go utility:

static int vxe_probe(struct hid_device *hdev, const struct hid_device_id *id) {
    ...

    if (ifnum == 1) {
        pr_info("vxe_probe: Initializing battery polling for interface 1.\n");
        // Initialize the workqueue item with our handler function
        INIT_WORK(&vxe_dev->battery_request_work, vxe_battery_work_handler);
        // Initialize the timer
        timer_setup(&vxe_dev->battery_poll_timer, vxe_request_battery_status, 0);
        // Start the timer immediately to get the first battery status
        // It will then reschedule itself periodically in the callback
        mod_timer(&vxe_dev->battery_poll_timer, jiffies + msecs_to_jiffies(100));
    }
}

The timer callback is responsible for queuing the work and rescheduling itself, creating a loop:

static void vxe_request_battery_status(struct timer_list *t) {
    struct vxe_mouse *vxe_dev = from_timer(vxe_dev, t, battery_poll_timer);
    // Schedule the work item to run the actual HID request in a process context
    // This prevents "scheduling while atomic" bugs
    schedule_work(&vxe_dev->battery_request_work);
    // Reschedule the timer to run again after BATTERY_POLL_INTERVAL_MS
    mod_timer(&vxe_dev->battery_poll_timer, jiffies + msecs_to_jiffies(BATTERY_POLL_INTERVAL_MS));
}

And of course, in vxe_remove(), we clean everything up:

static void vxe_remove(struct hid_device *hdev) {
    pr_info("vxe_remove: Device being removed.\n");
    struct vxe_mouse *vxe_dev = hid_get_drvdata(hdev);
    // Stop new work/timers and wait for existing work to finish
    if (vxe_dev) {
        // Delete the timer if it was active.
        del_timer_sync(&vxe_dev->battery_poll_timer);
        pr_info("vxe_remove: Battery poll timer deleted.\n");
        // Cancel any pending workqueue items and wait for them to complete.
        cancel_work_sync(&vxe_dev->battery_request_work);
        pr_info("vxe_remove: Battery request work cancelled.\n");
    }
    // Stop the HID hardware operations
    hid_hw_stop(hdev);
    pr_info("vxe_remove: HID hardware stopped.\n");
    // Free our custom data and clear the driver data.
    if (vxe_dev) {
        kfree(vxe_dev);
        hid_set_drvdata(hdev, NULL);
    }
}

This entire setup ensures that battery status is polled regularly, and that everything is cleaned up properly when the device is removed. I really don’t want more oopses on the way.

Immovable Object

At this point, I hit a brick wall.

Although the SET_REPORT request is sent successfully, the device simply does not respond with an interrupt transfer.

Here’s what the request frame looks like when sent by the driver:

0000   c0 76 99 4f d1 8b ff ff 53 02 00 04 03 00 00 00
0010   18 a6 4e 68 00 00 00 00 6f 40 0b 00 8d ff ff ff
0020   11 00 00 00 11 00 00 00 21 09 08 02 01 00 11 00
0030   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0040   08 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0050   49

And here’s the same frame when sent by the website:

0000   80 14 eb 2a d6 8b ff ff 53 02 00 04 03 00 00 00
0010   f7 a6 4e 68 00 00 00 00 7f 0a 02 00 8d ff ff ff
0020   11 00 00 00 11 00 00 00 21 09 08 02 01 00 11 00
0030   00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0040   08 04 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0050   49

The two frames are identical except for the following byte ranges:

  • 0–7: URB ID (unique per transfer)
  • 16–23: Timestamp (seconds)
  • 24–27: Timestamp (microseconds)

These differences are expected and reflect runtime metadata rather than meaningful payload changes. The same pattern holds for the immediate response to SET_REPORT.

But here’s the kicker: when the website sends this frame, the device responds with an interrupt transfer. Same with the Go utility. But when the kernel driver sends it?

Nothing. Silence.

The device just… ignores it.

At this point, I was tired and I had exams coming up, so I decided to put this project on the backburner.

Closing Thoughts

What began as a minor inconvenience quickly unraveled into a full-blown exploration of USB protocols, browser debugging, Go programming, and kernel driver development. Along the way, I found myself reverse-engineering obfuscated JavaScript, poking at raw HID packets, and even crashing my own system with poorly written code.

This wasn’t just about getting a battery percentage to show up. It was about peeling back the layers of abstraction that modern systems wrap around hardware, and realizing just how much magic is happening under the hood. Writing a kernel module felt like stepping into a different world, one where every mistake is a potential system crash, and every success is a small miracle.

The project didn’t end with a polished solution. It ended with a half-working prototype, a trail of kernel oopses, and a mouse that sometimes just stops responding. But in the process, I learned more about how Linux, HID devices, and the kernel ticks (hah, get it?) than I ever expected to.

Huge thanks to my friend Ene7, whose comment nudged me to document this whole adventure in a more coherent fashion:

Message from Ene7 that says: This feels like stumbling on the notes of a mad scientist long gone in Fallout

And as a certain sunglasses-wearing cyborg once said:

arnold schwarzenegger wearing sunglasses says "i 'll be back"