Skip to content

feat(device): BS1 支持 USB HID 通道 - #47

Merged
TIANLI0 merged 6 commits into
TIANLI0:mainfrom
Bolingboluo:feat/bs1-hid
Oct 26, 2026
Merged

TIANLI0 merged 6 commits into
TIANLI0:mainfrom
Bolingboluo:feat/bs1-hid

Conversation

@Bolingboluo

Copy link
Copy Markdown
Contributor

概述

  • 在 BLEManager 内部加一层 HID 传输,BS1 的命令集/应答等待/0xEF 解析全部复用,上层零改动
  • 报文格式按官方 App 的 IsBS1Serial 分支实现(32 字节 / Report ID 0x01 / 校验和挪到末字节),真机逐条对比蓝牙一致
  • 11 个文件,+795/-17,对 BS2/BS3 和 BS1 蓝牙路径无行为改动
  • 压力测试 10 轮断开重连,Core 句柄零增长(508→508)
  • 顺带记录了 10 个既有问题(0x27 无响应、挡位转速预设偏大等),均未在本 PR 修改

背景

THRM 目前对 BS1 只走蓝牙。但 BS1 插 USB-C 线时同时提供数据通道——官方 App
(HIDClient.Send 的 IsBS1Serial 分支)就是有线优先,只有没插线时才走 BLE。

THRM 少了这条路,导致:

这个 PR 把 USB HID 通道补上。

改动思路

不新建设备类型,而是在 BLEManager 内部加一层传输实现:

BS1 的全部功能逻辑(命令集、应答等待、0xEF 状态解析、调试记录)
        ↑ 完全复用,没有改动
─────────────────────────────────────────
传输层:BLEManager.WriteCommand
          ├── hidTransport != nil → 走 USB HID
          └── 否则              → 走 BLE GATT

deviceType 仍然是 DeviceTypeBLE——它表示"走 BS1 协议栈",不是"走蓝牙"。
所以 GUI、智能控温、曲线、热键等上层代码零改动。

报文格式差异

BS2 系列 BS1
VID/PID 0x37D7 / 0x1001..1004 0x04A0 / 0x2501
报文长度 25 字节 32 字节
Output Report ID 0x02 0x01
校验和位置 原位 挪到最后一字节
输入报文前缀 — 0x03

依据:官方 App 的 IsBS1Serial 分支;已在真机(固件 0.0.6.2)逐条验证。

文件清单(11 个,+795 / -17)

新增

文件 说明
internal/device/bs1_hid.go HID 传输实现:枚举、报文封装、读循环
internal/device/bs1_hid_test.go 读循环解析与应答派发的单元测试
internal/powernotify/hid.go HIDDeviceID 类型

修改

文件 改动
internal/device/ble.go WriteCommand 按传输分流;新增 ConnectHID;断开处理区分传输
internal/device/device.go connect() 在 BS2/BS3 之后、BLE 兜底之前尝试 BS1 HID
internal/coreapp/lifecycle.go HID 到达通知同时登记 BS1 的 VID/PID
internal/coreapp/system_device.go 设备无响应时的用户提醒
internal/coremsg/catalog.go 通知文案(中/英/日)
internal/powernotify/hid_windows.go / hid_linux.go 注册接口支持多个 VID/PID
internal/device/hid_enumerate_test.go 补 BS1 枚举的 mock

实现中值得说明的三点

1. BS1 的分支位置

connect() 里的顺序必须是:BS2/BS3 → BS1 HID → BLE 兜底。

BS1 那一段必须排在 shouldSkipBLEFallback 之前——否则上一次连的是 BS2 时,
该函数会直接跳过整个 BLE 分支,插着的 BS1 就永远连不上。

2. HID 到达通知要单独登记 BS1 的 VID

Core 靠「HID 接口到达通知」发现设备插入,同时健康检查在通知可用时会放慢周期性
重连。原注册只覆盖 BS2/BS3(VID 0x37D7),BS1 是 0x04A0,不在名单里。

后果(实测):BS1 插回电脑后,到达通知因 VID 不匹配被丢弃;健康检查又认为通知
已就绪而不再快速轮询 → 两头都不动,60 秒内日志零重连尝试,只能手动点连接。

所以把 RegisterHIDInterfaceArrivalNotifications 的入参从
(vendorID uint16, productIDs []uint16) 改成 ([]HIDDeviceID),两组 VID 一起登记。

3. "假连接"检测

BS1 走 USB 时有一种状态:设备仍在设备管理器里、风扇照转,但 HID 通道完全不再响应。
(详见下文「已知限制」)

原来的读循环把"读超时/空读"一律当成"暂时没数据",于是 Core 一直显示「已连接」而
转速空白。加了两道防线:

  • 静默看门狗:超过 20 秒一帧未收即判定掉线。依据是实测——BS1 无论风扇转不转
    都约每 0.5 秒推一次 0xEF,20 秒静默必然是异常
  • 连接探测:句柄打开后 2 秒内收不到任何帧即判定"设备装死",直接关闭并返回失败。
    避免界面先显示成"已连接"再断开,也避免自动重连反复重演

探测失败时还会跳过 BLE 兜底扫描(依据见「已知限制」第 2 条),失败检测从
约 12 秒缩短到约 2 秒。这个判断每次尝试都重新求值,避免上一次的结论残留导致
设备改接充电头后蓝牙连不上。

验证情况

真机:Flydigi BS1,固件 0.0.6.2,Windows 11。

项目 结果
HID 连接 / 枚举 / 报文收发 ✅
全部命令(查询、调速、挡位、配置、自启) ✅ 与蓝牙逐条对比一致
状态推送(0xEF)解析 ✅
GUI 功能(T1–T7) ✅ 全通过
蓝牙通道端到端 ✅
换供电自动重连(HID↔BLE 双向,10 轮插拔) ✅ 9 次成功;1 次失败是操作竞态(见下)
压力测试(10 轮断开重连) ✅ Core 句柄零增长(508 → 508),零告警
睡眠唤醒后 2 秒识破假连接 ✅
不影响 BS2/BS3 与 BLE 路径 ✅

那 1 次失败的具体原因(不是代码问题):在蓝牙 GATT 服务发现进行中把线插回了
电脑,设备从蓝牙侧消失 → 发现 GATT 服务失败: could not retrieve device services。
5 秒后自动重试走 HID 成功。

已知限制

1. 睡眠/休眠唤醒后 USB 通道会失效(设备固件行为)

系统从 S3 唤醒后,BS1 仍留在设备管理器里、风扇照转,但 HID 通道完全不再响应:

  • 新开句柄:无应答
  • 复用旧句柄:无应答
  • 连 0x05 握手:无应答
  • 被动监听:一帧都没有(实测唤醒后 2 分 19 秒零帧,0x22 查询 7 次全部超时)

只有重新枚举 USB 能恢复——拔插线、或重启系统,两种方式都实测过。
用"保持句柄跨睡眠"的独立脚本确认过,与句柄新旧无关。

蓝牙通道在睡眠唤醒后是否也会失效,我们没有测过——本次所有睡眠测试设备都接在
USB 上。

本 PR 的处理:2 秒探测识破 + 界面显示离线 + 用户手动点「连接」失败时发系统通知,
告知拔插线或重启。

没有做自动提醒,原因是实测:自动重连发生在刚唤醒、桌面还没出来的时候,
通知会落在锁屏阶段,用户看不见(要事后去通知中心翻)。试过用"距上次键鼠输入"
判断用户是否在场,但唤醒本身就需要按键,这个信号失效。所以改成等用户主动点连接
——那时的反馈一定落在眼前。

2. 插着 USB 时设备不广播蓝牙

实测:设备活着且插着 USB 时扫描 15 秒,扫到 15 个设备但没有 BS1。

所以 HID 探测失败后无需再试 BLE,直接在 connect() 里跳过。

顺带发现的既有问题(都不是本 PR 引入,均未修改)

以下是接 BS1 的过程中撞见的、与传输层无关的现象。每条都标了证据来源,
供判断是否需要单独处理。

实测确认

① 0x27(QueryGearRPMTable)完全无响应——USB 与 BLE 都一样

QueryDeviceSettings 对两条通道用同一份 basicDeviceSettingsQueryCommands
(0x27/0x25/0x45),其中 0x27 每次都超时。本次测试期间的日志统计:

等待命令 0x27 响应超时 × 39
  ├── HID 连接期间 31 次
  └── BLE 连接期间  8 次

两条通道都不响应,不是 HID 特有的问题。每次读设备信息白等一个
deviceResponseTimeout(response.go:11,900ms)。

(协议报告里没有 0x27,所以我们的验证工具直接拒发;是 THRM 自己在发。)

② 挡位转速预设与设备实际不符

代码里(types.BS1GearCommands):静音 1300 / 标准 2100 / 强劲 2800 / 超频 3500
设备实际回报的目标值:          静音 1300 / 标准 2100 / 强劲 2700 / 超频 3000

后两个偏大。超频那一条的实测方式:PD 供电下点超频,设备自己报的目标是 3000
(模式 4);非 PD 下点超频,设备降级到目标 2700(模式 3)。

③ 0x05 会清除实时目标转速

实测:Core 运行中目标 1320,另一个进程发 0x05 后目标变 0、风扇停转。
THRM 把它命名为 ClearInitializationLatch 是准确的。

对测试的启示:Core 在跑时不要用其他工具连同一设备,否则会把转速打掉。

④ 0x45 在设备失效状态下也会超时

正常状态下 0x45 工作良好;睡眠唤醒后设备"装死"期间会超时(本次 2 次)。
属于失效状态的连带表现,不是独立问题。

⑤ HID 下挡位显示「未知」

0xEF 的 payload[0] 两条通道编码不同:

BLE:  0x6A → 位域(高 4 位 = 最大挡位,低 4 位 = 当前挡位)
HID:  0x30 / 0x32 / 0x34 → ASCII 字符,值 = (挡位-1)×2

现有 parseBS1GearSettings 按位域解,所以 HID 下挡位名显示"未知"。
只影响挡位名称,转速/模式/控制全部正常。

挡位 4 因供电不足会被设备降级回"强劲",没取到它的 payload[0] 样本,
所以这条公式只覆盖挡位 1–3。

⑥ BS1 的固件版本被硬编码成 unsupported

internal/device/query.go:248 在 BS1 路径里写死 FirmwareReadStatus: "unsupported"。
但实测 0x01 在 USB HID 下正常响应,返回 0.0.6.2。

BLE 下 0x01 是否响应,我们没有测过(测的时候设备插着线,无法切 BLE)。
所以这条只能说明:接入 HID 后,USB 场景下 BS1 的固件版本是可读的。

⑦ 超频在非 PD 供电下会被设备静默降级

实测两次对比:

电脑 USB 口点超频 → 命令成功(设置挡位成功: 超频 3500)
                  → 设备目标变 2700、模式回到 3     ← 静默降级

PD 充电宝点超频   → 超频前 1300 RPM / 模式 1
                  → +6s 后目标 3000、模式 4        ← 真正生效

界面此时仍显示"超频"选中——挡位选中态取自 config.manualGear
(FanCurve.tsx:1886),不读设备实际挡位。这里只陈述这个现象。

代码确认

⑧ 实时转速下发只在 Debug 级留痕

// SetFanSpeed 末尾
b.logDebug("BS1 已设置转速: %d RPM", rpm)

// SetManualGear 末尾
b.logInfo("BS1 设置挡位成功: %s (目标转速: %d RPM)", gear, cmd.RPM)

两条路径的日志级别不一致:挡位是 Info,实时转速是 Debug。默认日志里看不到
实时转速的下发记录,排查"转速不对"时需要开 --debug。

⑨ 界面没有实时帧列表

后端有 GetDebugFrames,前端从未调用(grep -rn "GetDebugFrames" frontend/src/
无结果)。调试面板只有「调试模式 / 刷新调试信息 / PawnIO / 命令发送框」。

⑩ 没有「停止」模式

原版客户端 FanSpeedMode 有 STOP,THRM 的类型里没有对应项
(grep "STOP" internal/types/types.go 无结果)。

原版切手动挡位前会先置 STOP 再发 0x08;THRM 直接发 0x08,实测功能正常。

设备行为备忘(实测,方便以后排查)

  1. 实时模式下档位灯固定 3 格全亮——手动挡位时 1/2/3 格对应静音/标准/强劲,
    但进了实时模式(0x23)后,实测 1500 和 2500 都是 3 格全亮。具体含义未知,
    只记录这个现象。
  2. 不能靠挡位灯判断模式——只能看 0xEF 的 WorkMode。
  3. 风扇是斜坡控制——改目标后转速要几秒才到位,不能立刻判断命令是否生效。
  4. 超频需要 PD 供电——普通 USB 口下会被设备静默降级。
  5. 插着 USB 时不广播蓝牙。
  6. 睡眠唤醒后 USB 通道失效(见「已知限制」第 1 条)。
  7. 一个 USB-C 口同时供数据与供电——所谓"换供电自动切换"实际是"换供电 →
    设备重新上电 → 系统重新枚举 → 自动重连",不是无缝切换。

兼容性

  • BS2 / BS2PRO / BS3 / BS3PRO:未改动,行为不变
  • BS1 走蓝牙:未改动,行为不变(hidTransport == nil 时完全走原路径)
  • 非 Windows:HID 到达通知返回"未实现",其余逻辑未改动

测试

go build ./...                          ✅
go vet ./internal/...                   ✅(仅 internal/rtss 有一条既有告警)
go test ./internal/...                  ✅ 27 个包通过

internal/appmeta 的 TestFirstExistingPath_Hit 在 Windows 上失败(期望 /tmp),
该用例在 main 上同样失败,与本次改动无关。

顺带修了一个既有测试的隐患:
TestReconnectWithoutDeviceEnumeratesOnceAndQuietsRepeats 原先只 mock 了
enumerateVendorHID(BS2/BS3),本 PR 让它开始枚举 BS1 → 在接着设备的机器上会
"意外连上"而失败。已一并置空 enumerateBS1HID。

不在本 PR 范围(供参考,不需要在这个 PR 里解决)

以下几件事和传输层无关,本 PR 没有触碰,列出来只是让它们有记录:

  1. HID 挡位编码解析(需要先补挡位 4 的 payload[0] 样本)
  2. 挡位转速预设修正(见 ②)
  3. BS1 的「休眠时关闭输出」——后端停风扇的路径对 BS1 是通的,但界面被
    thrmFwVersion < 1 挡住(该门槛是给 BS2/BS3 定制固件设的)
  4. BLE 扫描阻塞重连(插回电脑时若正赶上扫描,要等 5–10 秒才走 HID)

附:本 PR 对既有行为的影响评估

改动点 对 BS2/BS3 的影响 对 BS1-BLE 的影响
WriteCommand 改为按传输分流 无(BS2/BS3 不走 BLEManager) 无(writeFrame 优先级不变,逻辑等价;仅错误文案由"BLE 设备未连接"改为"BS1 设备未连接")
connect() 插入 BS1 HID 分支 设备未插时每次连接多一次 HID 枚举(实测 hid.Enumerate 约 11ms,健康检查 30 秒一次) 无
探测失败时跳过 BLE 兜底 无(HIDUnresponsive() 恒为 false) 无
Disconnect 增加 HID 分支 无(hidTransport 为 nil 时走原路径) 无
handleDisconnect 区分传输 无(仅日志文案) 无(日志更准确)
HID 到达通知加 BS1 的 VID/PID 多监听一个 VID 无
ConnectDevice 失败时发通知 无(HIDUnresponsive() 恒为 false,提前返回) 无

净新增的运行时开销:非 BS1 用户在设备未插时,每次连接尝试多约 11ms 的 HID 枚举。

BS1 插 USB 线时官方 App 走的是 HID(32 字节报文、Report ID 0x01、校验和挪到
末字节),而 THRM 一直只走 BLE。本次把 HID 作为 BLEManager 的第二种传输接入,
BS1 的命令集、应答等待、0xEF 状态解析、调试记录全部原样复用,只有收发这一层不同。

- 新增 bs1_hid.go:BS1 HID 枚举(优先按产品名,退回 VID/PID)、报文封装、
  读循环。读循环独占句柄并在退出时自行关闭,避免 hidapi use-after-free。
- ble.go:WriteCommand 按 hidTransport 分派(测试注入的 writeFrame 仍优先);
  Disconnect 优先处理 HID 传输;新增 ConnectHID(只试 HID,不做 BLE 扫描)。
- device.go:connect() 在 BS2/BS3 系列 HID 之后、shouldSkipBLEFallback 之前
  尝试 BS1 HID。必须排在跳过判断之前,否则上一次连的是 BS2 时插着的 BS1 永远连不上。

未在真机验证:开发机没有 BS1 设备。报文布局依据官方 HIDClient.Send 的
IsBS1Serial 分支(曾在固件 0.0.6.02 上验证),并有单元测试覆盖封装逻辑。
用真机抓包的字节序列喂 handleHIDFrame,验证:
- 0xEF 状态帧解析出正确的转速与模式(2100 RPM / 模式 0x02)
- 命令应答派发给等待者(sendBLECommandAndWait 换传输后仍成立)
- 校验和错误的帧被丢弃,不污染状态也不误派发
- 非协议杂散数据不会让读循环崩掉

不改任何生产代码。
handleDisconnect 现在同时服务两条传输(HID 与 BLE),但日志硬编码了
「BS1 BLE 连接已断开」。USB HID 掉线时也会打这句,日志与实际传输不符,
排查时容易误判。

改为在清空 hidTransport 之前先记下当时的传输类型,再按类型输出对应措辞。
Core 靠「HID 接口到达通知」发现设备插入,同时健康检查在通知可用时会放慢
周期性重连。原注册只覆盖 BS2/BS3 系列(VID 0x37D7),BS1 用的是
VID 0x04A0 / PID 0x2501,不在名单里。结果是 BS1 插回电脑时:

  1) 到达通知因 VID 不匹配被丢弃;
  2) 健康检查又认为通知已就绪,不再快速轮询;
  3) 两头都不动,只能由用户手动点“连接”。

实测确认:拔掉再插回后 60 秒内日志没有任何重连尝试,手动点击才连上。

改动:
- powernotify:注册接口改为接收 []HIDDeviceID(VID/PID 对),不再假定单一 VID
- coreapp:同时登记 BS2/BS3 系列与 BS1
- device:导出 BS1HIDVendorID / BS1HIDProductID;enumerateBS1HID 改为可注入变量
- 测试:TestReconnectWithoutDeviceEnumeratesOnceAndQuietsRepeats 一并置空
  BS1 枚举,避免测试结果取决于本机是否接着 BS1
BS1 走 USB 时,系统睡眠/休眠唤醒后会出现一种假连接:设备仍留在系统
枚举列表里,风扇照转,但 HID 通道完全不再响应——新开句柄、复用旧句柄、
连 0x05 握手都无应答,只有重新枚举 USB(拔插线或重启)才能恢复。
已用保持句柄跨睡眠的独立脚本实测确认(帧计数在唤醒后 2 分 19 秒零增长,
查询 7 次全部超时)。

原先的读循环把读超时/空读一律当成暂时没数据,于是 Core 一直显示
「已连接」而转速空白,自动重连还会反复重演「连上但没有任何数据」。

改动:
- 读循环加静默看门狗:超过 bs1HIDIdleTimeout(20s) 一帧未收即判定掉线。
  实测 BS1 无论风扇转不转都约每 0.5 秒推一次 0xEF,20 秒静默必然是异常。
- 连接阶段加首帧探测:句柄打开后 bs1HIDProbeTimeout(2s) 内收不到任何帧
  就判定设备装死,直接关闭并返回失败——实测唤醒后 2 秒即识破,界面
  不会先显示成已连接再断开。
- 探测失败时跳过 BLE 兜底:实测设备活着且插着 USB 时扫描 15 秒扫不到任何
  BS1 广播,再扫只是白等 10 秒。失败检测因此从 ~12 秒缩短到 ~2 秒。
  该判断每次尝试都重新求值(ConnectHID 入口重置),避免上一次的结论残留
  导致设备改接充电头后蓝牙永远连不上。
- 把该失败与设备没插区分开(Manager.HIDUnresponsive)。用户手动点
  「连接」失败时通过 coremsg 发系统通知,明确要求拔插 USB-C 线或重启。
  不做自动提醒:自动重连发生在刚唤醒、桌面还没出来的时候,实测通知会
  落在锁屏阶段,用户看不见;等用户主动来点,反馈才落在眼前。
- 通知文案进 coremsg 目录(中/英/日)。

注意:这是设备固件的限制,软件侧无法恢复连接本身,本次改动只负责让状态
如实反映、并明确告诉用户该怎么做。
@vercel

vercel Bot commented Oct 26, 2026

Copy link
Copy Markdown

@Bolingboluo is attempting to deploy a commit to the Tianli's projects Team on Vercel.

A member of the Team first needs to authorize it.

- go-hid 的 Close 释放原生句柄后不置空,读循环因掉线自行关闭句柄时若恰有一次
  调速写入会写到已释放的句柄上;传输内加 ioMu 串行化 Write 与关闭
- 读循环异常退出改走 handleHIDTransportLost,只有退出的仍是当前传输时才断开,
  避免 Close 超时后晚退出的旧循环把已重连的新连接标成断开
- 断开复位收拢到 resetConnectionLocked,stopChan 改在锁内关闭

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TIANLI0
TIANLI0 merged commit 4af6354 into TIANLI0:main Oct 26, 2026
1 check failed
@Bolingboluo
Bolingboluo deleted the feat/bs1-hid branch November 4, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants