Enseñándole a un teclado Gigabyte a hablar Linux

1 El dolor de un teclado oscuro
Compré una Gigabyte Aero X16 con la intención de hacerla mi “máquina de uso diario que también corre builds sin quejarse”. En Windows, lo primero que pasó cuando la encendí fue teatral: el teclado se iluminó en una ola de cian, como una pequeña banda de LEDs marchando, y el Gigabyte Control Center me insistía alegremente en que eligiera entre una docena de efectos RGB. Se sentía como un teclado que quería hablarme.
Luego instalé Linux, y el teclado se calló. Sin un destello. Sin un brillo. Nada.
Para ser justo, la máquina seguía escribiendo. Las teclas seguían produciendo caracteres. Pero hay un tipo particular de dolor peculiar de los desarrolladores que pagaron por RGB y luego pasaron una noche mirando plástico apagado. Ese dolor me sugirió una idea: si el hardware sigue funcionando y el OS es lo único que cambió, entonces en algún lugar entre mi sesión de usuario y el firmware del teclado hay una conversación que simplemente no está ocurriendo — y las conversaciones que no ocurren pueden, en principio, iniciarse. Miré herramientas existentes: hay un maravilloso proyecto de Windows llamado wtwrp/aeroctl que hace todo lo que uno podría querer, si uno está dispuesto a también correr Windows. Yo no lo estaba. Así que hice lo que uno hace: abrí una terminal, murmuré algo no imprimible sobre lsusb, y empecé a explorar.
Unas noches después, tenía un pequeño proyecto Python que llamé aeroctl-linux-indicator. Son 593 líneas de fuente a lo largo del paquete (device.py + cli.py + tray.py), tiene alrededor de 80% de cobertura por tests, incluye un CLI y un tray indicator, y su único propósito es convencer a un controlador ITE-829X enterrado dentro de un chassis Gigabyte de que Linux es, de hecho, un sistema operativo legítimo que merece luz de colores.
Esta es la historia de cómo llegué ahí. Es también la historia de cómo un commit — 68dbdae, “chore: remove unsupported RGB effects” — es la línea más triste del repo.
Ese commit, sin embargo, es el final del hilo. El principio es una pregunta más inocente — una que solo hice después de un par de noches de decepción con las respuestas de otros: ¿qué está realmente conectado al bus USB con lo que aún no he hablado, y cómo lo sabría siquiera?
2 El arte previo: cuatro proyectos, ninguno para esta laptop
Antes de escribir una sola línea de Python, hice lo que se supone que hace todo ingeniero: busqué trabajo existente. La noticia decepcionante-pero-alentadora es que hay bastante arte previo sobre “teclado RGB de laptop Gigabyte desde Linux”. La parte decepcionante es que ninguno funcionó en mi laptop específica sin tener que tocar nada. La parte alentadora es que cada uno me enseñó algo que terminé usando.
Aquí está el árbol que recorrí antes de seguir mi propio camino.
2.1 wtwrp/aeroctl — el nombre que tomé prestado
wtwrp/aeroctl es el AeroCtl original, una aplicación C# / .NET para Windows. Es el proyecto que le dio nombre al mío, y leerlo es la forma más fácil de entender cómo se ve un Gigabyte Aero bien soportado desde el lado del host: control de ventiladores, política de carga, teclas Fn no estándar, RGB del teclado, GPU boost, todo pasando por el driver ACPI WMI de Gigabyte (acpimof.dll y amigos). La sección de RGB en particular es informativa: confirma que el teclado se accede a través de un HID feature report y no de algún puerto IO propietario o blob WMI.
El problema es obvio: es Windows. Toda la premisa es que ya instalaste Gigabyte ControlCenter o SmartManager para que acpimof.dll esté presente. Nada de eso existe en mi laptop, que corre Linux exclusivamente. Pero el código de muestra de RGB en Samples/ — la parte que es simplemente “abre el dispositivo HID, escribe un feature report, ciérralo” — es portable en espíritu. Esa es la pieza que me llevé.
2.2 AeroControlCenter — el port de Linux que pedía un driver del kernel
tangalbert919/AeroControlCenter es un port Linux del Gigabyte Control Center, escrito en C++/Qt, dirigido al Aero 15 Classic (SA/WA/XA/YA). Su enfoque es más ambicioso que el mío: quiere manejar control de ventiladores, política de batería, y RGB, y para hacerlo limpiamente depende de un driver de kernel compañero, gigabyte-laptop-wmi, que expone la interfaz WMI como nodos sysfs de Linux.
Admiro la arquitectura y no pude usarla. Mi máquina no está en la lista probada, gigabyte-laptop-wmi no se enlaza limpiamente en kernels modernos en mi chassis, y no tenía ganas de depurar una tabla ACPI WMI en una laptop que también necesitaba seguir funcionando para el correo. Lo que AeroControlCenter sí me dio fue el archivo 70-keyboard.rules — es decir, la confirmación de que el enfoque udev era la forma correcta para el problema de permisos. Escribí mi propia regla, pero la escribí porque la regla de ese proyecto me dijo dónde ponerla.
2.3 fusion-kbd-controller — libusb, kernel unbind, y una advertencia
martin31821/fusion-kbd-controller es un pequeño binario C de userspace usando libusb, dirigido al AERO 15X. Leer el README es toda una experiencia: necesita root, desenlaza temporalmente el dispositivo USB del driver usbhid del kernel, y el autor advierte alegremente que “es posible brickear tu teclado enviando valores sin sentido aquí.”
Funciona, en un 15X, con libusb, con kernel detach. Yo no quería ninguna de esas cosas. Quería /dev/hidraw para poder hablarle al dispositivo mientras el kernel seguía siendo propietario de la interfaz HID (para que las teclas siguieran escribiendo mientras enviaba comandos de color), quería una regla udev para no necesitar root, y realmente no quería brickear mi propio teclado aprendiendo qué significaban “valores sin sentido” para el hardware de alguien más. Pero fusion-kbd-controller es donde adquirí el modelo mental de “paquete de bytes con un modo y una región de parámetros”, que resultó generalizarse al paquete de feature-report del ITE-829X que terminé escribiendo.
2.4 keyboard-fusion-rgb — el lenguaje correcto, el vendor equivocado
rcassani/keyboard-fusion-rgb es un driver Python HIDAPI para el AORUS 15G. Es lo más cercano a “lo que terminé haciendo” — Python, HID, userspace, reverse-engineered desde el AORUS Control Center de Windows — excepto por un detalle: los IDs de vendor/producto son 0x1044:0x7a3c (Chu Yuen Enterprise Co., Ltd.), que es una familia de controladores de teclado completamente distinta a la de mi laptop (0x0414:0x8104, el ITE-829X).
El código no puede usarse tal cual — el formato de wire es distinto, el layout por tecla es distinto, los IDs de modo son distintos. Pero el enfoque es exactamente el que yo quería: feature reports estilo HIDAPI desde Python, decodificados observando lo que escribe la herramienta de Windows. La existencia de ese proyecto fue también un empujón moral: si rcassani pudo decodificar una sub-familia Gigabyte viendo el tráfico USB, yo podía decodificar la de mi laptop de la misma manera.
2.5 OpenRGB — el gigante que aún no conoce esta máquina
CalcProgrammer1/OpenRGB es la herramienta RGB universal — Windows, Linux, macOS, cada fabricante de motherboard y RAM bajo el sol. Es el proyecto que quería poder usar y no pude. Su issue abierto #2288 sobre el Gigabyte Aero 15 OLED YD es, esencialmente, “aún no tenemos este modelo”. Mi laptop es un SKU diferente al de ese issue, pero lo suficientemente cercano como para saber que la respuesta era la misma: todavía no.
Menciono OpenRGB explícitamente porque en un mundo mejor este post no existe — hubiera abierto su panel de configuración, elegido un efecto, y seguido adelante. En cambio voy a decodificar un paquete, escribir 593 líneas de Python, y pretender que estoy bien con esto.
2.6 Lo que tomé del árbol
El resumen de una hora de investigación se veía algo así:
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 thisEse es el truco de la investigación de arte previo en este rincón del mundo: la respuesta a “¿hay algo para mi máquina?” es casi siempre no, pero la respuesta a “¿qué han descubierto otras personas que no debería re-derivar?” es casi siempre algo. Cerré las pestañas del navegador con un plan a medio formar: Python, /dev/hidraw, una regla udev para no necesitar sudo, un paquete que probablemente sea “modo + parámetros + checksum8”, y — el gran riesgo — un algoritmo de checksum que tendría que adivinar porque ninguno del arte previo usa exactamente este controlador.
Lo que me devolvió a la pregunta inocente: ¿qué está realmente en mi bus USB, y qué quiere escuchar?
3 Reverse-engineering del teclado
Lo primero que aprendí es que hacer reverse-engineering de un periférico USB es 10% trabajo de detective inteligente y 90% leer la salida de dmesg mientras tu pareja pregunta qué estás haciendo a la 1am. El teclado del Gigabyte Aero no es un dongle separado, claro — es un dispositivo USB interno, y lsusb | grep 0414 lo confirmó sin chistar:
Bus 001 Device 003: ID 0414:8104 Gigabyte Technology Co.,Ltd ...Vendor 0x0414, producto 0x8104. Ese es el ITE-829X. Busqué todo lo que pude encontrar, lo crucé con las fuentes de la herramienta de Windows, y formé una hipótesis: el control RGB vive detrás de un HID feature report, expuesto en una interfaz cuya usage page es 0xFF01 — una página HID definida por el vendor, que es el equivalente USB de un camino de tierra marcado como “privado”.
3.1 Encontrando el /dev/hidraw correcto
Aquí es donde el reverse engineering deja de sentirse como magia y empieza a sentirse como arqueología de sistema de archivos. Linux expone cada dispositivo HID bajo /sys/bus/hid/devices/, y para cada dispositivo te da un report_descriptor — un blob de bytes que te dice (si entrecierras los ojos) sobre qué está dispuesto a hablar el dispositivo.
Escribí un scanner. Dado el par vendor/producto correcto, globa el directorio sysfs, lee cada report_descriptor, y busca los tres bytes \x06\x01\xff — jerga HID para “usage page 0xFF01”. Cuando encuentro una coincidencia, sigo el symlink hacia su subdirectorio hidraw y tomo el nodo /dev/hidrawN que le pertenece.
Piensa en udev + sysfs aquí como una especie de /proc para periféricos: un documento vivo de solo lectura que describe lo que el kernel cree actualmente sobre cada dispositivo conectado. Y como /proc, la forma correcta de usarlo no es confiar en un número de dispositivo particular — /dev/hidraw0 hoy podría ser /dev/hidraw3 mañana si conectas un ratón — sino escanear, filtrar y elegir por propiedad.
# 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 NoneEl prefijo 0003: en el patrón del glob vale una pausa. Los dispositivos HID se registran en el kernel con IDs de bus, y 0003 es USB. El patrón completo se expande, para mi máquina, a /sys/bus/hid/devices/0003:0414:8104.*. El * está ahí porque un solo dispositivo físico puede exponer varias interfaces HID — una para las teclas normales, una para las teclas multimedia, una para el canal de control del vendor — y yo quiero aquella cuyo report descriptor anuncia 0xFF01.
Así quedó el flujo de descubrimiento:
┌──────────────────────────────────────────┐
│ /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) │
└──────────────────────────────────────────┘Una vez que ese hidraw path está en mano, todo lo demás es fcntl.ioctl. Los ioctls HIDIOCSFEATURE y HIDIOCGFEATURE son la forma estándar de enviar y recibir HID feature reports desde userspace. Hice los números mágicos a mano porque no quería depender de hidapi:
# 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 = 9La clase AeroKeyboardRGB (src/aeroctl_linux_indicator/device.py:77-240) es toda la abstracción de hardware en un solo lugar: abre el dispositivo, escribe paquetes de 9 bytes, lee respuestas de 9 bytes, cierra al salir. También es un context manager, porque me gustan mis file descriptors como me gustan mis laptops caras: cerrados cuando termino con ellos.
Tener un file descriptor es una cosa. Tener los bytes correctos para enviarle es otra, y ahí es donde golpeé la primera pared que parecía un rompecabezas en lugar de un problema de fontanería — que, resulta, es exactamente cuando el reverse engineering se pone interesante.
4 El paquete: nueve bytes y un checksum que no es XOR
Ahora, el paquete. Esta es la parte graciosa.
Uno esperaría — yo ciertamente lo hice — que un modesto paquete de control de 9 bytes para un controlador LED se verificaría a sí mismo con XOR. Todo el mundo usa XOR. XOR es barato, XOR es a lo que llega el ingeniero de firmware cuando son las 4pm del viernes y quiere irse a casa. Miré las capturas de Wireshark de la herramienta de Windows. Escribí un pequeño script Python para probar XOR sobre cada posible slice del paquete. Nada coincidió.
Probé la suma simple. Tampoco.
Probé 256 - suma. Cerca. Sospechosamente cerca. Desviado por uno.
La respuesta, resultó, es esta:
checksum = (0xFF - (sum(bytes[1..7]) & 0xFF)) & 0xFFNo XOR. No XOR. Es una cosa tipo complemento a uno de 8 bits: toma la suma de los bytes de payload, enmascárala a un byte, luego réstala de 0xFF. Un checksum de “hacer que todo totalice 0xFF”. Lo cual es perfectamente razonable, de la manera en que todos los protocolos de hardware son perfectamente razonables una vez que dejas de esperar que sean de otra forma.
Si estás pensando “ah, eso es solo ~suma + 1 - 1, ¿verdad?” — sí, y también, no, ya basta, no es complemento a dos. Es un invariante de “suma más checksum debe igualar 0xFF”. Lo cual es elegante, si ligeramente inusual, y la primera regla del reverse engineering es: haz lo que hace el hardware, aunque el hardware sea un poco excéntrico.
Aquí está toda la implementación:
# src/aeroctl_linux_indicator/device.py:94-96
@staticmethod
def _checksum8(data) -> int:
return (0xFF - (sum(data) & 0xFF)) & 0xFFDos líneas, más un decorador. El & 0xFF de afuera es por pura precaución — la aritmética en Python no tiene límite, y quiero que esto quepa en un byte sin importar cuántos bytes le pase.
El layout del paquete en sí es este:
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/hidrawNVale la pena admirar un momento la elección del framing. El byte 0 es el report ID de HID, y porque este dispositivo particular usa report ID 0, es efectivamente padding — pero el ABI de Linux hidraw siempre quiere un report ID como primer byte de un feature report, así que lo cargo conmigo. Los bytes 1–7 son donde vive el comando real. El byte 8 es el checksum en el que estuve equivocado durante dos horas.
Si has escrito emuladores de terminal, la analogía que me hizo click fue esta: un paquete de HID feature report es básicamente un código de escape ANSI con un checksum. Tienes un carácter de inicio (el byte de comando), tienes parámetros (efecto, velocidad, brillo, color, dirección), y tienes un terminador — excepto que el terminador, en lugar de ser una letra imprimible como m para “set graphics mode,” es un byte de checksum que verifica que el firmware no recibió basura. Envía el byte equivocado y el controlador te ignora educadamente. Envía los bytes correctos y tu teclado se convierte en una discoteca.
El resto de device.py es solo vocabulario. set_effect es _send_feature(_packet(0x08, 0x00, effect, speed, brightness, color, direction)). get_firmware_version es _send_feature(_packet(0x80)) seguido de un _get_feature(9). reset es _send_feature(_packet(0x13, 0xFF)). Cada comando es una línea. Cada línea es una oración en un idioma que eventualmente aprendí a hablar.
Hablar ese idioma correctamente, sin embargo, asumía que podía abrir el file descriptor en primer lugar — y una vez que escribí mi contraseña de sudo por cuadragésima vez en un día, la siguiente rama del árbol prácticamente se anunció sola.
5 udev: el guardián de la felicidad
Hay un círculo especial del purgatorio del escritorio Linux reservado para las herramientas que funcionan perfectamente, pero solo si las corres con sudo. Cada vez que cambiaba el teclado, tenía que escribir mi contraseña. Cada vez que el tray indicator quería leer el efecto actual, no podía, porque un proceso de usuario normal no puede abrir /dev/hidraw0 para lectura-escritura por defecto. Eso era un dealbreaker para un “tray indicator”.
La solución es una regla udev de cuatro líneas, y es una pequeña obra maestra:
# 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."
fiLa palabra mágica aquí es TAG+="uaccess". Esta etiqueta le dice a systemd-logind: “quien esté actualmente conectado en la sesión local debería poder acceder a este dispositivo”. Sin ella, /dev/hidraw0 es propiedad de root:root con modo 0660, y tu proceso de usuario rebota contra él como una polilla contra una ventana.
Con ella, systemd agrega un ACL al nodo del dispositivo dándole a tu usuario conectado permisos de lectura+escritura, automáticamente, mientras estés en el teclado. Cierras sesión, el ACL desaparece. Conectas un segundo teclado Aero, el ACL aparece en el nuevo también. Es el equivalente Linux de una tarjeta de hotel: tienes acceso mientras eres el huésped, el sistema te lo quita cuando te vas.
Me gusta pensar en las reglas udev como una carta de amor a tu yo del futuro. Las escribes una vez, las dejas en el disco, y para siempre después, la máquina da la bienvenida a tu yo futuro — el que habrá olvidado todo sobre este proyecto y solo quiere que el teclado se ilumine — como si nada hubiera sido difícil alguna vez. El script install.sh hace este regalo automáticamente en la primera instalación. Nunca volverás a ver la carta de amor, porque nunca la necesitarás.
La regla udev arregló el baile de permisos, pero resolverla también sacó a la superficie una pregunta más silenciosa: ahora que cualquier usuario conectado podía manejar el dispositivo, ¿cómo deberían manejarlo realmente? ¿En la línea de comandos a la 1am? ¿Desde un menú del tray mientras escribes correo? Ambos, idealmente — y en el momento en que dije “ambos” en voz alta, la siguiente rama del proyecto se trazó sola.
6 Dos frontends, un cerebro
Una vez que la capa del dispositivo funcionó, había una decisión que tomar: ¿CLI o GUI? Dije: por qué no ambos. Pero también dije: no dos veces.
La forma en que terminé es un clásico “dos frontends compartiendo un cerebro” — la misma clase AeroKeyboardRGB en el fondo, dos UIs muy distintas encima. Ninguna UI sabe nada sobre HID. Ninguna UI hace su propio cálculo de checksum. Ambas se apoyan en la misma clase de dispositivo, y el único estado que comparten es un pequeño archivo JSON en el directorio de cache del usuario.
┌──────────────────────────┐ ┌─────────────────────────────┐
│ 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 │
└────────────────────────────────────────────────┘El CLI vive en src/aeroctl_linux_indicator/cli.py:69-162. Es una puerta argparse con subcomandos — devices, status, firmware, set, off, on, toggle, reset, keyboard-mode, raw — y cada subcomando es un bloque with kb: que abre el dispositivo, hace su trabajo, y cierra.
# 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 0El comando “off” no es “por favor oscurécete”. Por dentro es: “captura el efecto actual al disco, luego pon el brillo en cero sobre un efecto estático negro”. El firmware del teclado no tiene un apagado real — tiene brightness = 0. El comando “on” es simétrico: lee el JSON y vuelve a aplicar el efecto anterior. Si no hay estado previo (primer arranque, instalación limpia), recurre a un blanco cómodo al 100% de brillo.
El tray indicator (src/aeroctl_linux_indicator/tray.py:44-184) hace el mismo baile, pero manejado por clics de ratón. Construye un menú GTK con tres submenús — Brightness, Color, Effect — más Toggle On/Off, Reset, Status, Quit. Cada elemento del menú está conectado a los mismos helpers:
# 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)El wrapper _safe es una confesión de tres líneas: “soy un tray app, y si la apertura del dispositivo falla no debería crashear todo el escritorio”. Se traga las excepciones y las registra en stderr. No es elegante. Es pragmático. Una app del tray crasheada es una peor experiencia de usuario que una app del tray que falla silenciosamente en iluminar tu teclado, y si algo sale mal — cable desconectado, regla udev faltante, alguien quitó la batería — todavía tienes una sesión GNOME funcional desde donde arreglarlo.
Porque ambos frontends escriben al mismo ~/.cache/aeroctl-kbd-state.json, alternar desde el tray y luego desde el CLI Hace Lo Correcto. “Apagar desde el tray, encender desde el CLI” restaura los mismos colores y efectos que tenías. Esa pequeña consistencia es, honestamente, la razón por la que el indicator se siente pulido.
Pulido en mi laptop, de todos modos. El pensamiento que me seguía rondando era: “pulido en mi laptop” es una trampa — un tray indicator que solo corre en exactamente la distro, exactamente el Python, y exactamente el build de AppIndicator que casualmente tengo instalado no es una herramienta, es un truco de circo. Así fue como caí en el laberinto de PyInstaller.
7 PyInstaller + AppIndicator: un acto de diplomacia entre distros
Los escritorios Linux son muchas cosas, pero “consistentes sobre las APIs de system tray” no es una de ellas. La librería original para esto se llamaba AppIndicator3, mantenida por Canonical para Unity. Cuando Unity murió, la adoptó MATE. Luego GNOME la mantuvo viva a través de una extensión de shell. Luego salió Ubuntu 24.04 — y con él, el renombre: AyatanaAppIndicator3, el fork-del-fork que se supone que todos deben usar ahora.
Las instalaciones nuevas en 24.04 solo traen Ayatana. Sistemas más viejos y algunas distros solo traen la versión legacy. Unas pocas traen ambas. Necesitaba que mi binario funcionara en las tres.
El tray lo maneja en el momento del import con un pequeño try/except que prueba el nombre moderno primero y hace fallback:
# 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")Bien para un import de Python. No bien para PyInstaller, que tiene que saber en tiempo de build qué módulos empaquetar. Así que agregué un sniff-test correspondiente al script de build: corre un pequeño snippet inline de Python contra el intérprete activo, verifica cuál de las dos librerías está realmente instalada, e imprime el path de import. La shell luego pasa ese path a PyInstaller como --hidden-import:
# 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
)}"Este es el tipo de detalle que no llega a los títulos de los posts, porque “agregué un heredoc de 18 líneas a mi script de build” no es un titular. Pero es genuinamente la diferencia entre “corre en mi máquina” y “corre en la máquina del usuario también”. Cuando alguien clona el repo en Fedora, el sniff-test resuelve a Ayatana. Cuando alguien lo clona en una máquina LTS 20.04 que nunca se enteró del cambio, resuelve al nombre legacy. PyInstaller empaqueta el correcto y el binario Simplemente Funciona.
El binario CLI se construye con --onefile. El binario del tray se construye con --onedir, porque el runtime de GTK necesita muchos archivos hermanos (caches de íconos, archivos de schema, typelibs) que PyInstaller no puede incrustar en un solo ejecutable sin sufrir. El script install.sh luego empaqueta el árbol onedir, lo extrae en ~/.local/bin/aeroctl-kbd-tray/, y crea una entrada de escritorio autostart que apunta al binario del tray dentro.
En este punto, la historia del software estaba efectivamente terminada — la parte de distribución, de todos modos. Lo que quedaba era el lado de la historia del hardware, que solo podía leer en retrospectiva, a través del commit log. Y uno de esos commits es la razón por la que estoy escribiendo este ensayo.
8 Límites del hardware, contados por el commit log
Si lees el git log del proyecto de arriba a abajo, se lee casi como un cuento corto. Voy a citar hashes de commits — los reales — porque aquí es donde la narrativa se volvió 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 ← el triste
e1ac392 docs: add supported device model AERO X16 2WH
48c0f46 docs: clarify native hardware RGB effect limitations550ee28 es el volcado inicial: 1,077 líneas de una sola vez, 16 archivos, el CLI, el tray, el script de build, el script de instalación, el README. Así es siempre como se ve el primer commit de un proyecto de reverse engineering — no porque no sepas sobre buenas prácticas de commit, sino porque acabas de pasar un mes con todo viviendo en un solo directorio sin versionar mientras descubrías si la cosa iba a funcionar.
00055f2 es el primer test suite. Mockeé la capa de hardware (/dev/hidraw no existe en CI), para que el harness de pruebas pudiera ejercer la construcción de paquetes, el round-tripping de estado, y el parsing de argumentos del CLI sin necesitar un teclado físico en el runner.
74646e6 es el momento vergonzoso. Había mockeado AppIndicator.Indicator.new pero olvidado un atributo que retorna, y los tests del tray fallaron con un AttributeError profundo dentro del cableado de GTK. Una línea, un commit, 35 nuevas líneas de test, y un salto de cobertura a ~78%.
caea8df me llevó sobre el 80% de cobertura lógica en cli.py — agregó 56 líneas en tests/test_cli.py tocando cada rama de subcomando. Para un proyecto de 593 líneas, el 80% es el punto justo donde cada ruta principal queda ejercitada pero no estoy escribiendo tests para los internos de argparse.
f39a4b1 y f4a204e son los achaques de juventud del script de instalación. f39a4b1 hizo dos cosas: tradujo todo el script del español (uno de los idiomas nativos del autor) al inglés para que los no-hispanohablantes pudieran auditarlo, y hizo que el build de PyInstaller usara un virtualenv local para que el script no tropezara con PEP-668 en distros modernas basadas en Debian. f4a204e es el pulido diminuto que dice “después de una instalación nueva, también lanza el tray ahora mismo, en lugar de hacer que el usuario cierre sesión y vuelva a abrirla”. Esa es la llamada nohup al final de install.sh.
Y luego está 68dbdae: “chore: remove unsupported RGB effects.”
Son seis líneas eliminadas de device.py y ocho líneas cambiadas en tray.py. Es también el momento en que aprendí que mi flamante Aero X16 era menos capaz que un Aero 15 de 2019. Mira el diff:
# commit 68dbdae, src/aeroctl_linux_indicator/device.py, removed:
- "ripple": 6,
- "neon": 8,
- "rainbow": 9,
- "circle": 11,
- "hedge": 12,
- "rotate": 13,Esos son efectos reales. Existen en el protocolo de firmware de Windows. Están documentados. Simplemente no funcionan en el Aero 16 de 12va generación. El firmware acepta el comando, pero el teclado no renderiza el efecto. Los modelos más nuevos delegan esos efectos al Gigabyte Control Center — un servicio solo para Windows que pinta teclas cuadro por cuadro sobre USB desde userspace, porque el firmware ya no sabe cómo hacerlos él mismo.
e1ac392 y 48c0f46 son los commits del README que agregaron una sección dedicada explicando esto a los usuarios, para que nadie más tenga que aprenderlo como lo hice yo.
Con lo que me quedo son los siete efectos que el firmware todavía habla de manera nativa: static, breathing, wave, fade, marquee, flash, raindrop. Son suficientes. Son hermosos. Antes de 68dbdae, estaban acompañados por ripple y neon y rainbow, y los echo de menos.
9 Lecciones
Si fuera a hacer esto de nuevo, llevaría un puñado de lecciones conmigo. No muchas. Estos proyectos pequeños enseñan sus lecciones en pequeñas dosis.
9.1 La detección es frágil entre generaciones
Cada generación de Gigabyte Aero tiene sus propias peculiaridades. El vendor ID se mantiene en 0x0414, pero el product ID cambia, el report descriptor cambia, la tabla de efectos cambia. Mi scanner usa la usage page 0xFF01 como ancla precisamente porque esa es la señal más estable — si un modelo futuro cambia los product IDs, mientras el vendor siga usando la familia del controlador ITE, mi glob de sysfs y sniff de descriptor probablemente lo encontrarán. La alternativa (hardcodear PIDs) se habría podrido dentro de un año.
9.2 El fallback Ayatana/legacy es el pulido que importa
Las 18 líneas de heredoc en build-binaries.sh:14-24 no son glamorosas. Nadie escribirá un post sobre el try/except ValueError en tray.py:12-17. Pero juntos son la diferencia entre “usuarios Linux con la distro correcta” y “usuarios Linux”. Si vas a distribuir una utilidad de escritorio en 2026, haces el baile de Ayatana. Es lo opuesto al overengineering: son diez minutos de trabajo que silenciosamente cubren tres años de fragmentación de distros.
9.3 Un codebase de 593 LoC puede llegar al 80% de cobertura — si la capa de hardware está mockeada
La mayor ganancia para los tests fue comprometerse con un AeroKeyboardRGB falso en tests/conftest.py. Una vez que el hardware era mockeable, cada subcomando del CLI se convirtió en una función pura de sus inputs: argparse adentro, interacciones de dispositivo falsas afuera. El CI corre sin un teclado conectado al runner. Los tests corren en fracción de segundo. Y los commits que subieron la cobertura (74646e6, caea8df) agregaron menos de 100 líneas de test entre los dos. Codebase pequeño, test suite pequeño, cobertura real. No se puede pedir más.
9.4 Confía en el checksum, no en tu intuición
Esta es la que me tatuaría si pudiera. Cuando estás haciendo reverse engineering de un protocolo binario y el algoritmo obvio no coincide con la captura, no te pongas creativo. No asumas que el vendor está usando una variante CRC conocida. Prueba las cosas tontas primero, luego prueba variantes ligeramente desviadas de las cosas tontas, luego siéntate con las matemáticas hasta que haga clic. El checksum del teclado Gigabyte es “hacer que el paquete totalice 0xFF”, lo que nadie llamaría estado del arte, pero es lo que el firmware realmente computa, y en el reverse engineering, “lo que el firmware realmente computa” le gana a “lo que tú crees que debería computar” siempre.
Mi teclado brilla ahora. Brilla cuando inicio sesión, porque install.sh dejó un archivo autostart en ~/.config/autostart/. Recuerda su último color después de que lo apago y enciendo, porque cli.py y tray.py ambos escriben al mismo pequeño archivo JSON en ~/.cache/. Funciona sin sudo, gracias a una sola cláusula TAG+="uaccess" en una regla udev que configuré una vez y nunca volveré a pensar en ella.
Y en algún lugar debajo de todo eso, un paquete de 9 bytes — ocho bytes de intención, un byte de checksum que no es XOR — está siendo entregado desde Python a fcntl.ioctl al driver hidraw de Linux a un controlador ITE-829X que finalmente, por primera vez desde que borré Windows, entiende lo que quiero.
Que era, desde siempre, simplemente que el teclado dijera algo de vuelta.