# Teaching a Gigabyte Keyboard to Speak Linux


## The Ache of a Dark Keyboard

I bought a Gigabyte Aero X16 with every intention of making it my "daily driver that happens to also run builds without crying." On Windows, the first thing that happened when I powered it on was theatrical: the keyboard lit up in a wave of cyan, like a little LED marching band, and the Gigabyte Control Center cheerfully insisted that I pick between a dozen RGB effects. It felt like a keyboard that wanted to talk to me.

Then I installed Linux, and the keyboard shut up. Not a flicker. Not a shimmer. Nothing.

To be fair, the machine still typed. Keys still made characters. But there's a particular kind of ache peculiar to developers who paid for RGB and then spent an evening staring at dim plastic. The ache nudged a thought: if the hardware still works and the OS is the only thing that changed, then somewhere between my user session and the keyboard firmware there is a conversation that simply isn't happening — and conversations that aren't happening can, in principle, be started. I looked at existing tools: there is a wonderful Windows project called [`wtwrp/aeroctl`](https://gitlab.com/wtwrp/aeroctl) that does everything one could want, if one is willing to also run Windows. I was not. So I did what you do: I opened a terminal, muttered something unprintable about `lsusb`, and started poking.

A few evenings later, I had a small Python project I called [`aeroctl-linux-indicator`](https://github.com/misaelzapata/aeroctl-linux-indicator). It is 593 lines of source across the package (`device.py` + `cli.py` + `tray.py`), it is around 80% covered by tests, it ships a CLI and a tray indicator, and its entire purpose is to convince an ITE-829X controller buried inside a Gigabyte chassis that Linux is, in fact, a legitimate operating system deserving of colored light.

This is the story of how I got there. It is also the story of how one commit — `68dbdae`, "chore: remove unsupported RGB effects" — is the saddest line in the repo.

That commit, though, is the end of the thread. The beginning of it is a more innocent question — one I only asked *after* a couple of evenings of disappointment with other people's answers: what is actually plugged into the USB bus that I haven't talked to yet, and how would I even know?

## The Prior Art: Four Projects, None of Them for This Laptop

Before I wrote a single line of Python, I did the thing every engineer is supposed to do and went looking for existing work. The disappointing-slash-encouraging news is that there is quite a lot of prior art on "Gigabyte laptop RGB keyboard from Linux." The disappointing part is that none of it worked on *my* specific laptop out of the box. The encouraging part is that each one of them taught me something I ended up using.

Here is the tree I walked before I walked away.

### wtwrp/aeroctl — the name I borrowed

[`wtwrp/aeroctl`](https://gitlab.com/wtwrp/aeroctl) is the original AeroCtl, a C# / .NET application for Windows. It is the project that gave mine its name, and reading it is the easiest way to understand what a well-supported Gigabyte Aero looks like from the host side: fan control, charging policy, non-standard Fn keys, keyboard RGB, GPU boost, all going through Gigabyte's ACPI WMI driver (`acpimof.dll` and friends). The RGB section in particular is informative: it confirms that the keyboard is reached through a HID feature report and not through some proprietary IO port or WMI blob.

The problem is obvious: it's Windows. The whole premise is that you already installed Gigabyte ControlCenter or SmartManager so `acpimof.dll` is present. None of that exists on my laptop, which runs Linux exclusively. But the RGB sample code in `Samples/` — the part that's just "open the HID device, write a feature report, close it" — is portable in spirit. That's the piece I carried over.

### AeroControlCenter — the Linux port that asked for a kernel driver

[`tangalbert919/AeroControlCenter`](https://github.com/tangalbert919/AeroControlCenter) is a Linux port of Gigabyte Control Center, written in C++/Qt, targeted at the Aero 15 Classic (SA/WA/XA/YA). Its approach is more ambitious than mine: it wants to handle fan control, battery policy, *and* RGB, and to do it cleanly it depends on a companion kernel driver, [`gigabyte-laptop-wmi`](https://github.com/tangalbert919/gigabyte-laptop-wmi), that exposes the WMI interface as Linux sysfs nodes.

I admire the architecture and could not use it. My machine isn't in the tested list, `gigabyte-laptop-wmi` doesn't bind cleanly on modern kernels on my chassis, and I did not have the appetite to debug a WMI ACPI table on a laptop I also needed to keep working for email. What AeroControlCenter *did* give me, though, was the `70-keyboard.rules` file — which is to say, the confirmation that the udev approach was the right shape for the permissions problem. I wrote my own rule, but I wrote it because that project's rule told me where to put it.

### fusion-kbd-controller — libusb, kernel unbind, and a warning

[`martin31821/fusion-kbd-controller`](https://github.com/martin31821/fusion-kbd-controller) is a tiny C userspace binary using libusb, targeting the AERO 15X. Reading the README is a ride: it needs root, it temporarily unbinds the USB device from the kernel's `usbhid` driver, and the author cheerfully warns that "it's possible to brick your keyboard when sending bogus values here."

It works, on a 15X, with libusb, with kernel detach. I wanted none of those things. I wanted `/dev/hidraw` so I could talk to the device while the kernel kept owning the HID interface (so the keys kept typing while I was sending color commands), I wanted a udev rule so I wouldn't need root, and I *really* didn't want to brick my own keyboard learning what "bogus values" meant to someone else's hardware. But fusion-kbd-controller is where I picked up the mental model of "packet of bytes with a mode and a parameter region," which turned out to generalize to the ITE-829X feature-report packet I ended up writing.

### keyboard-fusion-rgb — right language, wrong vendor

[`rcassani/keyboard-fusion-rgb`](https://github.com/rcassani/keyboard-fusion-rgb) is a Python HIDAPI driver for the AORUS 15G. It is the closest thing to "what I ended up doing" — Python, HID, userspace, reverse-engineered from the Windows AORUS Control Center — except for one detail: the vendor/product IDs are `0x1044:0x7a3c` (Chu Yuen Enterprise Co., Ltd.), which is a completely different keyboard controller family from the one in my laptop (`0x0414:0x8104`, the ITE-829X).

So the code cannot be used as-is — the wire format is different, the per-key layout is different, the mode IDs are different. But the *approach* is exactly the one I wanted: HIDAPI-style feature reports from Python, decoded by watching what the Windows tool writes. That project's existence was also a moral nudge: if `rcassani` could decode one Gigabyte sub-family by watching USB traffic, I could decode the one on my laptop the same way.

### OpenRGB — the giant that doesn't yet know this machine

[`CalcProgrammer1/OpenRGB`](https://gitlab.com/CalcProgrammer1/OpenRGB) is the universal RGB tool — Windows, Linux, macOS, every motherboard and RAM vendor under the sun. It is the project I wanted to be able to use and couldn't. Their [open issue #2288](https://gitlab.com/CalcProgrammer1/OpenRGB/-/issues/2288) on the Gigabyte Aero 15 OLED YD is, essentially, "we don't have this model yet." My laptop is a different SKU than that issue, but close enough that I knew the answer was the same: not there yet.

I mention OpenRGB explicitly because in a better world this post doesn't exist — I would have opened their settings panel, picked an effect, and moved on. Instead I'm going to decode a packet, write 593 lines of Python, and pretend I'm fine with this.

### What I took from the tree

The summary of an hour's research looked something like this:

```
  prior art                         usable for my laptop?
  ----------------------------      ---------------------
  wtwrp/aeroctl (Windows, C#)       no  (wrong OS, WMI-based)
                                    yes — packet shape intuition
  AeroControlCenter (Linux, Qt)     no  (different model; wants kernel driver)
                                    yes — udev rule as the permissions story
  fusion-kbd-controller (C, libusb) no  (wrong model; kernel unbind; root only)
                                    yes — "mode byte + params + checksum" mental model
  keyboard-fusion-rgb (Py, HIDAPI)  no  (different vendor, 0x1044 vs 0x0414)
                                    yes — Python + HID is the right shape
  OpenRGB (C++, everywhere)         no  (my SKU not yet supported)
                                    yes — confirmation that nobody else had this
```

Which is the trick of prior-art research in this corner of the world: the answer to "is there something for my machine" is almost always *no*, but the answer to "what have other people figured out that I should not re-derive" is almost always *something*. I closed the browser tabs with a half-formed plan: Python, `/dev/hidraw`, a udev rule so I don't need sudo, a packet that is probably "mode + params + checksum8", and — the big risk — a checksum algorithm I'd have to guess because none of the prior art uses this exact controller.

Which brought me back to the innocent question: what is *actually* on my USB bus, and what does it want to hear?

## Reverse-Engineering the Keyboard

The first thing I learned is that reverse-engineering a USB peripheral is 10% clever detective work and 90% reading `dmesg` output while your spouse asks what you're doing at 1am. The Gigabyte Aero's keyboard isn't a separate dongle, of course — it's an internal USB device, and `lsusb | grep 0414` obligingly confirmed it:

```
Bus 001 Device 003: ID 0414:8104 Gigabyte Technology Co.,Ltd ...
```

Vendor `0x0414`, product `0x8104`. That's the ITE-829X. I Googled everything I could find, cross-referenced it with the Windows tool's sources, and formed a hypothesis: the RGB control lives behind a HID feature report, exposed on an interface whose usage page is `0xFF01` — a vendor-defined HID page, which is the USB equivalent of a dirt road marked "private."

### Finding the right `/dev/hidraw`

Here is where reverse engineering stops feeling like magic and starts feeling like filesystem archaeology. Linux exposes every HID device under `/sys/bus/hid/devices/`, and for each device it hands you a `report_descriptor` — a blob of bytes telling you (if you squint) what the device is willing to talk about.

I wrote a scanner. Given the right vendor/product pair, it globs the sysfs directory, reads every `report_descriptor`, and looks for the three bytes `\x06\x01\xff` — HID-speak for "usage page `0xFF01`." When I find a match, I follow the symlink into its `hidraw` subdirectory and grab the `/dev/hidrawN` node that belongs to it.

Think of udev + sysfs here as a sort of `/proc` for peripherals: a read-only living document describing what the kernel currently believes about every device plugged in. And like `/proc`, the correct way to use it is not to trust any particular device number — `/dev/hidraw0` today might be `/dev/hidraw3` tomorrow if you plug in a mouse — but to scan, filter, and pick by property.

```python
# src/aeroctl_linux_indicator/device.py:99-119
@staticmethod
def _find_hidraw(vendor_id=VID_DEFAULT, product_id=PID_DEFAULT, usage_page_bytes=b'\x06\x01\xff'):
    """Find the hidraw node with Usage Page 0xFF01 by scanning /sys."""
    pattern = "/sys/bus/hid/devices/0003:%04X:%04X.*" % (vendor_id, product_id)
    for path in sorted(glob.glob(pattern)):
        rd_path = os.path.join(path, "report_descriptor")
        try:
            with open(rd_path, "rb") as f:
                rd = f.read()
        except OSError:
            continue
        if usage_page_bytes in rd:
            hidraw_dir = os.path.join(path, "hidraw")
            try:
                entries = os.listdir(hidraw_dir)
                if entries:
                    devpath = "/dev/" + entries[0]
                    if os.path.exists(devpath):
                        return devpath
            except OSError:
                continue
    return None
```

The `0003:` prefix in the glob pattern is worth a pause. HID devices are registered by the kernel with bus IDs, and `0003` is USB. The full pattern expands, for my machine, to `/sys/bus/hid/devices/0003:0414:8104.*`. The `*` is there because a single physical device can expose several HID interfaces — one for the normal keys, one for media keys, one for the vendor control channel — and I want the one whose report descriptor advertises `0xFF01`.

Here's the discovery flow I ended up with:

```
                 ┌──────────────────────────────────────────┐
                 │  /sys/bus/hid/devices/0003:0414:8104.*   │
                 │  glob-expand (one entry per HID iface)   │
                 └─────────────────────┬────────────────────┘
                                       │
                                       ▼
                 ┌──────────────────────────────────────────┐
                 │  open report_descriptor, scan for        │
                 │  b'\x06\x01\xff'  ==  usage page 0xFF01  │
                 └─────────────────────┬────────────────────┘
                                       │ match
                                       ▼
                 ┌──────────────────────────────────────────┐
                 │  ls <iface>/hidraw/  ->  "hidraw0"       │
                 │  devpath = "/dev/" + entry               │
                 └─────────────────────┬────────────────────┘
                                       │
                                       ▼
                 ┌──────────────────────────────────────────┐
                 │  os.open(devpath, O_RDWR)                │
                 │  fcntl.ioctl(fd, HIDIOCSFEATURE(9), buf) │
                 └──────────────────────────────────────────┘
```

Once that hidraw path is in hand, everything else is `fcntl.ioctl`. The `HIDIOCSFEATURE` and `HIDIOCGFEATURE` ioctls are the standard way to send and receive HID feature reports from userspace. I hand-rolled the magic numbers because I didn't want to depend on `hidapi`:

```python
# src/aeroctl_linux_indicator/device.py:56-62
def _ioc(direction, typ, nr, size):
    return (direction << 30) | (size << 16) | (ord(typ) << 8) | nr

HIDIOCSFEATURE = lambda n: _ioc(3, 'H', 0x06, n)
HIDIOCGFEATURE = lambda n: _ioc(3, 'H', 0x07, n)

REPORT_SIZE = 9
```

The `AeroKeyboardRGB` class (`src/aeroctl_linux_indicator/device.py:77-240`) is the whole hardware abstraction in one place: open the device, write 9-byte packets, read 9-byte responses, close on exit. It's also a context manager, because I like my file descriptors the way I like my expensive laptops: closed when I'm done with them.

Having a file descriptor is one thing. Having the *right bytes to send down it* is another, and that is where I hit the first wall that looked like a puzzle instead of a plumbing problem — which, it turns out, is exactly when reverse engineering gets interesting.

## The Packet: Nine Bytes and a Non-XOR Checksum

Now, the packet. This is the funny bit.

You might expect — I certainly did — that a modest 9-byte control packet for an LED controller would check itself with XOR. Everybody uses XOR. XOR is cheap, XOR is what the firmware engineer reaches for when it's 4pm on a Friday and they want to go home. I stared at Wireshark captures from the Windows tool. I wrote a little Python script to try XOR over every possible slice of the packet. None of it matched.

I tried the simple sum. Also no.

I tried `256 - sum`. Close. Suspiciously close. Off by one.

The answer, it turned out, is this:

```
checksum = (0xFF - (sum(bytes[1..7]) & 0xFF)) & 0xFF
```

Not XOR. **Not XOR.** It's an 8-bit ones-complement-ish thing: take the sum of the payload bytes, mask it to a byte, then subtract from `0xFF`. A "make the whole thing total `0xFF`" checksum. Which is perfectly reasonable, in the way that all hardware protocols are perfectly reasonable once you stop expecting them to be.

If you're thinking "ah, that's just `~sum + 1 - 1`, isn't it?" — yes, and also, no, stop that, it isn't a two's complement. It's a "sum plus checksum should equal `0xFF`" invariant. Which is neat, if slightly unusual, and the first rule of reverse-engineering is: do what the hardware does, even if the hardware is a little eccentric.

Here's the entire implementation:

```python
# src/aeroctl_linux_indicator/device.py:94-96
@staticmethod
def _checksum8(data) -> int:
    return (0xFF - (sum(data) & 0xFF)) & 0xFF
```

Two lines, plus a decorator. The `& 0xFF` on the outside is belt-and-suspenders — arithmetic in Python is unbounded, and I want this to fit in a byte regardless of how many bytes I pass in.

The packet layout itself is this:

```
  Offset  ┬───────────────────────────────────────────────────────────
          │
   B0     │  0x00                        ← HID report ID / padding
          │
   B1     │  command   (e.g. 0x08 = SET_EFFECT, 0x80 = GET_FIRMWARE)
   B2     │  sub-arg   (often 0x00)
   B3     │  effect id (1=static, 2=breathing, 3=wave, ...)
   B4     │  speed
   B5     │  brightness
   B6     │  color id  (0..8)
   B7     │  direction
          │
   B8     │  checksum  =  (0xFF - (sum(B0..B7) & 0xFF)) & 0xFF
          │
  ────────┴───────────────────────────────────────────────────────────
           9 bytes total, sent via HIDIOCSFEATURE ioctl on /dev/hidrawN
```

The framing choice is worth admiring for a moment. Byte 0 is the HID report ID, and because this particular device uses report ID `0`, it's effectively padding — but the Linux `hidraw` ABI always wants a report ID as the first byte of a feature report, so I carry it along. Bytes 1–7 are where the actual command lives. Byte 8 is the checksum I spent two hours being wrong about.

If you've written terminal emulators, the analogy that clicked for me was this: **a HID feature report packet is basically an ANSI escape code with a checksum.** You've got an introducer (the command byte), you've got parameters (effect, speed, brightness, color, direction), and you've got a terminator — except the terminator, instead of being a printable letter like `m` for "set graphics mode," is a checksum byte that verifies the firmware hasn't been handed garbage. Send the wrong byte and the controller politely ignores you. Send the right bytes and your keyboard becomes a disco.

The rest of `device.py` is just vocabulary. `set_effect` is `_send_feature(_packet(0x08, 0x00, effect, speed, brightness, color, direction))`. `get_firmware_version` is `_send_feature(_packet(0x80))` followed by a `_get_feature(9)` read. `reset` is `_send_feature(_packet(0x13, 0xFF))`. Each command is a line. Each line is a sentence in a language I eventually learned to speak.

Speaking that language correctly, however, assumed I could even open the file descriptor in the first place — and once I typed my sudo password for the fortieth time in a day, the next branch of the tree basically announced itself.

## udev: The Gatekeeper of Happiness

There is a special circle of Linux-desktop purgatory reserved for tools that work perfectly, but only if you run them with `sudo`. Every time I toggled the keyboard, I had to type my password. Every time the tray indicator wanted to read the current effect, it couldn't, because a normal user process can't open `/dev/hidraw0` for read-write by default. This was a dealbreaker for a "tray indicator."

The fix is a four-line `udev` rule, and it is a small masterpiece:

```bash
# install.sh:14-21
UDEV_RULE_FILE="/etc/udev/rules.d/70-gigabyte-kbd.rules"
if [ ! -f "$UDEV_RULE_FILE" ]; then
    echo 'SUBSYSTEMS=="usb", ATTRS{idVendor}=="0414", ATTRS{idProduct}=="8104", MODE="0660", TAG+="uaccess"' | sudo tee "$UDEV_RULE_FILE" > /dev/null
    sudo udevadm control --reload-rules && sudo udevadm trigger
    echo "udev rules created and applied."
else
    echo "udev rules already exist."
fi
```

The magic word here is `TAG+="uaccess"`. This tag tells systemd-logind: "whoever is currently logged in on the local seat should be allowed to access this device." Without it, `/dev/hidraw0` is owned by `root:root` at mode `0660`, and your user process bounces off it like a moth off a window.

With it, systemd adds an ACL to the device node giving your logged-in user read+write permissions, automatically, for as long as you're at the keyboard. Log out, ACL vanishes. Plug in a second Aero keyboard, ACL appears on the new one too. It is the Linux equivalent of a hotel keycard: you get access while you're the guest, the system takes it back when you leave.

I like to think of udev rules as a **love letter to future-you**. You write them once, you leave them on disk, and forever after, the machine welcomes your future self — the one who has forgotten everything about this project and just wants the keyboard to light up — as if nothing had ever been hard. The `install.sh` script makes this gift automatically on first install. You will never see the love letter again, because you will never need to.

The udev rule fixed the permissions dance, but solving it also surfaced a quieter question: now that any logged-in user could drive the device, *how* should they actually drive it? On the command line at 1am? From a tray menu while writing email? Both, ideally — and the moment I said "both" out loud, the next branch of the project laid itself out.

## Two Frontends, One Brain

Once the device layer worked, there was a decision to make: CLI or GUI? I said: why not both. But I also said: **not twice.**

The shape I ended up with is a classic "two frontends sharing one brain" — the same `AeroKeyboardRGB` class at the bottom, two very different UIs on top. Neither UI knows anything about HID. Neither UI does its own checksum math. Both of them lean on the same device class, and the only state they share is a small JSON file in the user's cache directory.

```
   ┌──────────────────────────┐    ┌─────────────────────────────┐
   │   aeroctl-kbd  (CLI)     │    │   aeroctl-kbd-tray  (GTK)   │
   │                          │    │                             │
   │   argparse, stdin/stdout │    │   AppIndicator menu         │
   │   src/.../cli.py:69-162  │    │   src/.../tray.py:44-150    │
   └──────────────┬───────────┘    └──────────────┬──────────────┘
                  │                               │
                  │         both talk to          │
                  ▼                               ▼
         ┌────────────────────────────────────────────────┐
         │           AeroKeyboardRGB (device.py)          │
         │                                                │
         │    _find_hidraw  →  os.open  →  ioctl dance    │
         │                                                │
         │    set_effect / get_effect / firmware / reset  │
         └────────────────────────┬───────────────────────┘
                                  │
                                  ▼
                  /dev/hidraw?   ────────►   ITE-829X   ────►  RGB
                                                           (keyboard LEDs)
         ┌────────────────────────────────────────────────┐
         │    ~/.cache/aeroctl-kbd-state.json             │
         │    { effect, speed, brightness, color, dir }   │
         │    written on "off", read on "on"/"toggle"     │
         │    shared by CLI and tray                      │
         └────────────────────────────────────────────────┘
```

The CLI lives in `src/aeroctl_linux_indicator/cli.py:69-162`. It's an `argparse` front-door with subcommands — `devices`, `status`, `firmware`, `set`, `off`, `on`, `toggle`, `reset`, `keyboard-mode`, `raw` — and each subcommand is a `with kb:` block that opens the device, does its thing, and closes.

```python
# src/aeroctl_linux_indicator/cli.py:95-118  (off/on, state round-trip)
if args.cmd == "off":
    cur = kb.get_effect()
    if int(cur.get("brightness", 0)) > 0:
        _save_state(
            {
                "effect": int(cur["effect"]),
                "speed": int(cur["speed"]),
                "brightness": int(cur["brightness"]),
                "color": int(cur["color"]),
                "direction": int(cur["direction"]),
            }
        )
    kb.set_effect(EFFECTS["static"], 3, 0, COLORS["black"], 0)
    print("ok: off")
    return 0
if args.cmd == "on":
    st = _load_state()
    if st:
        kb.set_effect(st["effect"], st["speed"], st["brightness"], st["color"], st["direction"])
        print("ok: on (restored)")
    else:
        kb.set_effect(EFFECTS["static"], 3, 100, COLORS["white"], 0)
        print("ok: on (default white)")
    return 0
```

The "off" command is not "please go dark." Under the hood, it is: "capture the current effect to disk, then set brightness to zero on a static-black effect." The keyboard firmware doesn't have a real power-off — it has `brightness = 0`. The `on` command is symmetric: read the JSON, push the previous effect back in. If there's no prior state (first boot, clean install), it falls back to a comfortable default of white at 100% brightness.

The tray indicator (`src/aeroctl_linux_indicator/tray.py:44-184`) does the same dance, but driven by mouse clicks. It builds a GTK menu with three submenus — Brightness, Color, Effect — plus `Toggle On/Off`, `Reset`, `Status`, `Quit`. Every menu item is wired to the same helpers:

```python
# src/aeroctl_linux_indicator/tray.py:62-83
def _set(self, effect: str, color: str, brightness: int = 100, speed: int = 3, direction: int = 0) -> None:
    self._safe(lambda dev: dev.set_effect(EFFECTS[effect], speed, brightness, COLORS[color], direction))

def _set_brightness(self, brightness: int) -> None:
    def _op(dev: AeroKeyboardRGB):
        cur = dev.get_effect()
        dev.set_effect(cur["effect"], cur["speed"], brightness, cur["color"], cur["direction"])
    self._safe(_op)

def _set_effect_only(self, effect: str) -> None:
    def _op(dev: AeroKeyboardRGB):
        cur = dev.get_effect()
        bri = cur["brightness"] if cur["brightness"] > 0 else 100
        dev.set_effect(EFFECTS[effect], cur["speed"], bri, cur["color"], cur["direction"])
    self._safe(_op)
```

The `_safe` wrapper is a three-line confession: "I'm a tray app, and if the device opens fail I should not crash the whole desktop." It swallows exceptions and logs them to stderr. This is not elegant. It is pragmatic. A crashed tray app is a worse user experience than a tray app that silently fails to light your keyboard, and if anything goes wrong — cable unplugged, udev rule missing, someone pulled the battery — you still have a working GNOME session to go fix it from.

Because both frontends write to the same `~/.cache/aeroctl-kbd-state.json`, toggling from the tray and then toggling from the CLI Does The Right Thing. "Off from tray, on from CLI" restores the same colors and effects you had. That tiny consistency is, honestly, the entire reason the indicator feels polished.

Polished on my laptop, anyway. The thought that kept tugging at me was: "polished on my laptop" is a trap — a tray indicator that only runs on exactly the distro, exactly the Python, and exactly the AppIndicator build I happen to have installed is not a tool, it's a party trick. Which is how I fell down the PyInstaller rabbit hole.

## PyInstaller + AppIndicator: An Act of Distro Diplomacy

Linux desktops are many things, but "consistent about system tray APIs" is not one of them. The original library for this was called `AppIndicator3`, maintained by Canonical for Unity. When Unity died, it got adopted by MATE. Then GNOME kept it alive via a shell extension. Then Ubuntu 24.04 rolled out — and with it, the rename: `AyatanaAppIndicator3`, the fork-of-the-fork that everyone is supposed to use now.

Fresh installs on 24.04 ship only Ayatana. Older systems and some distros ship only the legacy one. A few ship both. I needed my binary to work on all three.

The tray handles it at import time with a small try/except that tries the modern name first and falls back:

```python
# src/aeroctl_linux_indicator/tray.py:12-17
try:
    gi.require_version("AyatanaAppIndicator3", "0.1")
    AppIndicator = importlib.import_module("gi.repository.AyatanaAppIndicator3")
except (ValueError, ImportError):
    gi.require_version("AppIndicator3", "0.1")
    AppIndicator = importlib.import_module("gi.repository.AppIndicator3")
```

Fine for a Python import. Not fine for PyInstaller, which has to know at build time which modules to bundle. So I added a corresponding sniff-test to the build script: it runs a little inline Python snippet against the active interpreter, checks which of the two libraries is actually installed, and prints the import path. The shell then passes that path to PyInstaller as `--hidden-import`:

```bash
# scripts/build-binaries.sh:14-24
APPINDICATOR_IMPORT="${APPINDICATOR_IMPORT:-$("$PYTHON_BIN" - <<'PY'
import gi

try:
    gi.require_version("AyatanaAppIndicator3", "0.1")
    print("gi.repository.AyatanaAppIndicator3")
except ValueError:
    gi.require_version("AppIndicator3", "0.1")
    print("gi.repository.AppIndicator3")
PY
)}"
```

This is the kind of detail that does not make it into blog post titles, because "I added an 18-line heredoc to my build script" is not a headline. But it is genuinely the difference between "runs on my machine" and "runs on the user's machine too." When somebody clones the repo on Fedora, the sniff-test resolves to Ayatana. When somebody clones it on a 20.04 LTS box that never got the memo, it resolves to the legacy name. PyInstaller bundles the right one and the binary Just Works.

The CLI binary is built with `--onefile`. The tray binary is built with `--onedir`, because GTK's runtime needs a lot of sibling files (icon caches, schema files, typelibs) that PyInstaller can't inline into a single executable without misery. The `install.sh` script then tars the onedir tree, extracts it into `~/.local/bin/aeroctl-kbd-tray/`, and creates an `autostart` desktop entry that points at the tray binary inside.

At this point, the software story was effectively finished — the shipping part, anyway. What was left was the hardware's side of the story, which I could only read in retrospect, through the commit log. And one of those commits is the reason I am writing this essay at all.

## Hardware Limits, Told by the Commit Log

If you read the project's git log top to bottom, it reads almost like a short story. I'm going to quote commit hashes — the real ones — because this is where the narrative got real.

```
550ee28  feat: setup project, cli, tray and configure auto-releases      (1077 LoC)
00055f2  test: add test suite, mock hardware IO and integrate pytest     (coverage to CI)
74646e6  fix: tray mock attribute error and boost test coverage to ~78%
caea8df  test: add missing branches coverage to cli.py reaching +80%
f39a4b1  fix(install): use python environment for pyinstaller build
f4a204e  fix(install): auto-start the tray indicator immediately
68dbdae  chore: remove unsupported RGB effects                           ← the sad one
e1ac392  docs: add supported device model AERO X16 2WH
48c0f46  docs: clarify native hardware RGB effect limitations
```

`550ee28` is the initial dump: 1,077 lines in one go, 16 files, the CLI, the tray, the build script, the install script, the README. This is always how the first commit of a reverse-engineering project looks — not because you don't know about good commit hygiene, but because you've just spent a month with it living in a single unversioned directory while you figured out whether the thing would work at all.

`00055f2` is the first test suite. I mocked the hardware layer (`/dev/hidraw` doesn't exist in CI), so the test harness could exercise packet construction, state round-tripping, and CLI argument parsing without needing a physical keyboard on the runner.

`74646e6` is the embarrassment. I'd mocked `AppIndicator.Indicator.new` but forgotten one attribute it returns, and the tray tests failed with an `AttributeError` deep inside GTK wiring. One line, one commit, 35 new test lines, and a coverage bump to ~78%.

`caea8df` pushed me over 80% logic coverage on `cli.py` — it added 56 lines in `tests/test_cli.py` hitting every subcommand branch. For a 593-line project, 80% is the sweet spot where every major pathway is exercised but I'm not writing tests for `argparse` internals.

`f39a4b1` and `f4a204e` are the install script's growing pains. `f39a4b1` did two things: it translated the whole script from Spanish (one of the author's native languages) to English so non-Spanish-speakers could audit it, and it made the PyInstaller build use a local virtualenv so the script didn't trip PEP-668 on modern Debian-based distros. `f4a204e` is the tiny polish that says "after a fresh install, also launch the tray right now, instead of making the user log out and back in." That's the `nohup` call at the bottom of `install.sh`.

And then there's `68dbdae`: **"chore: remove unsupported RGB effects."**

It's six lines removed from `device.py` and eight lines changed in `tray.py`. It's also the moment I learned that my shiny new Aero X16 was less capable than a 2019 Aero 15. Look at the diff:

```python
# commit 68dbdae, src/aeroctl_linux_indicator/device.py, removed:
-    "ripple": 6,
-    "neon": 8,
-    "rainbow": 9,
-    "circle": 11,
-    "hedge": 12,
-    "rotate": 13,
```

Those are real effects. They exist in the Windows firmware protocol. They are documented. They just don't work on the 12th-gen Aero 16. The firmware accepts the command, but the keyboard doesn't render the effect. Newer models offload those effects to **Gigabyte Control Center** — a Windows-only service that paints keys frame-by-frame over USB from userspace, because the firmware no longer knows how to do them itself.

{{< admonition type="note" title="Why `68dbdae` is the saddest line in the repo" open=true >}}
It's not a bug fix. It's not a refactor. It is an acknowledgement that Gigabyte made a business decision — "let the Windows app do the rendering, trim the firmware" — and that decision is permanently visible on my Linux keyboard in the form of six effect names that no longer exist.
{{< /admonition >}}

 `e1ac392` and `48c0f46` are the README commits that added a dedicated section explaining this to users, so nobody else has to learn it the way I did.

What I'm left with is the seven effects the firmware still speaks natively: `static`, `breathing`, `wave`, `fade`, `marquee`, `flash`, `raindrop`. They are enough. They are beautiful. They were, before `68dbdae`, joined by `ripple` and `neon` and `rainbow`, and I miss them.

## Lessons

If I was going to do this again, I'd carry a handful of lessons forward. Not many. These small projects teach their lessons in small bites.

### Detection is fragile across generations

Every generation of Gigabyte Aero has its own quirks. The vendor ID stays `0x0414`, but the product ID drifts, the report descriptor drifts, the effect table drifts. My scanner uses the `0xFF01` usage page as its anchor precisely because that's the most stable signal — if a future model changes product IDs, as long as the vendor keeps using the ITE controller family, my sysfs glob and descriptor sniff will probably still find it. The alternative (hardcoding PIDs) would have rotted within a year.

### The Ayatana/legacy fallback is the polish that matters

The 18 lines of heredoc in `build-binaries.sh:14-24` are not glamorous. Nobody will ever write a blog post about the `try/except ValueError` in `tray.py:12-17`. But together they are the difference between "Linux users with the right distro" and "Linux users." If you're going to ship a desktop utility in 2026, you do the Ayatana dance. It is the opposite of overengineering: it is ten minutes of work that quietly covers three years of distro fragmentation.

### A 593-LoC codebase can hit 80% coverage — if the hardware layer is mocked

The single biggest win for tests was committing to a fake `AeroKeyboardRGB` in `tests/conftest.py`. Once the hardware was mockable, every CLI subcommand became a pure function of its inputs: argparse in, fake device interactions out. The CI runs without a keyboard plugged into the runner. The tests run in a fraction of a second. And the commits that raised coverage (`74646e6`, `caea8df`) added fewer than 100 test lines between them. Small codebase, small test suite, real coverage. It's a good place to be.

### Trust the checksum, not your intuition

This is the one I'd tattoo on myself if I could. When you're reverse-engineering a binary protocol and the obvious algorithm doesn't match the capture, do not get clever. Do not assume the vendor is using a known CRC variant. Try the dumb things first, then try slightly-off variants of the dumb things, then sit with the math until it clicks. The Gigabyte keyboard's checksum is "make the packet total `0xFF`," which nobody would call state-of-the-art, but it is what the firmware actually computes, and in reverse engineering, "what the firmware actually computes" beats "what you think it should compute" every time.

---

My keyboard glows, now. It glows when I log in, because `install.sh` dropped an autostart file in `~/.config/autostart/`. It remembers its last color after I toggle it off and on, because `cli.py` and `tray.py` both write to the same little JSON file in `~/.cache/`. It works without `sudo`, because of one `TAG+="uaccess"` clause in a udev rule I set up once and will never think about again.

And somewhere under all that, a 9-byte packet — eight bytes of intent, one byte of not-XOR checksum — is being handed down from Python to `fcntl.ioctl` to the Linux hidraw driver to an ITE-829X controller that finally, for the first time since I wiped Windows, understands what I want.

Which was, all along, just for the keyboard to say something back.

