Ensinando um Teclado Gigabyte a Falar Linux

Comprei um Gigabyte Aero X16 com toda a intenção de torná-lo meu “computador do dia a dia que por acaso também roda builds sem chorar”. No Windows, a primeira coisa que aconteceu quando o liguei foi teatral: o teclado se acendeu numa onda de ciano, como uma pequena banda marcial de LEDs, e o Gigabyte Control Center insistia alegremente para que eu escolhesse entre uma dúzia de efeitos RGB. Parecia um teclado que queria conversar comigo.

Então instalei o Linux, e o teclado se calou. Nem um lampejo. Nem um brilho. Nada.

Para ser justo, a máquina ainda digitava. As teclas ainda produziam caracteres. Mas existe um tipo particular de dor peculiar aos desenvolvedores que pagaram por RGB e depois passaram uma noite encarando plástico apagado. A dor cutucou um pensamento: se o hardware ainda funciona e o sistema operacional é a única coisa que mudou, então em algum lugar entre minha sessão de usuário e o firmware do teclado existe uma conversa que simplesmente não está acontecendo — e conversas que não estão acontecendo podem, em princípio, ser iniciadas. Olhei para as ferramentas existentes: há um projeto maravilhoso para Windows chamado wtwrp/aeroctl que faz tudo o que alguém poderia querer, desde que essa pessoa esteja disposta a também rodar o Windows. Eu não estava. Então fiz o que se faz: abri um terminal, resmunguei algo impublicável sobre o lsusb e comecei a cutucar.

Algumas noites depois, eu tinha um pequeno projeto em Python que chamei de aeroctl-linux-indicator. São 593 linhas de código distribuídas pelo pacote (device.py + cli.py + tray.py), está coberto por testes em cerca de 80%, traz uma CLI e um indicador de bandeja, e seu propósito inteiro é convencer um controlador ITE-829X enterrado dentro de um chassi Gigabyte de que o Linux é, de fato, um sistema operacional legítimo merecedor de luz colorida.

Esta é a história de como cheguei lá. É também a história de como um commit — 68dbdae, “chore: remove unsupported RGB effects” — é a linha mais triste do repositório.

Esse commit, no entanto, é o fim do fio. O começo dele é uma pergunta mais inocente — uma que só fiz depois de algumas noites de decepção com as respostas dos outros: o que está realmente plugado no barramento USB com que eu ainda não conversei, e como eu sequer saberia?

Antes de escrever uma única linha de Python, fiz aquilo que todo engenheiro deveria fazer e fui procurar trabalho existente. A notícia decepcionante-barra-encorajadora é que existe bastante arte anterior sobre “RGB de teclado de laptop Gigabyte a partir do Linux”. A parte decepcionante é que nenhuma delas funcionava no meu laptop específico sem ajustes. A parte encorajadora é que cada uma delas me ensinou algo que acabei usando.

Aqui está a árvore que percorri antes de me afastar dela.

wtwrp/aeroctl é o AeroCtl original, uma aplicação C# / .NET para Windows. É o projeto que deu o nome ao meu, e lê-lo é a maneira mais fácil de entender como um Gigabyte Aero bem suportado se parece do lado do host: controle de ventoinha, política de carregamento, teclas Fn não-padronizadas, RGB do teclado, GPU boost, tudo passando pelo driver ACPI WMI da Gigabyte (acpimof.dll e companhia). A seção de RGB em particular é informativa: ela confirma que o teclado é alcançado por meio de um HID feature report e não por meio de alguma porta de IO proprietária ou blob de WMI.

O problema é óbvio: é Windows. A premissa inteira é que você já instalou o Gigabyte ControlCenter ou o SmartManager, de modo que acpimof.dll esteja presente. Nada disso existe no meu laptop, que roda Linux exclusivamente. Mas o código de exemplo de RGB em Samples/ — a parte que é apenas “abra o dispositivo HID, escreva um feature report, feche” — é portável em espírito. Essa é a peça que carreguei comigo.

tangalbert919/AeroControlCenter é uma port para Linux do Gigabyte Control Center, escrita em C++/Qt, voltada ao Aero 15 Classic (SA/WA/XA/YA). Sua abordagem é mais ambiciosa que a minha: ela quer lidar com controle de ventoinha, política de bateria e RGB, e para fazer isso de forma limpa depende de um driver de kernel companheiro, gigabyte-laptop-wmi, que expõe a interface WMI como nós sysfs do Linux.

Admiro a arquitetura e não pude usá-la. Minha máquina não está na lista testada, gigabyte-laptop-wmi não dá bind de forma limpa em kernels modernos no meu chassi, e eu não tinha o apetite para depurar uma tabela WMI ACPI num laptop que eu também precisava manter funcionando para e-mail. O que o AeroControlCenter me deu, porém, foi o arquivo 70-keyboard.rules — ou seja, a confirmação de que a abordagem via udev era o formato certo para o problema de permissões. Escrevi minha própria regra, mas a escrevi porque a regra daquele projeto me disse onde colocá-la.

martin31821/fusion-kbd-controller é um pequeno binário userspace em C usando libusb, voltado ao AERO 15X. Ler o README é uma viagem: precisa de root, faz unbind temporário do dispositivo USB do driver usbhid do kernel, e o autor alegremente avisa que “é possível inutilizar seu teclado ao enviar valores absurdos aqui”.

Funciona, num 15X, com libusb, com detach do kernel. Eu não queria nenhuma dessas coisas. Eu queria /dev/hidraw para poder conversar com o dispositivo enquanto o kernel continuava sendo dono da interface HID (de modo que as teclas continuassem digitando enquanto eu enviava comandos de cor), eu queria uma regra udev para não precisar de root, e eu realmente não queria inutilizar meu próprio teclado descobrindo o que “valores absurdos” significava para o hardware de outra pessoa. Mas o fusion-kbd-controller é onde peguei o modelo mental de “pacote de bytes com um modo e uma região de parâmetros”, que acabou se generalizando para o pacote de feature report do ITE-829X que acabei escrevendo.

rcassani/keyboard-fusion-rgb é um driver HIDAPI em Python para o AORUS 15G. É a coisa mais próxima do “que acabei fazendo” — Python, HID, userspace, com engenharia reversa a partir do AORUS Control Center do Windows — exceto por um detalhe: os IDs de fabricante/produto são 0x1044:0x7a3c (Chu Yuen Enterprise Co., Ltd.), que é uma família de controlador de teclado completamente diferente da do meu laptop (0x0414:0x8104, o ITE-829X).

Então o código não pode ser usado como está — o formato de fio é diferente, o layout por tecla é diferente, os IDs de modo são diferentes. Mas a abordagem é exatamente a que eu queria: feature reports no estilo HIDAPI a partir do Python, decodificados observando o que a ferramenta do Windows escreve. A existência daquele projeto também foi um empurrão moral: se o rcassani conseguiu decodificar uma subfamília Gigabyte observando o tráfego USB, eu poderia decodificar a do meu laptop da mesma forma.

CalcProgrammer1/OpenRGB é a ferramenta RGB universal — Windows, Linux, macOS, toda placa-mãe e fabricante de RAM debaixo do sol. É o projeto que eu queria poder usar e não pude. Sua issue aberta #2288 sobre o Gigabyte Aero 15 OLED YD é, essencialmente, “ainda não temos este modelo”. Meu laptop é um SKU diferente daquela issue, mas próximo o bastante para eu saber que a resposta era a mesma: ainda não chegou.

Menciono o OpenRGB explicitamente porque num mundo melhor este post não existe — eu teria aberto o painel de configurações deles, escolhido um efeito e seguido em frente. Em vez disso, vou decodificar um pacote, escrever 593 linhas de Python e fingir que estou bem com isso.

O resumo de uma hora de pesquisa ficou mais ou menos assim:

text

  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

O que é o truque da pesquisa de arte anterior neste canto do mundo: a resposta para “existe algo para a minha máquina” é quase sempre não, mas a resposta para “o que outras pessoas já descobriram que eu não deveria re-derivar” é quase sempre alguma coisa. Fechei as abas do navegador com um plano meio formado: Python, /dev/hidraw, uma regra udev para não precisar de sudo, um pacote que provavelmente é “modo + params + checksum8” e — o grande risco — um algoritmo de checksum que eu teria de adivinhar porque nenhuma das artes anteriores usa exatamente este controlador.

O que me trouxe de volta à pergunta inocente: o que está realmente no meu barramento USB, e o que ele quer ouvir?

A primeira coisa que aprendi é que fazer engenharia reversa de um periférico USB é 10% de trabalho detetivesco esperto e 90% de leitura da saída do dmesg enquanto sua esposa pergunta o que você está fazendo à 1h da manhã. O teclado do Gigabyte Aero não é um dongle separado, claro — é um dispositivo USB interno, e o lsusb | grep 0414 gentilmente confirmou isso:

text

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

Fabricante 0x0414, produto 0x8104. Esse é o ITE-829X. Googlei tudo o que consegui encontrar, fiz referência cruzada com os fontes da ferramenta do Windows e formei uma hipótese: o controle RGB vive atrás de um HID feature report, exposto numa interface cuja usage page é 0xFF01 — uma página HID definida pelo fabricante, que é o equivalente USB de uma estrada de terra marcada como “privada”.

Aqui é onde a engenharia reversa para de parecer mágica e começa a parecer arqueologia de sistema de arquivos. O Linux expõe todo dispositivo HID em /sys/bus/hid/devices/, e para cada dispositivo ele te entrega um report_descriptor — um blob de bytes que te diz (se você apertar os olhos) sobre o que o dispositivo está disposto a conversar.

Escrevi um scanner. Dado o par fabricante/produto certo, ele faz glob no diretório sysfs, lê cada report_descriptor e procura pelos três bytes \x06\x01\xff — HID-ês para “usage page 0xFF01”. Quando encontro uma correspondência, sigo o symlink até seu subdiretório hidraw e pego o nó /dev/hidrawN que pertence a ele.

Pense no udev + sysfs aqui como uma espécie de /proc para periféricos: um documento vivo e somente-leitura que descreve o que o kernel atualmente acredita sobre cada dispositivo plugado. E, assim como /proc, a forma correta de usá-lo não é confiar em nenhum número de dispositivo em particular — /dev/hidraw0 hoje pode ser /dev/hidraw3 amanhã se você plugar um mouse — mas escanear, filtrar e escolher por propriedade.

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

O prefixo 0003: no padrão de glob merece uma pausa. Dispositivos HID são registrados pelo kernel com IDs de barramento, e 0003 é USB. O padrão completo se expande, para a minha máquina, em /sys/bus/hid/devices/0003:0414:8104.*. O * está ali porque um único dispositivo físico pode expor várias interfaces HID — uma para as teclas normais, uma para as teclas de mídia, uma para o canal de controle do fabricante — e eu quero aquela cujo report descriptor anuncia 0xFF01.

Aqui está o fluxo de descoberta com que acabei:

text

                 ┌──────────────────────────────────────────┐
                 │  /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) │
                 └──────────────────────────────────────────┘

Uma vez que esse caminho do hidraw está em mãos, todo o resto é fcntl.ioctl. Os ioctls HIDIOCSFEATURE e HIDIOCGFEATURE são a maneira padrão de enviar e receber HID feature reports a partir do userspace. Construí os números mágicos à mão porque não queria depender do 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

A classe AeroKeyboardRGB (src/aeroctl_linux_indicator/device.py:77-240) é toda a abstração de hardware num só lugar: abre o dispositivo, escreve pacotes de 9 bytes, lê respostas de 9 bytes, fecha ao sair. Ela também é um context manager, porque gosto dos meus file descriptors do jeito que gosto dos meus laptops caros: fechados quando terminei com eles.

Ter um file descriptor é uma coisa. Ter os bytes certos para enviar por ele é outra, e é aí que bati na primeira parede que parecia um quebra-cabeça em vez de um problema de encanamento — o que, acontece, é exatamente quando a engenharia reversa fica interessante.

Agora, o pacote. Esta é a parte engraçada.

Você poderia esperar — eu certamente esperava — que um modesto pacote de controle de 9 bytes para um controlador de LED se verificasse com XOR. Todo mundo usa XOR. XOR é barato, XOR é o que o engenheiro de firmware busca quando são 16h de uma sexta-feira e ele quer ir para casa. Encarei capturas do Wireshark da ferramenta do Windows. Escrevi um pequeno script em Python para tentar XOR sobre cada fatia possível do pacote. Nada disso correspondeu.

Tentei a soma simples. Também não.

Tentei 256 - sum. Perto. Suspeitosamente perto. Errado por um.

A resposta, acontece, é esta:

text

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

Não XOR. Não XOR. É uma coisa meio complemento-de-um de 8 bits: pegue a soma dos bytes de payload, mascare para um byte e depois subtraia de 0xFF. Um checksum do tipo “fazer o total inteiro dar 0xFF”. O que é perfeitamente razoável, no jeito em que todos os protocolos de hardware são perfeitamente razoáveis depois que você para de esperar que sejam.

Se você está pensando “ah, isso é só ~sum + 1 - 1, não é?” — sim, e também, não, pare com isso, não é um complemento de dois. É um invariante “soma mais checksum deve dar 0xFF”. O que é elegante, se ligeiramente incomum, e a primeira regra da engenharia reversa é: faça o que o hardware faz, mesmo que o hardware seja um pouco excêntrico.

Aqui está a implementação inteira:

python

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

Duas linhas, mais um decorator. O & 0xFF na parte de fora é cinto-e-suspensório — aritmética em Python é ilimitada, e eu quero que isso caiba num byte independentemente de quantos bytes eu passar.

O layout do pacote em si é este:

text

  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

A escolha de framing merece ser admirada por um momento. O byte 0 é o HID report ID, e como este dispositivo em particular usa o report ID 0, ele é efetivamente padding — mas a ABI hidraw do Linux sempre quer um report ID como o primeiro byte de um feature report, então eu o carrego junto. Os bytes 1–7 são onde o comando de fato vive. O byte 8 é o checksum sobre o qual passei duas horas estando errado.

Se você já escreveu emuladores de terminal, a analogia que encaixou para mim foi esta: um pacote de HID feature report é basicamente um código de escape ANSI com um checksum. Você tem um introdutor (o byte de comando), você tem parâmetros (efeito, velocidade, brilho, cor, direção) e você tem um terminador — exceto que o terminador, em vez de ser uma letra imprimível como m para “definir modo gráfico”, é um byte de checksum que verifica se o firmware não recebeu lixo. Envie o byte errado e o controlador educadamente o ignora. Envie os bytes certos e seu teclado vira uma discoteca.

O resto de device.py é só vocabulário. set_effect é _send_feature(_packet(0x08, 0x00, effect, speed, brightness, color, direction)). get_firmware_version é _send_feature(_packet(0x80)) seguido de uma leitura _get_feature(9). reset é _send_feature(_packet(0x13, 0xFF)). Cada comando é uma linha. Cada linha é uma frase numa língua que eu acabei aprendendo a falar.

Falar essa língua corretamente, no entanto, pressupunha que eu sequer conseguisse abrir o file descriptor em primeiro lugar — e depois que digitei minha senha do sudo pela quadragésima vez num dia, o próximo galho da árvore basicamente se anunciou.

Há um círculo especial do purgatório do desktop Linux reservado para ferramentas que funcionam perfeitamente, mas só se você as roda com sudo. Toda vez que eu alternava o teclado, eu tinha de digitar minha senha. Toda vez que o indicador de bandeja queria ler o efeito atual, ele não conseguia, porque um processo de usuário normal não pode abrir /dev/hidraw0 para leitura-escrita por padrão. Isso era um impedimento fatal para um “indicador de bandeja”.

A correção é uma regra udev de quatro linhas, e é uma pequena obra-prima:

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

A palavra mágica aqui é TAG+="uaccess". Essa tag diz ao systemd-logind: “quem quer que esteja atualmente logado no seat local deveria ter permissão para acessar este dispositivo”. Sem ela, /dev/hidraw0 pertence a root:root no modo 0660, e seu processo de usuário ricocheteia nele como uma mariposa numa janela.

Com ela, o systemd adiciona uma ACL ao nó de dispositivo dando ao seu usuário logado permissões de leitura+escrita, automaticamente, por todo o tempo em que você estiver no teclado. Faça logout, a ACL desaparece. Pluge um segundo teclado Aero, a ACL aparece nele também. É o equivalente Linux de um cartão-chave de hotel: você ganha acesso enquanto é o hóspede, o sistema o retoma quando você sai.

Gosto de pensar nas regras udev como uma carta de amor para o você-do-futuro. Você as escreve uma vez, você as deixa em disco, e para sempre depois disso, a máquina dá as boas-vindas ao seu eu futuro — aquele que esqueceu tudo sobre este projeto e só quer que o teclado se acenda — como se nada nunca tivesse sido difícil. O script install.sh faz este presente automaticamente na primeira instalação. Você nunca mais verá a carta de amor, porque você nunca mais precisará dela.

A regra udev resolveu a dança das permissões, mas resolvê-la também trouxe à tona uma pergunta mais silenciosa: agora que qualquer usuário logado podia controlar o dispositivo, como eles deveriam de fato controlá-lo? Na linha de comando à 1h da manhã? A partir de um menu de bandeja enquanto se escreve um e-mail? Ambos, idealmente — e no momento em que disse “ambos” em voz alta, o próximo galho do projeto se desenhou sozinho.

Uma vez que a camada de dispositivo funcionou, havia uma decisão a tomar: CLI ou GUI? Eu disse: por que não ambos. Mas eu também disse: não duas vezes.

O formato com que acabei é um clássico “dois frontends compartilhando um cérebro” — a mesma classe AeroKeyboardRGB no fundo, duas UIs muito diferentes em cima. Nenhuma das UIs sabe nada sobre HID. Nenhuma das UIs faz sua própria matemática de checksum. Ambas se apoiam na mesma classe de dispositivo, e o único estado que compartilham é um pequeno arquivo JSON no diretório de cache do usuário.

text

   ┌──────────────────────────┐    ┌─────────────────────────────┐
   │   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                      │
         └────────────────────────────────────────────────┘

A CLI vive em src/aeroctl_linux_indicator/cli.py:69-162. É uma porta de entrada argparse com subcomandos — devices, status, firmware, set, off, on, toggle, reset, keyboard-mode, raw — e cada subcomando é um bloco with kb: que abre o dispositivo, faz sua coisa e fecha.

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

O comando “off” não é “por favor escureça”. Por baixo dos panos, ele é: “capture o efeito atual em disco, depois defina o brilho como zero num efeito static-black”. O firmware do teclado não tem um desligamento real — ele tem brightness = 0. O comando on é simétrico: leia o JSON, empurre o efeito anterior de volta. Se não houver estado prévio (primeiro boot, instalação limpa), ele recorre a um confortável padrão de branco a 100% de brilho.

O indicador de bandeja (src/aeroctl_linux_indicator/tray.py:44-184) faz a mesma dança, mas dirigida por cliques de mouse. Ele constrói um menu GTK com três submenus — Brightness, Color, Effect — mais Toggle On/Off, Reset, Status, Quit. Cada item de menu está conectado aos mesmos 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)

O wrapper _safe é uma confissão de três linhas: “Sou um app de bandeja, e se a abertura do dispositivo falhar eu não deveria derrubar o desktop inteiro”. Ele engole exceções e as registra no stderr. Isto não é elegante. É pragmático. Um app de bandeja que travou é uma experiência de usuário pior do que um app de bandeja que silenciosamente falha em acender seu teclado, e se algo der errado — cabo desplugado, regra udev faltando, alguém puxou a bateria — você ainda tem uma sessão GNOME funcionando da qual consertar.

Como ambos os frontends escrevem para o mesmo ~/.cache/aeroctl-kbd-state.json, alternar a partir da bandeja e depois alternar a partir da CLI Faz A Coisa Certa. “Off pela bandeja, on pela CLI” restaura as mesmas cores e efeitos que você tinha. Essa pequena consistência é, honestamente, a razão inteira pela qual o indicador parece polido.

Polido no meu laptop, de qualquer forma. O pensamento que ficava me cutucando era: “polido no meu laptop” é uma armadilha — um indicador de bandeja que só roda em exatamente a distro, exatamente o Python e exatamente a build do AppIndicator que eu por acaso tenho instalada não é uma ferramenta, é um truque de festa. O que é como caí no buraco de coelho do PyInstaller.

Os desktops Linux são muitas coisas, mas “consistentes sobre APIs de bandeja de sistema” não é uma delas. A biblioteca original para isso se chamava AppIndicator3, mantida pela Canonical para o Unity. Quando o Unity morreu, foi adotada pelo MATE. Depois o GNOME a manteve viva por meio de uma extensão de shell. Então o Ubuntu 24.04 chegou — e com ele, o renome: AyatanaAppIndicator3, o fork-do-fork que todos deveriam usar agora.

Instalações novas no 24.04 trazem apenas o Ayatana. Sistemas mais antigos e algumas distros trazem apenas o legado. Algumas poucas trazem ambos. Eu precisava que meu binário funcionasse nos três.

A bandeja lida com isso em tempo de import com um pequeno try/except que tenta o nome moderno primeiro e recorre ao outro:

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")

Tudo bem para um import de Python. Não tudo bem para o PyInstaller, que tem de saber em tempo de build quais módulos empacotar. Então adicionei um teste-de-cheiro correspondente ao script de build: ele roda um pequeno snippet inline de Python contra o interpretador ativo, verifica qual das duas bibliotecas está de fato instalada e imprime o caminho de import. O shell então passa esse caminho para o PyInstaller como --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
)}"

Este é o tipo de detalhe que não entra em títulos de post de blog, porque “adicionei um heredoc de 18 linhas ao meu script de build” não é uma manchete. Mas é genuinamente a diferença entre “roda na minha máquina” e “roda na máquina do usuário também”. Quando alguém clona o repo no Fedora, o teste-de-cheiro resolve para o Ayatana. Quando alguém o clona numa caixa 20.04 LTS que nunca recebeu o recado, ele resolve para o nome legado. O PyInstaller empacota o correto e o binário Simplesmente Funciona.

O binário da CLI é construído com --onefile. O binário da bandeja é construído com --onedir, porque o runtime do GTK precisa de muitos arquivos irmãos (caches de ícone, arquivos de schema, typelibs) que o PyInstaller não consegue inlinar num único executável sem sofrimento. O script install.sh então faz tar da árvore onedir, a extrai em ~/.local/bin/aeroctl-kbd-tray/ e cria uma entrada autostart desktop que aponta para o binário da bandeja lá dentro.

Neste ponto, a história do software estava efetivamente terminada — a parte de entregar, de qualquer forma. O que restava era o lado da história do hardware, que eu só pude ler em retrospecto, pelo log de commits. E um desses commits é a razão pela qual estou escrevendo este ensaio.

Se você ler o git log do projeto de cima a baixo, ele se lê quase como um conto. Vou citar hashes de commit — os reais — porque é aqui que a narrativa ficou de verdade.

text

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 é o despejo inicial: 1.077 linhas de uma vez, 16 arquivos, a CLI, a bandeja, o script de build, o script de instalação, o README. É sempre assim que o primeiro commit de um projeto de engenharia reversa se parece — não porque você não saiba sobre boa higiene de commits, mas porque você acabou de passar um mês com ele vivendo num único diretório sem versionamento enquanto descobria se a coisa funcionaria de algum modo.

00055f2 é a primeira suíte de testes. Mockei a camada de hardware (/dev/hidraw não existe na CI), de modo que o test harness pudesse exercitar a construção de pacotes, o round-trip de estado e o parsing de argumentos da CLI sem precisar de um teclado físico no runner.

74646e6 é o vexame. Eu havia mockado AppIndicator.Indicator.new, mas esquecido um atributo que ele retorna, e os testes da bandeja falharam com um AttributeError lá no fundo do encanamento do GTK. Uma linha, um commit, 35 novas linhas de teste e um aumento de cobertura para ~78%.

caea8df me empurrou para além de 80% de cobertura lógica em cli.py — adicionou 56 linhas em tests/test_cli.py atingindo cada galho de subcomando. Para um projeto de 593 linhas, 80% é o ponto doce onde cada caminho principal é exercitado mas eu não estou escrevendo testes para internos do argparse.

f39a4b1 e f4a204e são as dores de crescimento do script de instalação. f39a4b1 fez duas coisas: traduziu o script inteiro do espanhol (uma das línguas nativas do autor) para o inglês para que não-falantes de espanhol pudessem auditá-lo, e fez a build do PyInstaller usar um virtualenv local para que o script não tropeçasse na PEP-668 em distros modernas baseadas em Debian. f4a204e é o pequeno polimento que diz “depois de uma instalação nova, também lance a bandeja agora mesmo, em vez de fazer o usuário fazer logout e login de novo”. Essa é a chamada nohup no fim do install.sh.

E então há 68dbdae: “chore: remove unsupported RGB effects”.

São seis linhas removidas de device.py e oito linhas alteradas em tray.py. É também o momento em que aprendi que meu novinho Aero X16 era menos capaz do que um Aero 15 de 2019. Olhe o diff:

python

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

Esses são efeitos reais. Eles existem no protocolo de firmware do Windows. Eles estão documentados. Eles simplesmente não funcionam no Aero 16 de 12ª geração. O firmware aceita o comando, mas o teclado não renderiza o efeito. Modelos mais novos descarregam esses efeitos para o Gigabyte Control Center — um serviço exclusivo do Windows que pinta as teclas quadro a quadro por USB a partir do userspace, porque o firmware não sabe mais como fazê-los por si só.

Por que `68dbdae` é a linha mais triste do repositório
Não é uma correção de bug. Não é um refactor. É um reconhecimento de que a Gigabyte tomou uma decisão de negócio — “deixe o app do Windows fazer a renderização, enxugue o firmware” — e essa decisão é permanentemente visível no meu teclado Linux na forma de seis nomes de efeito que não existem mais.

e1ac392 e 48c0f46 são os commits de README que adicionaram uma seção dedicada explicando isso aos usuários, para que ninguém mais tenha de aprender da forma que eu aprendi.

O que me resta são os sete efeitos que o firmware ainda fala nativamente: static, breathing, wave, fade, marquee, flash, raindrop. Eles bastam. Eles são bonitos. Eles eram, antes de 68dbdae, acompanhados por ripple e neon e rainbow, e sinto falta deles.

Se eu fosse fazer isto de novo, carregaria adiante um punhado de lições. Não muitas. Estes pequenos projetos ensinam suas lições em pequenas mordidas.

Cada geração de Gigabyte Aero tem suas próprias peculiaridades. O vendor ID permanece 0x0414, mas o product ID deriva, o report descriptor deriva, a tabela de efeitos deriva. Meu scanner usa a usage page 0xFF01 como sua âncora precisamente porque esse é o sinal mais estável — se um modelo futuro mudar os product IDs, contanto que o fabricante continue usando a família de controlador ITE, meu glob de sysfs e o sniff de descriptor provavelmente ainda o encontrarão. A alternativa (cravar PIDs no código) teria apodrecido em menos de um ano.

As 18 linhas de heredoc em build-binaries.sh:14-24 não são glamorosas. Ninguém jamais escreverá um post de blog sobre o try/except ValueError em tray.py:12-17. Mas juntos eles são a diferença entre “usuários Linux com a distro certa” e “usuários Linux”. Se você vai entregar um utilitário de desktop em 2026, você faz a dança do Ayatana. É o oposto de overengineering: são dez minutos de trabalho que silenciosamente cobrem três anos de fragmentação de distros.

A maior vitória isolada para os testes foi me comprometer com um AeroKeyboardRGB falso em tests/conftest.py. Uma vez que o hardware ficou mockável, cada subcomando da CLI virou uma função pura de suas entradas: argparse para dentro, interações de dispositivo falsas para fora. A CI roda sem um teclado plugado no runner. Os testes rodam numa fração de segundo. E os commits que elevaram a cobertura (74646e6, caea8df) adicionaram menos de 100 linhas de teste entre eles. Base pequena, suíte de testes pequena, cobertura real. É um bom lugar para se estar.

Esta é a que eu tatuaria em mim mesmo se pudesse. Quando você está fazendo engenharia reversa de um protocolo binário e o algoritmo óbvio não corresponde à captura, não seja esperto. Não assuma que o fabricante está usando uma variante conhecida de CRC. Tente as coisas burras primeiro, depois tente variantes ligeiramente-erradas das coisas burras, depois sente com a matemática até ela encaixar. O checksum do teclado Gigabyte é “faça o pacote totalizar 0xFF”, o que ninguém chamaria de estado-da-arte, mas é o que o firmware de fato computa, e em engenharia reversa, “o que o firmware de fato computa” vence “o que você acha que ele deveria computar” toda vez.


Meu teclado brilha, agora. Ele brilha quando faço login, porque o install.sh largou um arquivo de autostart em ~/.config/autostart/. Ele lembra sua última cor depois que eu o ligo e desligo, porque cli.py e tray.py ambos escrevem para o mesmo pequeno arquivo JSON em ~/.cache/. Ele funciona sem sudo, por causa de uma cláusula TAG+="uaccess" numa regra udev que configurei uma vez e nunca mais pensarei a respeito.

E em algum lugar debaixo de tudo isso, um pacote de 9 bytes — oito bytes de intenção, um byte de checksum não-XOR — está sendo entregue do Python ao fcntl.ioctl ao driver hidraw do Linux a um controlador ITE-829X que finalmente, pela primeira vez desde que apaguei o Windows, entende o que eu quero.

O que era, o tempo todo, apenas o teclado dizer algo de volta.

Related Content