Writing a Simple Linux Driver for a Device Without Knowing About Drivers or USB

A hands-on guide to writing a userspace Linux driver in Rust for the Nanoleaf Pegboard Desk Dock's RGB LEDs, covering USB fundamentals, reverse-engineering device protocols, and handling interrupts — all in about fifty lines of code.

Several months ago, I purchased a Nanoleaf Pegboard Desk Dock — an advanced USB hub featuring RGB lighting and device hooks. Unfortunately, this engineering marvel only supports "gaming" operating systems: Windows and macOS. This created a need for a Linux driver.

I had previously configured Windows VMs with USB passthrough and attempted reverse-engineering official drivers. After considering the options, I contacted Nanoleaf support requesting protocol specifications and documentation. To my surprise, technical support responded within four hours, providing complete protocol descriptions for both the Desk Dock and RGB lightstrips. While the documentation largely confirmed findings from independent reverse-engineering, it revealed additional details about power management and brightness control.

Today's approach combines protocol knowledge gained through reverse-engineering with reference to official documentation. However, I had never written device drivers for Linux and only interacted with USB devices as an end-user.

Starting from Zero

Most Linux distributions include lsusb — a utility listing all connected USB devices. Running this command revealed:

Bus 001 Device 062: ID 37fa:8201 JW25021301515 Nanoleaf Pegboard Desk Dock

The device appears in the list. But how does the kernel recognize this specific device? While the kernel lacks inherent knowledge of this device's existence, once connected, it receives power, activates, and becomes recognized.

A driver already exists — it's simply basic. Running lsusb in verbose mode reveals extensive details about the device's configuration, including descriptor information and endpoint specifications.

Brief USB Introduction

USB specifications are lengthy and complex, primarily designed for low-level implementations. Fortunately, the resource "USB in a NutShell" summarizes essential concepts.

USB devices can have multiple configurations, typically explaining power requirements. Most devices have a single configuration.

Each configuration contains multiple interfaces. For example, a camera might function as both a storage device and webcam.

Each interface has multiple endpoints, describing data transmission methods. A camera used as a webcam might perform continuous transmission alongside bulk transfers for file copying.

This device has one interface: Human Interface Device (HID) — the USB class for keyboards, mice, and gamepads. The kernel includes a standard USB HID driver. Manufacturers mark devices with known HID subclasses, then implement functionality using standard protocols.

The HID specification document contains 443 pages. Interestingly, USB keyboards include at least 103 buttons; devices with fewer buttons are technically keypads.

RGB LED devices lack HID specifications (the "LED" specification targets status indicators, not color LEDs). This device is standard HID with subclass interface 0, meaning the kernel recognizes it and supplies power but doesn't know what to do with it.

Two options exist:

  • Write a kernel driver following kernel standards, exposing LEDs as three /sys/class/leds devices (one per color). However, this approach seems inappropriate for gaming-oriented devices.
  • Write a userspace driver using libusb, defining custom LED control methods. This significantly lowers the quality bar from "Linus Torvalds will curse you" to "whatever, let's proceed."

I chose option two.

Side Quest: udev Rules

Linux requires root access for interesting operations, including USB device communication. While drivers could run as root, this represents poor practice. If distributed, users expect operation without privilege elevation.

Linux uses udev for managing hardware event handlers. To make the device accessible to users, create /etc/udev/rules.d/70-pegboard.rules:

ACTION=="add", SUBSYSTEM=="usb", DRIVERS=="usb", ATTRS{idVendor}=="37fa", ATTRS{idProduct}=="8201", MODE="0770", TAG+="uaccess"

The ATTRS{idVendor} and ATTRS{idProduct} values come from lsusb, while TAG+="uaccess" grants the current active user device management permissions. Reconnect the device.

For NixOS users: The .rules filename must alphabetically precede 73. NixOS only allows adding rules via 99-local.rules. Solution: create a package defining the rule, then extend services.udev.packages:

services.udev.packages = [
    (pkgs.writeTextFile {
        name = "pegboard_udev";
        text = ''
          ACTION=="add", SUBSYSTEM=="usb", DRIVERS=="usb", ATTRS{idVendor}=="37fa", ATTRS{idProduct}=="8201", MODE="0770", TAG+="uaccess"
        '';
        destination = "/etc/udev/rules.d/70-pegboard.rules";
    })
];

Writing a Simple Driver

Starting with a simple Rust binary, add the rusb crate as a libusb binding:

cargo new gamer-driver
cd gamer-driver
cargo add rusb

Obtain a device descriptor and basic information similarly to lsusb. The rusb crate documentation describes this well. Use Context and its open_device_with_vid_pid method:

use rusb::{Context, UsbContext};

const VENDOR: u16 = 0x37fa;
const DEVICE: u16 = 0x8201;

fn main() {
    let context = Context::new().expect("cannot open libusb context");
    let device = context
        .open_device_with_vid_pid(VENDOR, DEVICE)
        .expect("cannot get device");
    let descriptor = device
        .device()
        .device_descriptor()
        .expect("cannot describe device");

    println!("{descriptor:#?}");
}

Running this outputs:

DeviceDescriptor {
    bLength: 18,
    bDescriptorType: 1,
    bcdUSB: 272,
    bDeviceClass: 0,
    ...
}

Debugging Joys

With device access established, write a simple payload. First, request the interface. Since lsusb showed interface ID (bInterfaceNumber) 0, use claim_interface:

const INTERFACE: u8 = 0x0;

fn main() {
    // ...
    device
        .claim_interface(INTERFACE)
        .expect("unable to claim interface");
}

Running this produces:

thread 'main' panicked at src/main.rs:15:10:
unable to claim interface: Busy

The kernel's standard driver holds the device. The four-character error message "Busy" means someone already has it open. To resolve this, detach the kernel driver:

fn main() {
    // ...
    if device
        .kernel_driver_active(INTERFACE)
        .expect("cannot get kernel driver")
    {
        device
            .detach_kernel_driver(INTERFACE)
            .expect("cannot detach kernel driver");
    }

    device
        .claim_interface(INTERFACE)
        .expect("unable to claim interface");
}

Note that the kernel driver doesn't automatically reattach, so call device.attach_kernel_driver(INTERFACE) to restore it.

Transmitting Data to the Device

Nearly ready to write bytes! The IDE suggests three write methods: write_bulk, write_control, and write_interrupt. These correspond to three of four USB endpoint types. Since "USB in a NutShell" explains each type, and the earlier lsusb output showed interrupt endpoints, use write_interrupt on endpoint 0x02.

To make the device glow red, send 02 00 c0 to endpoint 0x02, followed by 64 repetitions of 0f ff 0f. Define a timeout since rusb exposes only blocking libusb APIs:

use std::time::Duration;

const ENDPOINT_OUT: u8 = 0x02;
const ENDPOINT_IN: u8 = 0x82;
const TIMEOUT: Duration = Duration::from_secs(1);

fn main() {
    // ...
    device
        .claim_interface(INTERFACE)
        .expect("unable to claim interface");

    let command: [u8; 3] = [0x02, 0x00, 0xc0];
    let color: [u8; 3] = [0x0f, 0xff, 0x0f];
    let body: Vec<u8> = command
        .into_iter()
        .chain(color.into_iter().cycle().take(192))
        .collect();

    device
        .write_interrupt(ENDPOINT_OUT, &body, TIMEOUT)
        .expect("unable to write to device");
}

Important disclaimer: Writing arbitrary data in patterns reverse-engineered from packet analysis represents risky behavior. Described actions may cause irreversible device damage or worse. Experiment only with replaceable equipment and body parts.

Running this command makes the Pegboard glow red!

Regarding Interrupts

Running the binary a second time causes the firmware to crash, then return to default animation. The device sends a response that wasn't read. "Interrupts" require handling as they arrive. USB specifications require the host to poll for interrupts — devices cannot interrupt the host independently.

For this simple driver, poll the device immediately after writing:

fn main() {
    // ...
    device
        .write_interrupt(ENDPOINT_OUT, &body, TIMEOUT)
        .expect("unable to write to device");

    let mut buf = [0_u8; 64];
    device
        .read_interrupt(ENDPOINT_IN, &mut buf, TIMEOUT)
        .expect("unable to read from device");

    dbg!(buf);
}

USB specification detail: Endpoint OUT with ID 0x02 (binary 0b0000_0010) corresponds to IN with ID 0x82 (binary 0b1000_0010) — this relationship is specification-defined.

The buffer contains [130, 0, 1, 0...], corresponding to 0x82 0x00 0x01. Now that interrupt buffers clear each time, run repeatedly to set single colors.

System Improvements

However, the device generates other interrupts. The Desk Dock has one button supporting single-click, double-click, or hold — each generates different interrupts. A background task should actively poll for interrupts and handle them as they arrive.

While async Rust with tokio and channels suits real drivers, use std::thread::scope instead:

use std::thread;

const WRITE_TIMEOUT: Duration = Duration::from_secs(1);
const READ_TIMEOUT: Duration = Duration::from_millis(1);

fn main() {
    // ...
    thread::scope(|s| {
        s.spawn(|| {
            device
                .write_interrupt(ENDPOINT_OUT, &body, WRITE_TIMEOUT)
                .expect("unable to write to device");
        });
        s.spawn(|| {
            loop {
                let mut buf = [0_u8; 64];
                match device.read_interrupt(ENDPOINT_IN, &mut buf, READ_TIMEOUT) {
                    Ok(_) => println!("Interrupt: {}", buf[0]),
                    Err(rusb::Error::Timeout) => continue,
                    Err(e) => panic!("{e:?}"),
                }
            }
        });
    });
}

Running produces:

Interrupt: 130
^C

This works! While the driver no longer sends color frames, two threads now exist — one for changing displayed colors, another for reading interrupts.

This device exhibits quirks: it requires steady color frame streams; otherwise, it enters "offline mode," ignoring new host frames. Initial frame brightness is significantly lower than subsequent frames. Despite protocol documentation claiming RGB format, colors appear to use GRB instead. Making the device excessively bright causes reboot after seconds — part of the coding pleasure.

This proof-of-concept demonstrates that writing simple device drivers isn't difficult; approximately fifty lines of code achieve considerable functionality. Over coming weeks, I hope to refine the prototype, create a simple GUI, package everything, and share with the two Linux users possessing this device. I appreciate learning USB device driver reverse-engineering basics and applying them to write a custom driver — despite the option of simply requesting specifications from the manufacturer.

FAQ

What is this article about in one sentence?

This article explains the core idea in practical terms and focuses on what you can apply in real work.

Who is this article for?

It is written for engineers, technical leaders, and curious readers who want a clear, implementation-focused explanation.

What should I read next?

Use the related articles below to continue with closely connected topics and concrete examples.