最近给电脑换了一套 FK360 EL ARGB 水冷。

硬件安装没有问题,但冷头数显只支持 Windows 官方软件 SystemTemperatureMonitoring CHS V2.6。我的主力系统是 Arch Linux,数显在 Linux 下不会自己更新,因此这次目标不是“让 RGB 亮起来”,而是完整复现官方程序通过 USB HID 发送的监控数据。

最终实现是一个纯 Python 标准库的 Linux 用户态驱动,确认可用于 USB ID 0145:1001。它负责自动定位 hidraw、组装 65 字节报告、读取 hwmon、/proc、NVIDIA SMI 和 Intel RAPL,并通过 systemd 以 root 常驻运行。

0. 最终项目与当前默认参数

项目地址:

https://github.com/LuorixDev/fk360el-linux

当前默认配置是:

[fk360el]
device =
interval = 1
repeats = 1
repeat_delay = 0.02
display_mode = 0
power_smoothing = 0.6
verbose = false
  • device:留空时按 VID/PID 自动定位,也可以写死为 /dev/hidraw7

  • interval:完整采样与显示更新周期,单位秒,默认 1 秒。

  • repeats:同一份 65 字节报告连续发送次数,默认 1。

  • repeat_delay:重复报告之间的间隔,单位秒。只有 repeats > 1 时有效。

  • display_mode:原始模式字节,范围 0–255,目前默认 0。

  • power_smoothing:功耗 EMA 中“新值”的权重,1 表示不平滑,默认 0.6。

官方 Windows DLL 的发送节奏是 interval=0.5repeats=15repeat_delay=0.02。Linux 实机测试不需要每帧重复 15 次,因此最后把默认值改为 1 秒采样、每帧只写一次,降低无意义的 USB 写入。

1. 问题一:到底哪个接口负责数显

现象

水冷头有 ARGB 接口也有 USB 接口。ARGB 能由主板控制,不代表数显数据也走 ARGB。

排查

lsusb -d 0145:1001
ls -l /dev/hidraw*

# 查看 hidraw 对应的 USB/HID 身份
for dev in /sys/class/hidraw/hidraw*; do
  echo "=== $dev ==="
  cat "$dev/device/uevent" 2>/dev/null
done

实机确认数显设备为:

VID 0145
PID 1001
HID 输出节点 /dev/hidrawX

根因与修复

ARGB 的 3 针 5V 接口只负责灯珠,USB HID 才负责冷头屏幕。驱动扫描 /sys/class/hidraw/hidraw*,逐级读取父目录的 uevent,同时匹配两种内核格式:

HID_ID=...:00000145:00001001
PRODUCT=145/1001/...

匹配成功后,取同名节点 /dev/hidrawX,用 O_RDWR | O_CLOEXEC 打开;若设备拒绝读写模式,再回退为 O_WRONLY | O_CLOEXEC

因此不能把 hidraw7 写死。编号在重启或插拔后可能变化,VID/PID 才是稳定身份。

2. 问题二:Windows 程序到底写了什么

现象

官方安装包能正确刷新温度,但 Linux 下没有协议文档,也没有可直接调用的库。

排查方式

拆开 SystemTemperatureMonitoring CHS V2.6 后,顺着 HID 写入逻辑和数据组装顺序,确认动态数据使用固定 65 字节输出报告。Windows 程序并不是上传图片,而是周期性发送一组紧凑的数值字段。

65 字节报告结构

下面是当前实机验证过的偏移。偏移从写入 /dev/hidraw 的第一个字节开始计数:

偏移

长度

字段

编码 / 单位

0

1

Report ID

固定 0x00

1

1

动态数据命令

固定 0x02

2

1

CPU 温度

摄氏度,0–255

3

1

GPU 温度

摄氏度,0–255

4–6

3

时、分、秒

每项 1 字节

7

1

百分之一秒

microsecond / 10000

8–9

2

年份

世纪、世纪内年份

10–11

2

月、日

每项 1 字节

12

1

星期

Windows 规则,周日为 0

13

1

CPU 占用率

百分比

14–15

2

CPU 频率

MHz,16 位大端

16

1

GPU 占用率

百分比

17–18

2

GPU 频率

MHz,16 位大端

19

1

内存占用

百分比

20–23

4

保留

当前写 0

24

1

硬盘温度

摄氏度

25–26

2

CPU 风扇

RPM,16 位大端

27–28

2

CPU 电压

整数部分、小数两位

29–30

2

CPU 功耗

W × 10,16 位大端

31–32

2

GPU 风扇

16 位大端

33–34

2

GPU 电压

整数部分、小数两位

35–36

2

GPU 功耗

W × 10,16 位大端

37–48

12

保留

当前写 0

49

1

显示模式

display_mode

50–64

15

保留

当前写 0

16 位字段统一按大端写入:

report[offset] = number >> 8
report[offset + 1] = number & 0xFF

例如 CPU 功耗 65.4W 先乘 10 得到 654,即 0x028E,29、30 偏移分别写入 0x020x8E

写设备时强制检查 os.write() 的返回值必须是 65。少写任何字节都直接报 short HID write,避免把半份报告当成成功。

3. 问题三:Linux 没有一个接口能给齐所有数据

CPU 温度与风扇

遍历 /sys/class/hwmon/hwmon*,通过 name 识别设备。当前 CPU 名称集合包括:

coretemp
k10temp
zenpower
cpu_thermal
acpitz

temp*_input 是毫摄氏度,读取后除以 1000。温度标签按 package → tctl → tdie → cpu → core 0 排优先级,避免随便取到某个核心或主板环境温度。

风扇读取 fan*_input。带有 cpu 标签的通道优先,其他通道作为低优先级回退。

CPU 占用率

/proc/stat 的第一行只有累计 tick,不能直接当百分比。驱动保存上一次 total 和 idle:

total = 所有 CPU tick 之和
idle = idle + iowait
load = 100 × (1 - Δidle / Δtotal)

启动后先预采样一次并等待最多 150ms,再生成第一帧。否则第一次没有差值,CPU 占用率必然是 0。

CPU 频率与内存

CPU 频率读取所有核心:

/sys/devices/system/cpu/cpu[0-9]*/cpufreq/scaling_cur_freq

原始单位为 kHz,除以 1000 后取所有可用核心的平均 MHz。

内存读取 /proc/meminfo,使用:

memory_load = 100 × (MemTotal - MemAvailable) / MemTotal

这里使用 MemAvailable 而不是 MemFree,否则 Linux 文件缓存会被错误算成“已用内存”。

GPU

AMD、nouveau、nvidia hwmon 会尝试读取:

gpu_busy_percent
freq*_input
power*_input
fan*_input

Linux hwmon 的 power*_input 单位是 μW,最终需要除以 1,000,000 得到 W。代码内部先由通用读取器除 1000,再在 GPU/CPU 分支除 1000。

NVIDIA 额外调用:

nvidia-smi \
  --query-gpu=temperature.gpu,utilization.gpu,clocks.current.graphics,fan.speed,power.draw \
  --format=csv,noheader,nounits

超时设置为 2 秒。命令不存在、驱动通信失败、字段为非数字或没有输出时全部回退到 hwmon,不会中断主循环。

当前明确的限制

缺失的传感器会按 0 写入,不会阻止其他字段更新。当前 CPU/GPU 电压字段虽然协议位置已确认,但采集器没有稳定的跨平台来源,因此通常仍为 0。NVIDIA 的 fan.speed 返回百分比,不是 RPM,当前也没有把它伪装成 RPM 写入。

4. 问题四:CPU 功耗字段为什么一直是 0

现象

最初报告偏移 29–30 已经实现,但 Sample.cpu_watts 没有任何真实赋值,所以冷头始终显示 0W。

排查

find -L /sys/class/powercap -type f \
  \( -name name -o -name energy_uj -o -name max_energy_range_uj \) -print

ls -l /sys/class/powercap/intel-rapl:0/energy_uj
cat /sys/class/powercap/intel-rapl:0/name

Intel 平台暴露的不是实时 W,而是累计能量 μJ。实机同时存在:

package-0   CPU Package
core        Package 子域
uncore      Package 子域
psys        整个平台

如果把这些全部相加,会把同一份能量重复计算。正确做法是只选顶层名称以 package- 开头、且目录后缀只有一个数字的节点,例如 intel-rapl:0,排除 intel-rapl:0:0 这类子域。

计算逻辑

delta_uj = current_energy_uj - previous_energy_uj

如果 delta_uj < 0:
    delta_uj += max_energy_range_uj

watts = delta_uj / 1,000,000 / elapsed_seconds

同一时刻读取所有 Package,多路 CPU 时把各 Package 功耗相加。优先级为:

  1. /sys/class/powercap/intel-rapl:*:MSR 后端。

  2. /sys/class/powercap/intel-rapl-mmio:*:MSR 不可读时回退。

  3. AMD k10temp/zenpowerpower*_input

为了防止休眠、驱动重置或错误计数器产生巨大假值,单 Package 超过 2000W 的结果直接丢弃。

权限问题

实机 energy_uj 权限是:

-r-------- root root ... energy_uj

普通用户运行 --dry-run 时 CPU 功耗显示 0,不代表计算错误,而是根本读不到能量计数器。安装后的 systemd 单元明确设置:

User=root
Group=root

root 服务是为了读取 RAPL,不是为了访问 hidraw。hidraw 本身已经有 udev 规则处理。

5. 问题五:功耗每秒跳动,屏幕看起来很乱

RAPL 和 GPU 功耗都是瞬时采样。1 秒更新时,负载变化会让数值在几十瓦范围内快速跳动。

最后对 CPU/GPU 功耗分别维护 EMA 状态:

smooth = α × current + (1 - α) × previous
α = power_smoothing = 0.6

第一份有效值直接采用,不做从 0 缓慢爬升。之后若功耗从 20W 跳到 100W,结果为:

第 1 帧:68.0W
第 2 帧:87.2W
第 3 帧:94.9W

这个权重只压掉视觉抖动,不会产生几秒钟的严重延迟。配置为 0.8 更灵敏,配置为 1 完全关闭平滑。允许范围为 0.05–1。

6. 权限与常驻服务

udev

SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0145", ATTRS{idProduct}=="1001", MODE="0660", TAG+="uaccess"

这条规则让当前桌面登录用户可以访问对应 hidraw。安装后执行:

sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=hidraw

systemd

[Unit]
Description=FK360 EL ARGB USB display monitor
After=local-fs.target

[Service]
Type=simple
User=root
Group=root
ExecStart=/usr/local/lib/fk360el/fk360el.py --config /etc/fk360el.conf
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
RestrictAddressFamilies=AF_UNIX

[Install]
WantedBy=multi-user.target

Restart=on-failure 用于设备瞬时断开或传感器异常退出;ProtectSystem=strictProtectHome=true 等限制 root 服务的可写范围。没有启用 PrivateDevices=true,因为那会把真正需要写入的 hidraw 一起隔离。

7. 一键安装、升级和卸载

git clone https://github.com/LuorixDev/fk360el-linux.git
cd fk360el-linux
sudo ./install.sh

安装目标固定为:

源文件

安装位置

fk360el.py

/usr/local/lib/fk360el/fk360el.py

fk360el_config.py

/usr/local/bin/fk360el-config

install.sh

/usr/local/sbin/fk360el-uninstall

fk360el.conf

/etc/fk360el.conf

99-fk360el.rules

/etc/udev/rules.d/99-fk360el.rules

fk360el.service

/etc/systemd/system/fk360el.service

安装器只在 /etc/fk360el.conf 不存在时写默认配置,所以升级不会覆盖自己的参数。每次安装都会执行 daemon-reload、enable 和 restart,确保新代码立刻生效。

安装成功后源文件夹可以删除。卸载命令已被独立复制:

sudo fk360el-uninstall

卸载会停止并禁用服务,删除程序、udev 规则、systemd 单元以及 /etc/fk360el.conf

8. TUI 与非交互配置

sudo fk360el-config

TUI 使用 Python 标准库 curses,不需要 textual、rich 等 pip 依赖。上下选择,左右切换预设值,E 手动编辑,Enter 执行保存、启动、停用、测试或重启。

脚本环境直接用子命令:

fk360el-config show
sudo fk360el-config set interval 1
sudo fk360el-config set repeats 1
sudo fk360el-config set repeat-delay 0.02
sudo fk360el-config set display-mode 0
sudo fk360el-config set power-smoothing 0.6
sudo fk360el-config set device auto
sudo fk360el-config test
sudo fk360el-config restart
sudo fk360el-config enable
sudo fk360el-config disable

test 会先判断服务是否运行:运行中则临时 stop,发送一帧测试报告,最后在 finally 中重新 start,避免两个进程同时写 hidraw。

9. 最终验证顺序

# 1. USB 身份
lsusb -d 0145:1001

# 2. root 下确认 RAPL 可读
sudo /usr/local/lib/fk360el/fk360el.py --list-sensors

# 3. 单帧硬件测试
sudo fk360el-config test

# 4. 服务状态
systemctl status fk360el.service

# 5. 临时打开详细日志
sudo fk360el-config set verbose true
sudo fk360el-config restart
journalctl -u fk360el.service -f

正常日志现在会同时输出功耗:

CPU 47C 6% 3643MHz 23.8W; GPU 42C 10% 2542MHz 67.5W; RAM 52%

自动化测试目前覆盖 65 字节固定布局、字段钳位、配置优先级、新默认参数、RAPL 正常差分、RAPL 回绕、MMIO 回退、功耗平滑和日志功耗字段。

总结

这次真正困难的部分不是 os.write(fd, report),而是确定 65 字节每个偏移的含义、找对 Linux 数据源、处理单位和计数器回绕,再把 root-only RAPL 与普通 hidraw 权限拆开考虑。

最后形成的结构很简单:Python 标准库负责采集和 HID,udev 负责设备权限,systemd 负责 root 常驻与失败重启,INI 配置和 curses TUI 负责用户侧调整。

目前只确认 FK360 EL ARGB、VID:PID 0145:1001。其他外观相似的冷头不应直接假定协议相同,至少先检查 USB ID,再用单帧测试确认报告结构,之后再交给 systemd 常驻运行。