教技嘉键盘讲 Linux

我买了一台技嘉 Aero X16,本打算把它打造成我的"日常主力机,顺便还能跑构建而不哭鼻子"。在 Windows 上,开机后发生的第一件事颇具戏剧性:键盘亮起一波青色光浪,像一支小小的 LED 行进乐队,技嘉 Control Center 还兴高采烈地坚持要我在十几种 RGB 效果里挑一个。它感觉像是一块想跟我说话的键盘。

然后我装上了 Linux,键盘就闭嘴了。没有一丝闪烁。没有一点微光。什么都没有。

平心而论,机器还能打字。按键还能敲出字符。但对于那些为 RGB 付了钱、然后花一整晚盯着昏暗塑料的开发者来说,存在一种特有的隐痛。这隐痛顶出一个念头:如果硬件还在工作,而唯一改变的只是操作系统,那么在我的用户会话和键盘固件之间的某个地方,有一场本应进行却没有进行的对话——而没有进行的对话,原则上是可以被开启的。我看了看现有的工具:有一个很棒的 Windows 项目叫 wtwrp/aeroctl,它能做到你想要的一切,前提是你也愿意一起跑 Windows。我不愿意。于是我做了该做的事:打开一个终端,对着 lsusb 嘟囔了一句不可印的脏话,然后开始捅鼓。

几个晚上之后,我做出了一个小小的 Python 项目,取名 aeroctl-linux-indicator。整个包的源码是 593 行(device.py + cli.py + tray.py),测试覆盖率约 80%,它提供一个 CLI 和一个托盘指示器,而它存在的全部目的,就是说服一颗深埋在技嘉机身里的 ITE-829X 控制器:Linux 确实是一个值得拥有彩色光的合法操作系统。

这是我如何走到那一步的故事。它也是关于一个提交——68dbdae,“chore: remove unsupported RGB effects”——为何是整个仓库里最悲伤的一行的故事。

不过,那个提交是线头的末端。它的开端是一个更天真的问题——一个我在被别人答案折腾了几个晚上之后才问出口的问题:USB 总线上到底插着什么我还没跟它说过话的东西,而我又怎么会知道?

在写下一行 Python 之前,我做了每个工程师都该做的事,去寻找已有的工作。令人沮丧又令人鼓舞的消息是,关于"从 Linux 控制技嘉笔记本 RGB 键盘"已经有相当多的前人之作。令人沮丧的部分是,没有一个能在我这台特定的笔记本上开箱即用。令人鼓舞的部分是,它们每一个都教会了我某些我最终用上的东西。

下面是我在离开之前走过的那棵树。

wtwrp/aeroctl 是最初的 AeroCtl,一个面向 Windows 的 C# / .NET 应用程序。它给了我的项目名字,而阅读它是理解一台受到良好支持的技嘉 Aero 从主机端看起来是什么样的最简单办法:风扇控制、充电策略、非标准 Fn 键、键盘 RGB、GPU boost,全都走技嘉的 ACPI WMI 驱动(acpimof.dll 之流)。RGB 那一节尤其有参考价值:它确认了键盘是通过一个 HID feature report 触及的,而不是通过某个专有的 IO 端口或 WMI blob。

问题显而易见:它是 Windows。整个前提是你已经装好了技嘉 ControlCenter 或 SmartManager,所以 acpimof.dll 是存在的。这些在我这台只跑 Linux 的笔记本上都不存在。但 Samples/ 里的 RGB 示例代码——就是那段"打开 HID 设备,写一个 feature report,关闭"的部分——在精神上是可移植的。那正是我搬过来的那块。

tangalbert919/AeroControlCenter 是技嘉 Control Center 的 Linux 移植版,用 C++/Qt 写成,面向 Aero 15 Classic(SA/WA/XA/YA)。它的路子比我的更有野心:它想处理风扇控制、电池策略以及 RGB,而为了干净地做到这一点,它依赖一个配套的内核驱动 gigabyte-laptop-wmi,把 WMI 接口暴露为 Linux sysfs 节点。

我欣赏这个架构,却用不了它。我的机器不在受测列表里,gigabyte-laptop-wmi 在我机身上的现代内核上无法干净地绑定,而我也没有那个胃口去在一台我还得用来收发邮件的笔记本上调试一张 WMI ACPI 表。不过 AeroControlCenter 给了我那个 70-keyboard.rules 文件——也就是说,确认了 udev 这条路子对于权限问题来说是对的形状。我写了自己的规则,但我之所以会写,是因为那个项目的规则告诉了我该把它放在哪里。

martin31821/fusion-kbd-controller 是一个用 libusb 写的小小的 C 用户态二进制程序,面向 AERO 15X。读它的 README 是一趟过山车:它需要 root,它会临时把 USB 设备从内核的 usbhid 驱动上解绑,作者还乐呵呵地警告说"在这里发送荒谬的值可能会把你的键盘变砖"。

它能用,在 15X 上,用 libusb,靠内核解绑。这些东西我一个都不想要。我想要 /dev/hidraw,这样我就能在内核仍然掌管 HID 接口的同时跟设备对话(这样我发送颜色命令时按键还能继续打字),我想要一条 udev 规则这样我就不需要 root,而我真的不想为了搞清楚"荒谬的值"对别人硬件意味着什么,把自己的键盘变成砖。但 fusion-kbd-controller 是我捡到"带有一个模式和一个参数区域的字节包"这个心智模型的地方,它最终推广到了我后来写的那个 ITE-829X feature report 包上。

rcassani/keyboard-fusion-rgb 是一个面向 AORUS 15G 的 Python HIDAPI 驱动。它是最接近"我最终做的事"的东西——Python、HID、用户态、通过观察 Windows 版 AORUS Control Center 逆向得来——只差一个细节:厂商/产品 ID 是 0x1044:0x7a3c(Chu Yuen Enterprise Co., Ltd.),这是一个与我笔记本里那颗(0x0414:0x8104,即 ITE-829X)完全不同的键盘控制器家族。

所以代码无法照搬使用——线上格式不同,每键布局不同,模式 ID 不同。但那路子正是我想要的:从 Python 发出 HIDAPI 风格的 feature report,通过观察 Windows 工具写了什么来解码。那个项目的存在也是一记道德上的推动:如果 rcassani 能靠观察 USB 流量解码一个技嘉子家族,那我也能用同样的方式解码我笔记本上的这个。

CalcProgrammer1/OpenRGB 是通用的 RGB 工具——Windows、Linux、macOS,天底下每一块主板和每一家内存厂商。它正是那个我想用却用不上的项目。他们那个关于技嘉 Aero 15 OLED YD 的未关闭 issue #2288,本质上就是"我们还没有这个型号"。我的笔记本和那个 issue 是不同的 SKU,但近到足以让我知道答案是一样的:还没到。

我之所以明确提到 OpenRGB,是因为在一个更美好的世界里这篇文章不会存在——我本可以打开它们的设置面板,挑一个效果,然后就此打住。可现实是,我要去解码一个数据包、写 593 行 Python,再假装我对此没意见。

一小时研究的总结大致是这样:

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

这正是在世界这个角落里做前人之作研究的诀窍:“有没有给我这台机器的东西"这个问题的答案几乎总是没有,但"别人都搞清楚了哪些我不该重新推导一遍的东西"这个问题的答案几乎总是有那么一些。我关掉浏览器标签页,带着一个半成形的计划:Python、/dev/hidraw、一条让我不需要 sudo 的 udev 规则、一个很可能是"模式 + 参数 + checksum8"的数据包,以及——最大的风险——一个我得靠猜的校验和算法,因为前人之作里没有一个用的正是这颗控制器。

这把我带回了那个天真的问题:我的 USB 总线上实际有什么,而它又想听到什么?

我学到的第一件事是,逆向工程一个 USB 外设,10% 是聪明的侦探活,90% 是在凌晨一点你配偶问你在干什么时阅读 dmesg 输出。技嘉 Aero 的键盘当然不是一个独立的 dongle——它是一个内部 USB 设备,而 lsusb | grep 0414 殷勤地确认了这一点:

text

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

厂商 0x0414,产品 0x8104。那就是 ITE-829X。我把能找到的一切都谷歌了一遍,跟 Windows 工具的源码交叉比对,形成了一个假设:RGB 控制藏在一个 HID feature report 之后,暴露在一个 usage page 为 0xFF01 的接口上——一个厂商自定义的 HID 页,相当于 USB 世界里一条标着"私人"的土路。

这里就是逆向工程不再像魔法、开始像文件系统考古的地方。Linux 把每一个 HID 设备暴露在 /sys/bus/hid/devices/ 下,而对每个设备它都递给你一个 report_descriptor——一团字节,告诉你(如果你眯起眼睛的话)这个设备愿意聊些什么。

我写了一个扫描器。给定正确的厂商/产品对,它对 sysfs 目录做 glob,读取每一个 report_descriptor,并寻找那三个字节 \x06\x01\xff——HID 行话里的"usage page 0xFF01"。当我找到一个匹配时,我顺着 symlink 进入它的 hidraw 子目录,抓住属于它的那个 /dev/hidrawN 节点。

把这里的 udev + sysfs 想象成外设的某种 /proc:一份只读的、活的文档,描述内核当前对每个插入设备的认知。而且和 /proc 一样,使用它的正确方式不是去信任任何特定的设备号——今天的 /dev/hidraw0 在你插上一个鼠标后明天可能就成了 /dev/hidraw3——而是去扫描、过滤,并按属性挑选。

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

glob 模式里的 0003: 前缀值得停一下。HID 设备由内核用总线 ID 注册,而 0003 是 USB。对我的机器来说,完整的模式展开为 /sys/bus/hid/devices/0003:0414:8104.*。那个 * 之所以在那里,是因为单个物理设备可以暴露好几个 HID 接口——一个给普通按键,一个给媒体键,一个给厂商控制通道——而我想要的是那个 report descriptor 声明了 0xFF01 的接口。

下面是我最终得到的发现流程:

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

一旦那个 hidraw 路径到手,剩下的一切都是 fcntl.ioctlHIDIOCSFEATUREHIDIOCGFEATURE 这两个 ioctl 是从用户态发送和接收 HID feature report 的标准方式。我手搓了那些魔数,因为我不想依赖 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

AeroKeyboardRGB 类(src/aeroctl_linux_indicator/device.py:77-240)把整个硬件抽象集中在一处:打开设备,写 9 字节的数据包,读 9 字节的响应,退出时关闭。它还是一个上下文管理器,因为我喜欢我的文件描述符就像我喜欢我那些昂贵的笔记本一样:用完就关。

有一个文件描述符是一回事。有要往里送下去的正确字节是另一回事,而那正是我撞上第一堵看起来像谜题而非管道问题的墙的地方——而事实证明,那恰恰就是逆向工程变得有趣的时候。

现在,说说数据包。这是有趣的地方。

你或许会预期——我当然预期了——一个用于 LED 控制器的、区区 9 字节的控制数据包,会用异或来校验自己。人人都用异或。异或很便宜,异或是固件工程师在周五下午四点、一心想回家时顺手就抓的东西。我盯着从 Windows 工具抓来的 Wireshark 捕获。我写了个小 Python 脚本,对数据包每一种可能的切片尝试异或。没有一个对得上。

我试了简单求和。也不对。

我试了 256 - sum。接近了。可疑地接近。差了一。

事实证明,答案是这个:

text

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

不是异或。**不是异或。**它是一种 8 位的、类似反码的东西:取 payload 字节之和,掩码成一个字节,然后从 0xFF 里减掉。一个"让整个东西总和为 0xFF“的校验和。这是完全合理的,就像所有硬件协议在你不再指望它们合理之后都变得完全合理一样。

如果你在想"啊,这不就是 ~sum + 1 - 1 嘛?"——是的,而且,不,别这样,它不是二的补码。它是一个"和加校验和应该等于 0xFF“的不变量。这很巧妙,虽然略微不寻常,而逆向工程的第一条规则是:硬件怎么做你就怎么做,哪怕硬件有点古怪。

下面是全部实现:

python

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

两行,外加一个装饰器。外面那个 & 0xFF 是双保险——Python 里的算术是无界的,而无论我传进多少字节,我都想让这个结果塞进一个字节里。

数据包布局本身是这样:

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

这个分帧的选择值得欣赏片刻。字节 0 是 HID report ID,而由于这个特定设备使用 report ID 0,它实际上就是 padding——但 Linux 的 hidraw ABI 总是要求一个 report ID 作为 feature report 的第一个字节,所以我把它一路带着。字节 1–7 才是真正命令所在的地方。字节 8 是那个我花了两个小时弄错的校验和。

如果你写过终端模拟器,对我而言一拍即合的类比是这个:**一个 HID feature report 数据包基本上就是一个带校验和的 ANSI 转义码。**你有一个引导符(命令字节),你有参数(效果、速度、亮度、颜色、方向),你还有一个终止符——只不过终止符不是像 m(“设置图形模式”)那样的可打印字母,而是一个校验和字节,用来核实固件没有被递了一堆垃圾。送错字节,控制器就礼貌地无视你。送对字节,你的键盘就变成一个迪斯科。

device.py 的其余部分只是词汇表。set_effect_send_feature(_packet(0x08, 0x00, effect, speed, brightness, color, direction))get_firmware_version_send_feature(_packet(0x80)) 后跟一个 _get_feature(9) 读取。reset_send_feature(_packet(0x13, 0xFF))。每个命令是一行。每一行都是一门我最终学会说的语言里的一个句子。

然而,正确地说这门语言,前提是我首先得能打开那个文件描述符——而当我在一天里第四十次敲入我的 sudo 密码之后,这棵树的下一根枝丫基本上是自己宣告登场的。

Linux 桌面的炼狱里有一个特殊的圈层,专门留给那些工作得完美无瑕、但前提是你得用 sudo 来跑的工具。每次我切换键盘,我都得敲我的密码。每次托盘指示器想读取当前效果,它都读不到,因为一个普通用户进程默认无法以读写方式打开 /dev/hidraw0。对一个"托盘指示器"来说,这是致命伤。

修复办法是一条四行的 udev 规则,它是一件小小的杰作:

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

这里的魔法词是 TAG+="uaccess"。这个标签告诉 systemd-logind:“当前在本地座位登录的人,应该被允许访问这个设备。“没有它,/dev/hidraw0root:root 所有、权限为 0660,而你的用户进程会像扑窗的飞蛾一样被弹回来。

有了它,systemd 就给设备节点加上一条 ACL,自动地给你这个登录用户读+写权限,只要你还坐在键盘前就一直有效。注销,ACL 消失。插上第二块 Aero 键盘,ACL 也会出现在新的那块上。它是 Linux 版的酒店门卡:你当客人期间获得访问权,离店时系统把它收回。

我喜欢把 udev 规则想成一封写给未来的你的情书。你写它一次,把它留在磁盘上,从此往后,机器就永远欢迎你未来的自己——那个已经把这个项目忘得一干二净、只想让键盘亮起来的自己——仿佛一切从来都不曾艰难过。install.sh 脚本在首次安装时自动送出这份礼物。你再也不会见到这封情书,因为你再也不需要它。

udev 规则解决了权限的舞步,但解决它也浮出了一个更安静的问题:既然现在任何登录用户都能驱动这个设备,那他们究竟该如何驱动它?凌晨一点在命令行上?还是在写邮件的同时从一个托盘菜单里?理想情况下,两者都要——而当我把"两者都要"大声说出来的那一刻,项目的下一根枝丫就自己铺展开了。

一旦设备层跑通了,就有一个决定要做:CLI 还是 GUI?我说:何不两者都要。但我也说:别做两遍。

我最终得到的形态是经典的"两个前端共享一个大脑”——底部是同一个 AeroKeyboardRGB 类,顶部是两个很不一样的 UI。两个 UI 都对 HID 一无所知。两个 UI 都不做自己的校验和运算。它们都倚靠同一个设备类,而它们共享的唯一状态,是用户缓存目录里一个小小的 JSON 文件。

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

CLI 位于 src/aeroctl_linux_indicator/cli.py:69-162。它是一个带子命令的 argparse 大门——devicesstatusfirmwaresetoffontoggleresetkeyboard-moderaw——而每个子命令都是一个 with kb: 代码块,打开设备,干它的活,然后关闭。

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

“off” 命令不是"请熄灭”。在底层,它是:“把当前效果捕获到磁盘,然后在一个静态黑色效果上把亮度设为零。“键盘固件没有真正的关机——它有的是 brightness = 0on 命令是对称的:读取 JSON,把先前的效果推回去。如果没有先前的状态(首次启动、全新安装),它会回退到一个舒服的默认值:白色,100% 亮度。

托盘指示器(src/aeroctl_linux_indicator/tray.py:44-184)跳同样的舞步,只是由鼠标点击来驱动。它构建一个 GTK 菜单,有三个子菜单——Brightness、Color、Effect——外加 Toggle On/OffResetStatusQuit。每个菜单项都接到同样的辅助函数上:

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)

_safe 包装器是一句三行的招供:“我是一个托盘应用,如果设备打开失败,我不该把整个桌面搞崩。“它吞掉异常并把它们记录到 stderr。这不优雅。这很务实。一个崩溃了的托盘应用比一个悄悄地点不亮你键盘的托盘应用是更糟糕的用户体验,而如果出了什么差错——线缆拔了、udev 规则没了、有人把电池拔了——你还有一个能用的 GNOME 会话,可以从里面去修。

因为两个前端都写入同一个 ~/.cache/aeroctl-kbd-state.json,从托盘切换、然后从 CLI 切换会做对的事。“从托盘关、从 CLI 开"会恢复你之前拥有的同一套颜色和效果。这点小小的一致性,老实说,正是这个指示器之所以感觉精致的全部原因。

不过,精致是在我这台笔记本上。一直拉扯着我的念头是:“在我这台笔记本上精致"是一个陷阱——一个只能在我恰好装了的那个发行版、那个 Python、那个 AppIndicator 构建上运行的托盘指示器不是工具,而是一个派对小把戏。这就是我如何掉进 PyInstaller 兔子洞的。

Linux 桌面是很多东西,但"在系统托盘 API 上保持一致"不是其中之一。最初的这个库叫 AppIndicator3,由 Canonical 为 Unity 维护。Unity 死了之后,它被 MATE 收养。然后 GNOME 通过一个 shell 扩展让它活了下来。然后 Ubuntu 24.04 推出了——随之而来的是改名:AyatanaAppIndicator3,那个所有人现在都应该用的"分叉的分叉”。

24.04 上的全新安装只带 Ayatana。更老的系统和一些发行版只带遗留的那个。少数几个两个都带。我需要我的二进制文件在这三种情况下都能用。

托盘在导入时用一个小小的 try/except 处理这件事,先尝试现代的名字,再回退:

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

对一个 Python 导入来说没问题。对 PyInstaller 来说就不行了,它必须在构建时就知道要打包哪些模块。于是我给构建脚本加了一个对应的嗅探测试:它对着活动的解释器跑一小段内联 Python,检查这两个库里哪一个是真正装着的,并打印出导入路径。然后 shell 把那个路径作为 --hidden-import 传给 PyInstaller:

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

这是那种进不了博客文章标题的细节,因为"我给构建脚本加了一段 18 行的 heredoc"算不上头条。但它确确实实是"在我机器上能跑"和"在用户机器上也能跑"之间的区别。当有人在 Fedora 上克隆这个仓库时,嗅探测试解析为 Ayatana。当有人在一台从没收到过通知的 20.04 LTS 机器上克隆它时,它解析为遗留的名字。PyInstaller 打包正确的那个,于是二进制文件就是能用。

CLI 二进制用 --onefile 构建。托盘二进制用 --onedir 构建,因为 GTK 的运行时需要一大堆兄弟文件(图标缓存、schema 文件、typelib),PyInstaller 没法在不带来痛苦的情况下把它们内联进单个可执行文件。然后 install.sh 脚本把这棵 onedir 树打成 tar,解压到 ~/.local/bin/aeroctl-kbd-tray/,并创建一个指向里面那个托盘二进制的 autostart 桌面项。

到这一步,软件这边的故事实际上已经讲完了——至少是发布的那部分。剩下的是硬件那边的故事,而那个故事我只能事后从提交日志里读出来。其中一个提交,正是我之所以会写这篇随笔的全部原因。

如果你把这个项目的 git log 从头读到尾,它读起来几乎像一篇短篇小说。我打算引用提交哈希——真实的那些——因为正是在这里,叙事变得真切起来。

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 是最初的倾倒:一口气 1,077 行,16 个文件,CLI、托盘、构建脚本、安装脚本、README。一个逆向工程项目的第一个提交总是这副样子——不是因为你不懂良好的提交卫生,而是因为你刚刚花了一个月,让它住在一个没有版本控制的单一目录里,一边琢磨这玩意儿到底行不行得通。

00055f2 是第一套测试。我把硬件层 mock 掉了(/dev/hidraw 在 CI 里不存在),这样测试装置就能在不需要 runner 上插着物理键盘的情况下,演练数据包构造、状态往返和 CLI 参数解析。

74646e6 是那次出丑。我 mock 了 AppIndicator.Indicator.new,却忘了它返回的一个属性,于是托盘测试在 GTK 接线深处以一个 AttributeError 失败了。一行,一个提交,35 行新测试,以及覆盖率上升到约 78%。

caea8df 把我在 cli.py 上推过了 80% 的逻辑覆盖率——它在 tests/test_cli.py 里加了 56 行,击中了每一个子命令分支。对一个 593 行的项目来说,80% 是那个甜点位:每一条主要路径都被演练到,但我又不至于去给 argparse 的内部写测试。

f39a4b1f4a204e 是安装脚本的成长之痛。f39a4b1 做了两件事:它把整个脚本从西班牙语(作者的母语之一)翻译成英语,好让不说西班牙语的人也能审计它;它还让 PyInstaller 构建使用一个本地 virtualenv,这样脚本就不会在现代基于 Debian 的发行版上撞上 PEP-668。f4a204e 是那点小小的打磨,它说的是"在一次全新安装之后,也马上把托盘启动起来,而不是逼用户注销再登录”。那就是 install.sh 末尾那个 nohup 调用。

然后就是 68dbdae“chore: remove unsupported RGB effects”。

它是从 device.py 删掉的六行,以及在 tray.py 里改动的八行。它也是我得知我那台崭新的 Aero X16 竟不如一台 2019 年的 Aero 15 能干的那一刻。看看这个 diff:

python

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

那些是真实的效果。它们存在于 Windows 固件协议里。它们有文档。它们只是在第 12 代 Aero 16 上不工作。固件接受了命令,但键盘不渲染那个效果。更新的型号把这些效果卸载给了 Gigabyte Control Center——一个只在 Windows 上的服务,它从用户态通过 USB 一帧一帧地绘制按键,因为固件自己已经不再知道怎么做它们了。

为何 `68dbdae` 是仓库里最悲伤的一行
它不是一个 bug 修复。它不是一次重构。它是一个承认:技嘉做了一个商业决定——“让 Windows 应用去做渲染,把固件削薄”——而这个决定以六个不再存在的效果名字的形式,永久地显现在我的 Linux 键盘上。

e1ac39248c0f46 是那两个 README 提交,它们加了一个专门的章节向用户解释这件事,好让别人不必像我那样去学会它。

留给我的,是固件仍然原生会说的那七个效果:staticbreathingwavefademarqueeflashraindrop。它们够用了。它们很美。在 68dbdae 之前,它们还有 rippleneonrainbow 相伴,而我想念它们。

如果我要再做一遍这件事,我会带上几条教训往前走。不多。这些小项目以小口小口的方式教授它们的教训。

每一代技嘉 Aero 都有它自己的怪癖。vendor ID 始终是 0x0414,但 product ID 会漂移,report descriptor 会漂移,效果表会漂移。我的扫描器之所以拿 0xFF01 这个 usage page 当锚点,恰恰因为那是最稳定的信号——如果未来某个型号改了 product ID,只要厂商还在用 ITE 控制器家族,我那个 sysfs glob 和 descriptor 嗅探很可能仍然能找到它。另一条路(把 PID 写死在代码里)不出一年就会烂掉。

build-binaries.sh:14-24 里那 18 行 heredoc 并不光鲜。没有人会为 tray.py:12-17 里的那个 try/except ValueError 写一篇博客文章。但合在一起,它们就是"装了对的发行版的 Linux 用户"和"Linux 用户"之间的区别。如果你打算在 2026 年发布一个桌面工具,你就得跳那支 Ayatana 之舞。它是过度工程的反面:它是十分钟的工作,悄悄地覆盖了三年的发行版碎片化。

测试上最大的单项胜利,是下定决心在 tests/conftest.py 里搞一个假的 AeroKeyboardRGB。一旦硬件可被 mock,每一个 CLI 子命令就都变成了它输入的纯函数:argparse 进去,假设备交互出来。CI 在 runner 上没插键盘的情况下就能跑。测试在零点几秒内跑完。而那些提升了覆盖率的提交(74646e6caea8df)两者加起来添加的测试还不到 100 行。小代码库,小测试套件,真实的覆盖率。这是一个不错的位置。

这一条我要是能办到就刺在自己身上。当你在逆向一个二进制协议、而那个显而易见的算法对不上捕获时,别耍小聪明。别假设厂商用的是某个已知的 CRC 变体。先试那些笨办法,再试笨办法的略微偏一点的变体,然后跟数学一起坐着,直到它咔哒一声对上。技嘉键盘的校验和是"让数据包总和为 0xFF",没人会管这叫顶尖技术,但它就是固件实际计算的东西,而在逆向工程里,“固件实际计算的东西"每一次都胜过"你以为它应该计算的东西”。


我的键盘现在会发光了。它在我登录时发光,因为 install.sh~/.config/autostart/ 里丢了一个 autostart 文件。它在我把它关了又开之后还记得它上一次的颜色,因为 cli.pytray.py 都写入 ~/.cache/ 里同一个小小的 JSON 文件。它不用 sudo 也能工作,因为一条 udev 规则里的一句 TAG+="uaccess",那条规则我设置过一次,此后再也不会去想它。

而在这一切之下的某个地方,一个 9 字节的数据包——八个字节的意图,一个字节的非异或校验和——正被从 Python 一路递交给 fcntl.ioctl,递给 Linux 的 hidraw 驱动,递给一颗 ITE-829X 控制器,它终于,自我抹掉 Windows 以来第一次,明白了我想要什么。

而那,从头到尾,不过是想让键盘回我一句话。

相关内容