ElXreno/flydigictl

GitHub: ElXreno/flydigictl

一款用于在 Linux 上控制 Flydigi BS 系列笔记本散热器的工具,提供 CLI、守护进程和桌面界面,支持基于多传感器温度的自定义风扇曲线和灯效控制。

Stars: 0 | Forks: 0

# flydigictl 在 Linux 上控制 Flydigi BS 系列笔记本散热器。 ## 已测试的硬件 | 散热器 | 连接方式 | |-----------------|-----------------------| | Flydigi BS3 Pro | Bluetooth (PID `1004`)| BS2、BS2 Pro 和 BS3 共享相同的协议,应该同时支持 Bluetooth 和 USB,但未经测试。BS1 使用的是 BLE 而非 HID,因此不受支持。无论您的型号是哪种,都可以提交一个 issue。 ## 要求 - 带有 `hidraw` 支持的 Linux - 通过系统 Bluetooth 设置配对的散热器 - 对 hidraw 设备的读写权限(通过 udev 规则或 root 用户) ## 用法 ``` $ flydigictl list /dev/hidraw8 BS3 Pro $ flydigictl status current 1700 rpm target 1700 rpm mode gear gear quiet (max overclock) $ flydigictl set 2600 target 2600 rpm $ flydigictl watch -n 3 current 1800 rpm target 2600 rpm mode realtime gear quiet (max overclock) current 2100 rpm target 2600 rpm mode realtime gear quiet (max overclock) current 2400 rpm target 2600 rpm mode realtime gear quiet (max overclock) $ flydigictl auto released to gear mode $ flydigictl sensors nvidia 0000:01:00.0 (core) active 46 C nvidia 0000:01:00.0 (memory) active 48 C k10temp 0000:00:18.3 Tctl 59 C amdgpu 0000:66:00.0 edge 41 C nvme 0000:05:00.0 (nvme0) Composite 33 C spd5118 0000:00:14.0/0050 - 41 C ``` `set` 会保持固定转速,直到您调用 `auto`、重启散热器电源或通过物理按键切换档位。在该模式激活期间,档位 LED 指示灯会闪烁。 ## 安装 ### NixOS (module) ``` { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flydigictl = { url = "github:ElXreno/flydigictl"; inputs.nixpkgs.follows = "nixpkgs"; }; }; outputs = { nixpkgs, flydigictl, ... }: { nixosConfigurations."hostname" = nixpkgs.lib.nixosSystem { modules = [ flydigictl.nixosModules.default { programs.flydigictl.enable = true; programs.flydigictl.gui.enable = true; } ]; }; }; } ``` 该 module 会安装二进制文件以及授予会话访问散热器权限的 udev 规则。`gui.enable` 会添加桌面界面,该界面作为独立的 package 构建:它会引入 wgpu 和窗口技术栈,这对于无头安装毫无用处。 界面是自行绘制的,而不是通过 GTK 或 Qt 绘制的,因此没有任何桌面主题能影响到它:`org.freedesktop.appearance` 提供了浅色或深色偏好、强调色和对比度标志,但没有提供调色板。如果不作设置,它会遵循该偏好。 如果指定了颜色,它就会使用这些颜色。它会读取它能解析的第一个文件: | 路径 | | |------|--| | `$FLYDIGICTL_PALETTE` | 您指定的任何位置 | | `~/.config/flydigictl/palette.json` | 本应用自身的配置 | | `~/.cache/wallust/colors.json` | wallust 最后生成的配置 | | `~/.cache/wal/colors.json` | 来自 pywal 的相同配置 | 其自身的文件定义了它使用的六种颜色: ``` { "background": "#1f2430", "text": "#cccac2", "primary": "#73d0ff", "success": "#d5ff80", "warning": "#ffd173", "danger": "#f28779" } ``` JSON 格式的 base16 配色方案(`base00` 到 `base0F`)也可以。读取 wallust 和 pywal 缓存是为了照顾那些已经生成过配置的桌面环境——那里不需要进行任何设置。 在 Nix 上,`homeModules.default` 会自动填充该文件: ``` programs.flydigictl = { enable = true; palette = with config.lib.stylix.colors.withHashtag; { background = base00; text = base05; primary = base0D; success = base0B; warning = base0A; danger = base08; }; }; ``` 决定十六种方案颜色中的哪一种扮演六种角色中的哪一种需要主观判断,这就是为什么该 module 直接接收结果而不是自己做出决定。 ### 其他发行版 从 [releases](https://github.com/ElXreno/flydigictl/releases) 获取 `.deb`、`.rpm` 或压缩包, 或者自己构建: ``` $ cargo build --release ``` 然后手动安装 udev 规则: ``` $ sudo tee /etc/udev/rules.d/70-flydigi-cooler.rules <<'EOF' SUBSYSTEM=="hidraw", ATTRS{idVendor}=="37d7", MODE="0660", TAG+="uaccess" SUBSYSTEM=="hidraw", KERNELS=="*:37D7:*", MODE="0660", TAG+="uaccess" EOF $ sudo udevadm control --reload-rules $ sudo udevadm trigger --subsystem-match=hidraw ``` `70-` 前缀不是装饰性的:systemd 的 `73-seat-late.rules` 会运行 `uaccess` 内置程序,因此如果规则在其后执行,会导致对设备打标签太晚而无法接收 ACL。第二行对于 Bluetooth 很重要:这些散热器挂载在 `uhid` 上,没有带有 `idVendor` 的 USB 父设备。 ## 协议 [docs/FIRMWARE.md](docs/FIRMWARE.md) 是参考资料:从固件中读取的完整命令接口,包含参数范围、什么会被截断及什么会被拒绝、确认语义、状态机以及用于测试的逐命令接受标准。 [docs/PROTOCOL.md](docs/PROTOCOL.md) 是早期的映射图,基于黑盒测试编写;如果两者有出入,以固件为准,前者的第 11 节列出了修正内容。 **不要发送 `0xDF` 命令。** 它会擦除固件的第一个 flash 扇区并重启进入 ROM bootloader,没有任何身份验证和 payload 门控,并且在通过 USB 重新刷写之前,散热器将无法再次运行。任何未配对的 Bluetooth 设备都能触发它,因此即使这里的任何操作都不会发送它,也值得了解一下。`0x06` 是恢复出厂设置,而带有越界档位的 `0x08` 会损坏存储的档位表;这两者在这里也均未使用。如果您要对该设备进行模糊测试,请排除这三个命令。 ## 许可证 MIT ## 守护进程 `flydigictld` 会对散热器运行风扇曲线,并为其他工具暴露一个 socket。 ``` services.flydigictl = { enable = true; settings = { interval_secs = 3; hysteresis_rpm = 100; standby = "delayed"; smoothing = { rise_secs = 10.0; fall_secs = 60.0; panic_c = 90; }; curves = [ { name = "ram"; sensor.hwmon = "spd5118"; # both DIMMs, hottest one wins panic_c = 78; points = [ { temp_c = 46; rpm = 0; } # below this the fan stops entirely { temp_c = 47; rpm = 500; } # one degree later, the slowest it holds { temp_c = 58; rpm = 800; } { temp_c = 70; rpm = 2200; } { temp_c = 78; rpm = 4000; } ]; } { name = "cpu"; sensor = { hwmon = "k10temp"; label = "Tctl"; }; panic_c = 92; points = [ { temp_c = 52; rpm = 0; } { temp_c = 53; rpm = 600; } { temp_c = 72; rpm = 1800; } { temp_c = 92; rpm = 4000; } ]; } ]; }; }; ``` 该 module 会写入 `/etc/flydigictl/config.toml`。 每条曲线将其自身的传感器转换为转速,并取最高需求为准。这很重要,因为不同子系统之间的温度没有可比性:60 C 对于 CPU 来说是空闲状态,但对于内存条来说则已经是高温了,因此将它们平均会让温暖的硬盘躲在凉爽的处理器后面。`flydigictl sensors` 列出了可用于指向曲线的传感器;空的 `label` 会匹配该 hwmon 的所有输入并取最热的一个,这样就可以用一条曲线同时覆盖两根 DIMM 或两块硬盘。 转速在各个点之间进行插值,并且只要散热器回退到档位模式(它会在每次重新连接后执行此操作),就会重新应用目标转速。 `rpm = 0` 会让风扇停止,而且停止并不是启动的镜像操作。任何介于 1 到 500 rpm 之间的转速都属于失速区间——扇叶几乎不转,转速计在 0 和 400 之间跳动——因此落在这个区间内的点或插值会被向上取整到 500。一条打算停止的曲线应该在一个刻度(温度)内从 0 阶跃到它的第一个工作转速。只要曲线一发出请求,加速就会立刻发生;而停止则要等待每一条曲线持续同意一分钟,因为停止的风扇需要大约二十秒的“失速-重试”才能再次转动。无论哪种情况,手动设置的转速都会立即应用。 在曲线读取读数之前会先对其进行平滑处理,并且上升时的时间常数比下降时更短。CPU 可以在十秒内飙升三十度然后再回落;对输入进行平滑处理可以保证对真正的升温保持响应,同时让瞬间的峰值几乎不产生波动。达到或超过 `panic_c` 的原始读数会绕过平滑处理,并且该阈值属于每条曲线,因为 85 C 对于 CPU 来说只是正常的工作负载,而对于 SSD 来说早就出问题了。 由于声明式配置存放在 store 中,因此无法对其进行写入。守护进程会注意到这一点,将运行时更改保留在内存中并作出提示: ``` [WARN ] /etc/flydigictl/config.toml is read-only, runtime changes are lost on restart ``` 在 NixOS 之外,相同的文件是可写的,并且更改会被保存。无论哪种方式,配置都会被实时重新加载:守护进程监视的是*目录*,因此在 `nixos-rebuild switch` 期间替换 symlink 会被捕捉到,普通的编辑器保存也是如此。 ### Socket 在 `/run/flydigictl/flydigictl.sock` 上使用换行符分隔的 JSON: ``` $ echo '{"request":"status"}' | socat - UNIX-CONNECT:/run/flydigictl/flydigictl.sock {"reply":"status","model":"BS3 Pro","connected":true,"temp_c":49,"current_rpm":1100,"target_rpm":2826, "manual":false,"leading":"ram","demands":[{"name":"ram","temp_c":49,"smoothed_c":49,"rpm":2826,"panic":false}, {"name":"cpu","temp_c":51,"smoothed_c":51,"rpm":500,"panic":false}]} ``` | 请求 | 效果 | |---------|--------| | `{"request":"status"}` | 转速、模式以及每条曲线的读数,加上哪条曲线处于领先 | | `{"request":"subscribe"}` | 将连接转换为状态更新流 | | `{"request":"get_config"}` | 当前生效的配置,以及它是否可以保存 | | `{"request":"set_config","config":{...}}` | 替换配置 | | `{"request":"set_manual","rpm":1500}` | 保持固定转速;`"rpm":null` 返回至曲线控制 | | `{"request":"sensors"}` | 守护进程可读取的温度输入及其当前读数 | | `{"request":"gears"}` | 散热器中存储的四个档位转速,以及电源是否支持每个档位 | | `{"request":"set_gear","gear":"quiet","rpm":1500}` | 重写其中一个档位 | | `{"request":"set_lighting","lighting":{"mode":{"mode":"effect","effect":3},"brightness":60,"indicators":true}}` | 一次性设置整个灯光状态 | | `{"request":"set_standby","standby":"delayed"}` | 当宿主离开时散热器的行为 | 一条曲线通过 hwmon、设备和标签来命名其传感器,空的字段会匹配任何内容——没有标签时会取该芯片中最热的输入,这就是一条曲线覆盖两根 DIMM 的原理。这里的设备是一个**稳定的地址**,而不是内核名称: ``` $ flydigictl sensors nvme 0000:05:00.0 (nvme0) Composite 37 C nvme 0000:02:00.0 (nvme1) Composite 39 C spd5118 0000:00:14.0/0050 (21-0050) - 49 C ``` `nvme0` 和 `nvme1` 是按探测顺序分配的,并且在每次启动时可能会互换,因此针对它们编写的配置最终可能会监视到另一块硬盘。该地址是芯片所在的 PCI 插槽,加上多个芯片共享一条总线时的 i2c 地址——同一 SMBus 上的两根内存条仅凭此就能区分开来。手动编写的 `device = "nvme0"` 仍然可以匹配,只是并不可靠。 ### NVIDIA 曲线可以追踪 NVIDIA GPU,而内核没有为其发布 hwmon: ``` services.flydigictl.nvidia.enable = true; services.flydigictl.settings.curves = [ { name = "gpu"; sensor = { kind = "nvidia"; label = "core"; }; # or "memory", or empty for the hotter panic_c = 87; points = [ { temp_c = 52; rpm = 0; } { temp_c = 53; rpm = 600; } { temp_c = 80; rpm = 2800; } { temp_c = 87; rpm = 4000; } ]; } ]; ``` 读数**不是**来自 `nvidia-smi`,这正是关键所在。打开驱动程序的任何设备节点都会获取运行时电源引用,并强制显卡进入 D0 状态,随后驱动程序需要数秒的空闲时间才能再次挂起——因此,每隔几秒轮询一次的曲线会在守护进程运行的整个期间让笔记本显卡保持唤醒状态。先检查电源状态只能避免唤醒休眠中的显卡;它对于防止已唤醒的显卡再次进入休眠毫无帮助。 相反,守护进程通过 sysfs 以只读方式映射显卡的 BAR0,并自行读取寄存器。该路径永远不会进入驱动程序,也不会获取电源引用,并且挂起的显卡会全返回 1 而不是被唤醒,守护进程会将其读取为“没有温度,也不需要温度”。已在 RTX 4060 Laptop 上验证:每三秒读取一次,显卡仍然按时挂起。 这里暴露了两个温度。`label = "memory"` 是内存接点温度,位于 BAR0 的 `0xE2A8` 处,采用表示三十二分之一度的十二位精度——这是在 Ada 上获取该温度的唯一方法,因为此时 `nvidia-smi` 报告为 `N/A`。`label = "core"` 是核心温度,位于 BAR0 的 `0x20400` 处,采用低字节中的整数度;没有人记录过该地址,因此它是通过在已知温度下转储热孔并保留与之同步变化的数据发现的,随后在冷却运行期间与 `nvidia-smi` 进行了核对。空的标签会遵循两者中温度较高的一个。这两者都很重要:在受带宽限制的负载下,内存的温度会远远高于核心温度,而且它没有自己的风扇。 这需要系统提供两样东西。内核命令行必须带有 `iomem=relaxed`,因为驱动程序会声明该热孔,否则 `iomem_is_exclusive` 会拒绝映射;如果缺少它,NixOS module 会发出警告。此外,守护进程需要对 `/sys/bus/pci/devices/
/resource0` 的读取权限,内核在创建它时将其所有者设为 root 并且权限为 `0600`,无法请求其他权限——该 module 提供了一条 udev 规则,在 NVIDIA 显示控制器上授予 `flydigi` 组读取它的权限。无法读取其显卡的曲线会明确报告该情况,而不是悄无声息地消失:它在状态中被列为不可读,并在界面中显示为 `cannot read`。 请向守护进程请求传感器列表,而不是直接读取 `/sys/class/hwmon`。这两者可能会有差异:systemd 的 `PrivateNetwork=` 会赋予服务自己的网络 namespace,sysfs 由 namespace 标记,并且属于网络设备的每个 hwmon(例如 Wi-Fi 卡的温度)都会从该服务的视图中消失,而对其他所有人仍然清晰可见。基于其中之一构建的曲线永远读不到任何内容。正是出于这个原因,这里的单元没有使用 `PrivateNetwork=`,而且 `RestrictAddressFamilies=AF_UNIX` 已经阻止了守护进程打开网络 socket。 `set_manual` 以下的所有操作都是通过守护进程而不是绕过它来到达散热器的:它拥有该设备,如果第二个进程写入同一个 hidraw 节点,其确认信息会被窃取。这也是该界面本身不提供设备访问权限的原因。 订阅是追踪散热器状态而不是反复查询它的方法:每当其状态发生变化时,守护进程就会写入一次状态,这会发生两次每秒,因为散热器汇报自身状态的频率就是如此。曲线仍然在 `interval_secs` 上进行评估,因此这些更新中的温度会以自身较慢的节奏变化。断开的散热器会作为一个带有 `"connected":false` 的状态到达,这就是客户端区分拔下的散热器和死掉的守护进程的方法。在该连接上不会读取其他任何内容,因此请打开第二个连接来发送请求。 警告除了文本之外还带有一个稳定的 `code`,因此只显示一次每个警告的客户端可以基于该代码进行去重,而不是基于消息文本进行去重,因为消息文本命名了在每次重新构建时都会更改的配置路径。 ### 灯光 散热器中没有任何东西会汇报灯带正在播放的内容,因此守护进程只知道它被告知的内容。通过 socket 进行的更改在其运行期间一直有效;在配置中声明它们,以便每次连接时恢复它们: ``` [lighting] brightness = 60 indicators = true [lighting.mode] mode = "effect" # or "off", or "static" with a colour effect = 3 ``` 亮度既适用于动画也适用于纯色:无论哪种方式它都是同一个 header byte,因此调暗效果并不会使其停止运行。 `lights_follow_screens = true` 会在所有已连接的显示器关闭时关闭灯带和档位指示灯,并在第一个亮起的显示器出现时重新开启它们。守护进程从 `/sys/class/drm` 读取此状态,合成器关闭监视器后会在那里留下一个已禁用的连接器或一个表明该状态的 DPMS 属性——不需要会话、不需要桌面 portal 也不需要总线参与,这就是让系统服务能够感知到的原因。在此期间做出的选择会被记住而不是被应用,因此不会有任何东西照亮空房间。 ### 撤销 界面发送的每一次更改都是一个快照,因此 `Ctrl+Z` 可以逐步撤回它们,而 `Ctrl+Shift+Z` 或 `Ctrl+Y` 可以逐步重做。快照是在更改发出时拍摄的,而不是在更改过程中拍摄的:拖动一个点只会产生一个快照,而不是每移动一个像素就产生一个。 只有配置是以这种方式被记住的。灯光和保持的转速则不会:散热器正在显示它们,而撤销这些意味着必须告诉它再次更改。 ### 导出 守护进程保存着正在运行的配置,并且在 NixOS 上,其背后的文件是一个任何人都无法编辑的 store 路径。界面中的**导出 (Export)** 会将其复制为 TOML,因此手动拖拽成型的曲线可以粘贴到配置文件中或转换为 Nix。 ### 待机 ``` $ flydigictl standby delayed standby delayed ``` `off`、`instant` 和 `delayed` 决定了当宿主离开时(例如关闭笔记本)散热器的行为。这是固件自身的功能:它会停止风扇、熄灭两个光源,并在宿主返回时带着之前的档位唤醒。该设置存储在散热器中,因此即使守护进程停止,它也能继续工作。 守护进程会在每次连接时根据配置重新应用 `standby`。
标签:信息收集, 可视化界面, 散热管理, 硬件控制, 蓝牙, 通知系统