I’ve used Linux daily for over a decade—drawn in by its customization, its reliability, and the open philosophy around the kernel and OS utilities that makes the whole thing tick. But after all these years, and despite my software engineering background, I never got involved with its development, and most of my dev experience was around user space: web applications, cloud infrastructure, containers, configuration management. The kernel and open source utilities were this mythical layer I depended on but never touched.

That changed because I wanted to play video games.

This is the story of how a broken gamepad led me to write my first Linux kernel patch with the help of Kiro IDE, how I navigated the mailing list etiquette, and what this deep dive into hardware taught me as a developer.

The Problem: A Broken Gamepad Link to heading

It all started with a HORI Wireless Switch Pad (vendor 0x0f0d, product 0x00f6) that my wife gave me a couple years ago. It’s a great, officially licensed third-party Nintendo Switch controller which I wanted to repurpose for my PC games. So there I was, pairing it via Bluetooth to my NixOS machine, expecting it to just work, just to realize that things weren’t going to be that simple.

First, the buttons were completely scrambled. Pressing the Left Shoulder button (L) triggered BTN_WEST (the Y button) instead of BTN_TL. The triggers and minus buttons were all shifted. Games were unplayable.

Next, the included LED lights that indicate both the established connection and the player number were spinning like Mario running after touching a flame on Mario 64.

Digging into dmesg and evdev, I realized the problem: Linux was treating it as a generic Bluetooth controller using the hid-generic driver.

First-party Switch controllers are handled by a dedicated kernel module called hid-nintendo, which maps the quirky Switch protocol into standard Linux gamepad inputs. Because my HORI pad had a third-party vendor ID, hid-nintendo completely ignored it.

I had a choice: map the buttons in user space via a messy wrapper script, or fix the root cause in the kernel. I chose the kernel.

Setting Up the Environment: The NixOS Way Link to heading

On most Linux distros, kernel development starts with installing dev dependencies such as GCC, running make menuconfig, and eventually make install to drop a new kernel into your bootloader. NixOS doesn’t work that way—the filesystem is read-only, there’s no /usr/src/linux, and you can’t just install packages imperatively.

But NixOS gives you something better: Nix flakes. I created a flake.nix that declares the exact toolchain, libraries, and kernel headers I need. Running nix develop drops me into an isolated shell with everything pinned and reproducible:

{
  description = "Kernel module dev shell";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";

  outputs = { self, nixpkgs }: {
    devShells.x86_64-linux.default = let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in pkgs.mkShell {
      nativeBuildInputs = with pkgs; [
        gcc gnumake flex bison pahole kmod
        linuxPackages_latest.kernel.dev
      ];
      shellHook = ''
        export KDIR=${pkgs.linuxPackages_latest.kernel.dev}/lib/modules/*/build
      '';
    };
  };
}

No host contamination, no mismatched GCC versions, no “works on my machine”—just a clean, disposable environment ready for kernel hacking.

The one NixOS-specific trick I needed was getting the kernel development headers for my running kernel. Unlike traditional distros where you apt install linux-headers-$(uname -r), NixOS requires building (or fetching from cache) the .dev output of the kernel package:

# Fetch the kernel dev headers matching your running kernel
nix-build '<nixpkgs>' -A linuxPackages_latest.kernel.dev
# Output: /nix/store/<hash>-linux-7.0.5-dev

# Export KDIR so the build system finds them
export KDIR=/nix/store/<hash>-linux-7.0.5-dev/lib/modules/7.0.5/build

This KDIR path provides the .config, Module.symvers, and all the headers that kbuild needs to compile out-of-tree modules with the correct vermagic—ensuring your compiled .ko file will actually load on the running kernel.

Learning by Doing: Writing a Standalone Driver Link to heading

Before touching the massive, 3000-line hid-nintendo codebase, I wanted to understand how Linux HID drivers actually work. The best way to learn was to ask Kiro to write a tiny, standalone driver from scratch—hid-hori.

How Linux Drivers Work (The 30-Second Version) Link to heading

A Linux kernel module is essentially a struct full of function pointers that you register with a subsystem. For HID devices, you fill in a struct hid_driver with callbacks like .probe (called when your device connects), .remove (called when it disconnects), and .input_mapping (called when the kernel needs to know what a button means):

static struct hid_driver hori_driver = {
    .name           = "hori",
    .id_table       = hori_devices,      /* which hardware to claim */
    .input_mapping  = hori_input_mapping, /* fix button codes */
    .probe          = hori_probe,         /* initialize on connect */
    .remove         = hori_remove,        /* clean up on disconnect */
};
module_hid_driver(hori_driver);

You also provide a device table—a list of vendor/product IDs that tells the kernel “wake me up when you see this hardware.”

When a matching device appears on the bus, the kernel calls your probe function. That’s your chance to parse the device’s report descriptor, start the HID transport, send initialization commands, and register input devices:

static int hori_probe(struct hid_device *hdev, const struct hid_device_id *id)
{
    /* Allocate driver state — automatically freed on device removal */
    ctlr = devm_kzalloc(&hdev->dev, sizeof(*ctlr), GFP_KERNEL);

    /* Parse the HID report descriptor (buttons, axes, etc.) */
    hid_parse(hdev);

    /* Start the transport and create input devices */
    hid_hw_start(hdev, HID_CONNECT_DEFAULT);

    /* Send the player LED command to the hardware */
    hori_set_player_leds(hdev, 0x01);

    return 0;
}

From that point on, the kernel routes incoming HID reports through your callbacks, and you translate raw bytes into input events that userspace applications understand.

The beauty of this model is that you can insmod your module into a running kernel and rmmod it out—no reboot required. The kernel handles the lifecycle, memory cleanup (via devm_* managed allocations), and device matching automatically.

Claiming the Device Link to heading

The first step was telling the kernel: “If you see this specific hardware, let my code handle it instead of hid-generic.”

Every HID device on Linux is identified by its bus type, vendor ID, and product ID. The hid_device_id table is what the kernel uses to match hardware to drivers. When a Bluetooth device with vendor 0x0f0d and product 0x00f6 appears, the kernel checks all loaded drivers’ tables and finds mine. The MODULE_DEVICE_TABLE macro exports this table so that modprobe can auto-load the module when the hardware is detected—no manual insmod needed after installation.

#define USB_VENDOR_ID_HORI                      0x0f0d
#define USB_DEVICE_ID_HORI_WIRELESS_SWITCH_PAD  0x00f6

static const struct hid_device_id hori_devices[] = {
    { HID_BLUETOOTH_DEVICE(USB_VENDOR_ID_HORI,
                           USB_DEVICE_ID_HORI_WIRELESS_SWITCH_PAD) },
    { }  /* sentinel — marks end of the list */
};
MODULE_DEVICE_TABLE(hid, hori_devices);

Because my driver has this device in its table, hid-generic automatically backs off—its internal match function checks whether any specialized driver wants the device before claiming it.

Fixing the Scrambled Buttons Link to heading

This was the core problem. The HID specification defines buttons as numbered usages: Button 1, Button 2, Button 3, and so on. When hid-generic handles a gamepad, it maps these sequentially to evdev codes starting at BTN_GAMEPAD (304). But the evdev gamepad codes aren’t contiguous—there are gaps (306 and 309 are BTN_C and BTN_Z, rarely used). This means Button 5 on the HORI lands on BTN_WEST (308) instead of BTN_TL (310), and everything downstream is shifted.

The fix: provide an explicit lookup table that maps each HID button usage to the correct evdev code, bypassing the default math entirely.

static const unsigned int hori_switch_pad_keymap[] = {
    BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST,
    BTN_TL, BTN_TR, BTN_TL2, BTN_TR2,
    BTN_SELECT, BTN_START, BTN_THUMBL, BTN_THUMBR,
    BTN_MODE, BTN_Z,
};

The driver that Kiro wrote set an input_mapping callback that is called by the HID core once per usage in the report descriptor. For each button, it extracts the usage number, subtracts 1 (HID usages are 1-based), and looks up the correct code. Returning 1 tells the HID core “I handled this—don’t apply the default mapping.”

static int hori_input_mapping(struct hid_device *hdev, struct hid_input *hi,
                              struct hid_field *field, struct hid_usage *usage,
                              unsigned long **bit, int *max)
{
    unsigned int index = (usage->hid & HID_USAGE) - 1;
    if (index >= HORI_KEYMAP_SIZE) return 0; /* let HID core handle it */

    hid_map_usage_clear(hi, usage, bit, max, EV_KEY,
                        hori_switch_pad_keymap[index]);
    return 1; /* we handled this mapping */
}

Setting the Player LED Link to heading

The spinning LEDs were driving me crazy. After asking Kiro to research the Nintendo Switch controller protocol (thanks to the incredible reverse engineering work by the community), I learned the HORI responds to a subset of the Nintendo Switch subcommand protocol.

The protocol works by sending HID output reports with a specific structure: a report type byte, a packet counter, 8 bytes of rumble data (zeros = silent), and then the subcommand ID followed by its payload. For setting player lights, the subcommand is 0x30 and the payload is a single byte bitmask where each bit corresponds to one of the four LEDs:

static int hori_set_player_leds(struct hid_device *hdev, u8 leds)
{
    u8 buf[12] = { 0 };
    buf[0]  = 0x01;  /* report type: rumble + subcommand */
    buf[1]  = 0;     /* packet counter (not critical for one-shot) */
    /* buf[2..9] = rumble data, all zeros */
    buf[10] = 0x30;  /* subcmd: set player lights */
    buf[11] = leds;  /* bitmask: bit 0 = LED 1, bit 1 = LED 2, etc. */

    return hid_hw_output_report(hdev, buf, sizeof(buf));
}

Passing 0x01 sets only the first LED (Player 1 pattern: *---). The moment this packet hit the controller, the spinning animation stopped and a single solid LED appeared. A small thing, but enormously satisfying—my first successful communication with hardware from kernel space.

Testing Without Rebooting Link to heading

One of the coolest things about Linux driver development is that you don’t have to reboot to test. I compiled my module against the running kernel’s headers and hot-swapped it:

# Compile against the running kernel (from the hid-hori-build directory)
make -C "$KDIR" M=$PWD modules

# Load the module
sudo insmod hid-hori.ko

# Connect the controller — hid-generic automatically backs off
# because our driver has the device in its id_table
bluetoothctl connect 30:31:7D:8D:45:BB
sudo evtest /dev/input/event25

Pressing L now showed BTN_TL (310). The LED stopped spinning and showed Player 1. It worked.

Pivoting to the Real Patch Link to heading

My standalone hid-hori driver worked flawlessly, but I quickly realized it was the wrong approach for upstreaming. The HORI controller speaks the same underlying Nintendo Switch protocol as the official Pro Controller—hid-nintendo already had 95% of the logic I needed (stick calibration, IMU support, battery reporting, rumble infrastructure).

Instead of duplicating thousands of lines of protocol handling, I needed to teach hid-nintendo about my controller’s quirks.

Discovering the Controller’s Identity Link to heading

After adding the HORI’s device ID to hid-nintendo and enabling dynamic debug, Kiro spotted the key line in dmesg logs:

nintendo 0005:0F0D:00F6.000B: controller type = 0x06

The HORI reports itself as controller type 0x06, while first-party Pro Controllers report 0x03. This single byte—returned by the device info subcommand during initialization—was the key to everything. The driver didn’t recognize 0x06, so it skipped all input configuration and produced an empty device.

The Changes to hid-nintendo Link to heading

With AI assistance from Kiro IDE, I identified and implemented the minimal set of changes:

  1. New controller type: Added JOYCON_CTLR_TYPE_LIC_PRO = 0x06 to the enum and made joycon_type_is_procon() recognize it.

  2. Default stick calibration: The HORI’s SPI flash contains incompatible calibration data that causes severe stick drift. For LIC_PRO controllers, we skip the SPI read and use safe defaults.

  3. Swapped X/Y buttons: The HORI has bits 0 and 1 swapped compared to Nintendo’s layout. A dedicated lic_procon_button_mappings table fixes this.

  4. Non-fatal timeouts: The HORI lacks rumble hardware and sometimes fails IMU initialization. These errors are logged but shouldn’t abort the probe.

In drivers/hid/hid-ids.h, I registered the device:

#define USB_VENDOR_ID_HORI                      0x0f0d
#define USB_DEVICE_ID_HORI_WIRELESS_SWITCH_PAD  0x00f6

In drivers/hid/hid-nintendo.c, the controller type enum gained a new entry:

enum joycon_ctlr_type {
    JOYCON_CTLR_TYPE_JCL  = 0x01,
    JOYCON_CTLR_TYPE_JCR  = 0x02,
    JOYCON_CTLR_TYPE_PRO  = 0x03,
    JOYCON_CTLR_TYPE_LIC_PRO = 0x06, /* Licensed third-party Pro Controllers */
    ...
};

The type check function now recognizes both:

static inline bool joycon_type_is_procon(struct joycon_ctlr *ctlr)
{
    return ctlr->ctlr_type == JOYCON_CTLR_TYPE_PRO ||
           ctlr->ctlr_type == JOYCON_CTLR_TYPE_LIC_PRO;
}

The hid-nintendo driver manages button mappings through button mapping tables. This allows it to support multiple controller types with the same code logic. So a dedicated button mapping table handles the X/Y swap:

/* Licensed Pro Controllers (e.g. HORI) swap X/Y bits in the report */
static const struct joycon_ctlr_button_mapping lic_procon_button_mappings[] = {
    { BTN_EAST,   JC_BTN_A,     },
    { BTN_SOUTH,  JC_BTN_B,     },
    { BTN_NORTH,  JC_BTN_Y,     }, /* swapped vs first-party */
    { BTN_WEST,   JC_BTN_X,     }, /* swapped vs first-party */
    { BTN_TL,     JC_BTN_L,     },
    { BTN_TR,     JC_BTN_R,     },
    ...
    { /* sentinel */ },
};

And the initialization path gracefully handles timeouts:

/* Enable rumble */
ret = joycon_enable_rumble(ctlr);
if (ret) {
    if (ctlr->ctlr_type == JOYCON_CTLR_TYPE_LIC_PRO) {
        hid_dbg(hdev, "rumble enable failed, continuing\n");
        ret = 0; /* non-fatal: HORI has no vibration motor */
    } else {
        hid_err(hdev, "Failed to enable rumble; ret=%d\n", ret);
        goto out_unlock;
    }
}

Compiling Against the Running Kernel Link to heading

However, there was one gotcha trying to compile hid-nintendo out of tree. As Boromir would say, one does not simply point kbuild at drivers/hid/ and compile. Doing so, tried to build every .c file in that directory, including hid-core.c which had API differences between the source tree and my running kernel’s headers.

The solution: copy just the modified files into an isolated build directory with a minimal Makefile:

mkdir -p build-hid-nintendo
cp drivers/hid/hid-nintendo.c build-hid-nintendo/
cp drivers/hid/hid-ids.h build-hid-nintendo/

cat > build-hid-nintendo/Makefile << 'EOF'
obj-m += hid-nintendo.o
ccflags-y += -I$(M)
EOF

The ccflags-y += -I$(M) tells the compiler to look in our directory first for hid-ids.h (with the HORI defines) before the kernel’s copy. Then build against the running kernel’s headers:

make -C "$KDIR" M=$(pwd)/build-hid-nintendo modules

Then, we remove the current hid_nintendo module, and load the patched one:

# Load the dependency module and swap
sudo modprobe -r hid_nintendo
sudo modprobe ff-memless
sudo insmod build-hid-nintendo/hid-nintendo.ko

# Reconnect and test
bluetoothctl connect 30:31:7D:8D:45:BB
sudo dmesg | grep nintendo

The Magic of NixOS: Deploying Permanently Link to heading

Once the driver was stable, I didn’t want to manually insmod after every reboot. NixOS made permanent deployment trivial:

boot.kernelPatches = [
  {
    name = "hid-nintendo-hori-support";
    patch = ./patches/hori-support-v2.patch;
  }
];

One nh os switch later, NixOS automatically fetched the mainline kernel source, applied my patch, compiled the entire kernel in an isolated sandbox, and updated my bootloader. It took about 30 minutes, but once done, my daily driver had native gamepad support—with automatic rollback if anything went wrong.

Now I can play with my Hori pad

Now I can play with my Hori pad

The Patch Submission Process Link to heading

Paraphrasing Boromir once again, one does not simply git push into mainline Linux. The kernel doesn’t use GitHub-like Pull Requests. It uses plain-text emails sent to specialized mailing lists.

Kiro introduced me to b4, a modern tool that manages the entire patch lifecycle—branch preparation, recipient discovery from the MAINTAINERS file, version tracking, and threading. The workflow looked like:

# Create a b4-managed branch
b4 prep -n hori-support -f for-next

# Auto-discover who should receive the patch
b4 prep --auto-to-cc

# Send (b4 handles threading, versioning, everything)
b4 send --no-sign

Code Review and Upstreaming Link to heading

Once the email was sent, automated CI systems like sashiko-bot picked up the patch, compiled it across dozens of architectures, and ran static analysis. It found a real bug: I wasn’t clearing the error code after non-fatal timeouts, which could cause intermittent probe failures. A v2 setting ret = 0 fixed it.

Then came the human review. I had submitted during -rc5—late in the cycle when maintainers focus on bug fixes, not new features. Kiro had warned me about this, but the kernel community was welcoming.

After a bit of back-and-forth, the email I’d been waiting for arrived:

Applied, thanks.

My code was accepted into the HID subsystem tree, queued to ship in Linux 7.2.

Lessons Learned Link to heading

The Kernel is Just Code: It’s intimidating, but at the end of the day, it’s just logic. If you can trace a bug in a complex web app, you can trace a button mapping issue in a C struct.

Start Small, Then Integrate: Writing a standalone 150-line driver taught me HID fundamentals without the complexity of hid-nintendo. Once I understood the protocol, integrating into the real driver was straightforward.

AI is a Collaborator, Not an Author: Kiro IDE didn’t fix my controller—I drove every decision, from the initial approach to the final submission. What Kiro really did was automate my research: surfacing kernel documentation, explaining unfamiliar macros like DEFINE_IDA and devm_kzalloc, generating code drafts that I validated against real hardware, and guiding me through the checkpatch.pl and b4 workflows. It got things wrong too—assumptions about Bluetooth hostnames, overcomplicated fallback paths—and I caught those through testing. In the end, the AI accelerated my understanding at each step, but the engineering judgment was mine.

NixOS is a Superpower for Kernel Work: A single flake.nix gave me a reproducible, isolated dev environment with the exact toolchain and headers I needed. Once the patch was ready, boot.kernelPatches let me deploy it declaratively—version-controlled, reviewable, and trivially removable. And if the patched kernel ever broke something, I could select the previous generation at boot and be back to a working system in seconds.

Hardware bugs teach you more than software bugs: Tracing a misbehaving controller through the Bluetooth stack, HID subsystem, and input layer gave me a deeper understanding of Linux than years of userspace development ever did.

Having the capability and the confidence to drop down into the OS kernel means I no longer have to wait for someone else to fix my hardware problems. I can fix them myself, from the bare metal all the way up.

Even if it starts with just trying to play a video game.