Virtual Bluetooth Keyboard & Mouse -- 把 PC 变成 BLE HID 键盘/鼠标外设,连接手机后可以将 PC 输入转发到手机。
基于 Microsoft BluetoothLEExplorer (UWP) 移植到 WinUI 3 / .NET 8。
| 层面 | 技术 |
|---|---|
| UI 框架 | WinUI 3 (Windows App SDK 1.5) |
| 运行时 | .NET 8 (net8.0-windows10.0.19041.0) |
| MVVM | CommunityToolkit.Mvvm 8.2 |
| 蓝牙 | Windows.Devices.Bluetooth (WinRT BLE GATT Server) |
| 打包 | MSIX(必须,BLE GATT Server 广播需要 Package Identity) |
| 序列化 | Newtonsoft.Json 13.0 |
VirtualBT.sln
├── VirtualBT/ # 主项目(WinUI 3 应用)
│ ├── Models/ # 蓝牙设备模型、VirtualKeyboard / VirtualMouse HID 实现
│ ├── ViewModels/ # MVVM ViewModel
│ ├── Views/ # XAML 页面
│ ├── Services/ # 转换器、设置、Toast、蓝牙服务辅助
│ ├── CustomControls/ # 自定义 GATT 特征控件
│ ├── Assets/ # 图标资源
│ ├── Package.appxmanifest
│ └── VirtualBT.csproj
├── GattHelper/ # GATT 辅助库
├── GattServicesLibrary/ # 预定义 GATT 服务(心率、电池等)
└── SortedObservableCollection/ # 排序的 ObservableCollection
- Visual Studio 2022 (17.8+)
- 工作负载:".NET Desktop Development" + "Windows App SDK C# Templates"
- .NET 8 SDK
- Windows 10 19041+
- 蓝牙适配器需支持 Peripheral Role(大部分 Intel 无线网卡自带的蓝牙都支持)
- 用 VS 2022 打开
VirtualBT.sln - 工具栏选择 x64 / Debug(不要选 Any CPU 或 x86)
Ctrl+Shift+B编译
# 用 VS 的 MSBuild(不要用 dotnet build,XAML 编译器有兼容问题)
& "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\amd64\MSBuild.exe" `
VirtualBT.sln /p:Configuration=Debug /p:Platform=x64 /restore /t:BuildMSIX 打包应用需要先注册再运行:
# 注册应用(首次或重新编译后)
Add-AppxPackage -Register "VirtualBT\bin\x64\Debug\net8.0-windows10.0.19041.0\AppxManifest.xml"
# 启动
Start-Process "shell:AppsFolder\VirtualBT_qhx848t84fegm!App"
# 如需重新注册(更新代码后)
Get-AppxPackage -Name "VirtualBT" | Remove-AppxPackage
Add-AppxPackage -Register "VirtualBT\bin\x64\Debug\net8.0-windows10.0.19041.0\AppxManifest.xml"也可以在 VS 中直接 F5 调试运行。
- 启动应用后,点左侧栏 键盘图标 进入 Virtual Keyboard 页面
- 打开 "Discoverable and Connectable" 开关
- 在手机蓝牙设置中搜索并配对你的电脑名(如
DESKTOP-XXXXXX) - 配对后,在 VirtualBT 窗口中按键,输入会转发到手机
- 点左侧栏 鼠标图标 进入 Virtual Mouse 页面
- 打开 "Discoverable and Connectable" 开关
- 在手机蓝牙设置中搜索并配对你的电脑名
- 配对成功后,页面状态会显示已连接客户端数量
- 在触摸板区域拖拽控制光标移动
- 点击 Left / Middle / Right 按钮发送鼠标点击
- 点击 Scroll Up / Scroll Down 按钮滚动
注意:鼠标和键盘各自独立广播,手机上会看到两个配对请求。如果连接后立即断开,请在手机上删除旧的配对记录后重新搜索配对。
| 问题 | UWP 原版 | WinUI 3 移植方案 |
|---|---|---|
| Package Identity | 自带(AppContainerExe) | 必须用 MSIX 打包,否则 BLE GATT Server 广播静默失败 |
| 键盘捕获 | CoreWindow.GetForCurrentThread().KeyDown |
在 Window.Content 上挂 KeyDown/KeyUp 事件 |
| ToggleSwitch 双向绑定 | x:Bind TwoWay 直接工作 |
需要额外的 Toggled 事件处理器显式设值 |
| UI 线程调度 | CoreDispatcher |
DispatcherQueue |
| 导航 | Template10 NavigationService |
自定义 NavigationHelper 静态委托 |
| MVVM 基类 | Template10 ViewModelBase |
CommunityToolkit.Mvvm.ObservableObject |
- "List of subscribed clients" UI 列表可能不显示已连接的设备(功能不影响,键盘转发正常工作)
- XAML 编译器 Pass2 在
dotnet build下可能崩溃,需用 VS MSBuild 编译
| 手机型号 | 蓝牙键盘 | 蓝牙鼠标 | 备注 |
|---|---|---|---|
| 小米 13 (xiaomi13) | 正常 | 正常 | |
| 一加 8T (KB2000, Android 11) | 正常 | 正常 | |
| 一加 13 (PJZ110, Android 15) | 需验证 | 需验证 | 已实现 BR/EDR 抑制修复,见下方说明 |
问题:一加 13 (Android 15) 的 HidHostService 发现 PC 为 DUAL 模式设备(BR/EDR + LE)时,优先通过经典蓝牙 HID 连接(transport=0),而非 BLE HOGP(transport=2)。VirtualBT 只实现了 BLE GATT HID,经典 HID 连接直接超时,且不会 fallback 到 HOGP。
修复方案:在 BLE HID 广播期间,通过 Win32 API(BluetoothEnableDiscovery / BluetoothEnableIncomingConnections)临时关闭 BR/EDR 的 Inquiry Scan 和 Page Scan,使 PC 对手机呈现为 LE-only 设备,迫使 Android 走 BLE HOGP 路径。
实现细节:
BrEdrHelper.cs:P/InvokeBluetoothAPIs.dll,引用计数管理(键盘和鼠标独立调用)- 开启广播时调用
BrEdrHelper.Suppress(),停止广播时调用BrEdrHelper.Restore() - 应用退出 / 崩溃时通过
ForceRestore()确保 BR/EDR 恢复原状 - 不需要管理员权限(WinUI 3 desktop 是 full-trust 进程)
副作用:广播期间 PC 的经典蓝牙功能暂时不可用(蓝牙音箱、文件传输等),停止广播后自动恢复。
诊断过程(通过 adb shell dumpsys bluetooth_manager 获取):
-
一加 13 将 PC 识别为 DUAL 模式设备:
44:7C:3F:C4:24:6D => 9C:B1:50:71:24:BE [ DUAL ][ 0x000500 ] DESKTOP-6MH3QMGCoD
0x000500= Peripheral 设备类型。Windows 在注册 BLE GATT HID 服务时会自动将 Class of Device 设为 Peripheral。 -
HidHostService 选择了错误的传输通道:
HidHostService: XX:XX:XX:XX:24:6D : Selected transport=0 HID connection state=0 HOGP connection state=0transport=0表示 AUTO(实际走了经典蓝牙 HID),而非transport=2(BLE HOGP)。 -
一加 13 通过 BR/EDR SDP 发现服务,而非 BLE GATT:
SDP Discovery completed: xx:xx:xx:xx:24:be Result:SDP_SUCCESS services_found:0xc10c4048 -
经典 HID 连接超时(VirtualBT 只实现了 BLE GATT HID,没有经典蓝牙 HID 服务):
CachedBluetoothDevice: Connect to profile : 2 timeout, show error message !
对比一加 8T(正常工作):
| 一加 8T (Android 11) | 一加 13 (Android 15) | |
|---|---|---|
| 设备识别 | 纯 LE | DUAL (BR/EDR + LE) |
| HID transport | transport=2 (LE → HOGP) |
transport=0 (AUTO → 经典 HID) |
| 服务发现 | BLE GATT Discovery | BR/EDR SDP Discovery |
| 结果 | HOGP 订阅成功 | 经典 HID 超时,HOGP 从未尝试 |
结论:一加 13 的 Android 15 蓝牙栈在发现对端为 DUAL 模式且 CoD 包含 Peripheral 时,优先通过经典蓝牙 HID profile 连接,不会 fallback 到 BLE HOGP。VirtualBT 现在通过在广播期间抑制 BR/EDR 来绕过此问题。
如果你的手机配对连接后按键无响应,先用小米等已验证的手机排除 PC 端问题,再判断是否为手机兼容性问题。
症状:手机能搜到电脑蓝牙、配对成功,但随后连接立刻断开,反复重连均失败。
原因:手机或 PC 端残留了旧的配对密钥(LTK),与当前 GATT 服务的加密上下文不匹配,Windows 蓝牙栈在链路层直接拒绝连接。
解决:在 两端 都删除对方的配对记录后重新配对:
- 手机端:蓝牙设置 → 找到电脑名称 → 取消配对 / 忘记设备
- PC 端:设置 → 蓝牙和其他设备 → 找到手机名称 → 删除设备
- 重启应用,开启广播,用手机重新搜索配对
症状:手机显示已连接,但在 VirtualBT 窗口按键后手机端无任何反应。日志中持续出现 No clients are currently subscribed。
原因:应用以 unpackaged 模式(直接运行 exe)启动。WinUI 3 桌面应用的 BLE GATT Server 必须拥有 Package Identity 才能正常工作。直接运行 exe 时,GattServiceProvider 的广播和服务创建在 API 层面返回成功,但 Windows 蓝牙栈实际上不会将 GATT 服务注册到蓝牙控制器中。手机连上后做 GATT Service Discovery 时找不到 HID 服务,因此永远不会订阅 Report 特征。
解决:必须以 packaged 方式运行,有两种方法:
# 方法 1:命令行注册后启动
Add-AppxPackage -Register "VirtualBT\bin\x64\Debug\net8.0-windows10.0.19041.0\AppxManifest.xml"
Start-Process "shell:AppsFolder\VirtualBT_qhx848t84fegm!App"
# 方法 2:在 Visual Studio 中 F5 调试运行(VS 会自动注册包)绝对不要 直接双击
bin\...\VirtualBT.exe启动,这样蓝牙功能不会正常工作。
运行时日志写入 %TEMP% 目录,用于排查连接问题:
VirtualBT_log.txt— 键盘日志VirtualBT_mouse_log.txt— 鼠标日志
以下是蓝牙鼠标功能正常工作时,从配对到数据传输的完整 BLE 报文序列。基于 OnePlus 8T (Android 11) + DESKTOP-6MH3QMG (Windows, Intel BLE) 实测记录。
手机(Central) PC(Peripheral)
| |
|--- LE Create Connection ----------------->|
|<-- LE Enhanced Connection Complete --------| status=0, role=Peripheral
| |
|<-- LE Read Remote Features ---------------|
|<-- LE Data Length Change -----------------| 协商数据包长度
- 手机作为 Central 发起 BLE 连接
- PC 的 BLE 随机地址(如
6b:29:52:88:f0:dc)作为广播地址 - PC 的公有地址(如
9c:b1:50:71:24:be)也参与连接管理
手机(Central) PC(Peripheral)
| |
|<========= Encryption Change ==============>| enabled=1
|<-- LE Data Length Change -----------------| 加密后重新协商 DLE
- 首次连接时通过 SMP 配对交换密钥
- 后续重连使用已存储的 LTK 直接加密
- HID 服务要求
EncryptionRequired,加密完成前不可读写特征
手机在加密完成后执行 GATT Service Discovery:
手机(Central) PC(Peripheral)
| |
|--- ATT Read By Group Type Request ------->| UUID=0x2800 (Primary Service)
|<-- ATT Read By Group Type Response -------| 发现以下服务:
| | 0x1800 Generic Access
| | 0x1801 Generic Attribute
| | 0x180A Device Information
| | 0x1812 HID Service ← 关键
| |
|--- ATT Read By Type Request ------------->| UUID=0x2803 (Characteristic)
|<-- ATT Read By Type Response -------------| 发现 HID 服务的特征:
| | 0x2A4D Report (Input, Notify)
| | 0x2A4B Report Map (Read)
| | 0x2A4A HID Information (Read)
| | 0x2A4C HID Control Point (Write)
| | 0x2A4E Protocol Mode (Read/Write)
| |
|--- ATT Find Information Request --------->| 查询 Report 特征的描述符
|<-- ATT Find Information Response ---------| 0x2902 CCCD
| | 0x2908 Report Reference
| |
|--- ATT Read Request --------------------->| 读取 Report Reference
|<-- ATT Read Response ---------------------| [0x02, 0x01] = Report ID 2, Input
| |
|--- ATT Read Request --------------------->| 读取 Report Map
|<-- ATT Read Response ---------------------| (HID Report Descriptor, 见下方)
| |
|--- ATT Read Request --------------------->| 读取 HID Information
|<-- ATT Read Response ---------------------| [0x11, 0x01, 0x00, 0x01]
| | HID Version 1.1.1, RemoteWake
手机(Central) PC(Peripheral)
| |
|--- ATT Write Request -------------------->| handle=CCCD, value=0x0001
|<-- ATT Write Response --------------------| (Notifications enabled)
| |
| PC 日志: "Mouse subscribed clients changed. Count: 1"
| PC 日志: "NEW GattSession tracked: MaxPduSize=517"
- 写入 CCCD =
0x0001表示启用 Notification - 此时 PC 端
SubscribedClients.Count从 0 变为 1 - MaxPduSize 协商到 517(BLE 5.x 最大值)
手机(Central) PC(Peripheral)
| |
|<-- ATT Handle Value Notification ---------| 鼠标移动
|<-- ATT Handle Value Notification ---------| ...
|<-- ATT Handle Value Notification ---------| 按键点击
| 字节 | 含义 | 取值范围 |
|---|---|---|
| Byte 0 | Buttons | bit0=左键, bit1=右键, bit2=中键 |
| Byte 1 | X 位移 | -127 ~ 127(有符号,相对移动) |
| Byte 2 | Y 位移 | -127 ~ 127(有符号,相对移动) |
| Byte 3 | 滚轮 | -127 ~ 127(有符号) |
# 鼠标右移(dx=9, dy=0)
Mouse report: buttons=0x00 dx=9 dy=0 wheel=0 → Notification: [00 09 00 00]
# 鼠标左上移动(dx=-15, dy=-8)
Mouse report: buttons=0x00 dx=-15 dy=-8 wheel=0 → Notification: [00 F1 F8 00]
# 左键按下
Mouse report: buttons=0x01 dx=0 dy=0 wheel=0 → Notification: [01 00 00 00]
# 左键释放
Mouse report: buttons=0x00 dx=0 dy=0 wheel=0 → Notification: [00 00 00 00]
05 01 USAGE_PAGE (Generic Desktop)
09 02 USAGE (Mouse)
A1 01 COLLECTION (Application)
09 01 USAGE (Pointer)
A1 00 COLLECTION (Physical)
85 02 REPORT_ID (2)
05 09 USAGE_PAGE (Button)
19 01 USAGE_MINIMUM (Button 1 – Left)
29 03 USAGE_MAXIMUM (Button 3 – Middle)
15 00 LOGICAL_MINIMUM (0)
25 01 LOGICAL_MAXIMUM (1)
95 03 REPORT_COUNT (3)
75 01 REPORT_SIZE (1)
81 02 INPUT (Data,Var,Abs) ← 3 个按键位
95 01 REPORT_COUNT (1)
75 05 REPORT_SIZE (5)
81 03 INPUT (Cnst,Var,Abs) ← 5 bit 填充
05 01 USAGE_PAGE (Generic Desktop)
09 30 USAGE (X)
09 31 USAGE (Y)
15 81 LOGICAL_MINIMUM (-127)
25 7F LOGICAL_MAXIMUM (127)
75 08 REPORT_SIZE (8)
95 02 REPORT_COUNT (2)
81 06 INPUT (Data,Var,Rel) ← X, Y 相对位移
09 38 USAGE (Wheel)
15 81 LOGICAL_MINIMUM (-127)
25 7F LOGICAL_MAXIMUM (127)
75 08 REPORT_SIZE (8)
95 01 REPORT_COUNT (1)
81 06 INPUT (Data,Var,Rel) ← 滚轮
C0 END_COLLECTION
C0 END_COLLECTION
PC 日志时序(正常鼠标会话):
[10:46:28] Mouse advertising started. Status: Aborted → Started
[10:46:33] Mouse subscribed clients changed. Count: 1
[10:46:33] NEW GattSession tracked: MaxPduSize=517
[10:46:33] SESSION STATUS CHANGED: Closed (短暂断开)
[10:46:36] SESSION STATUS CHANGED: Active (自动重连)
[10:46:41] Session MaxPduSize changed: 517 (DLE 重协商)
[10:46:43] Mouse report: buttons=0x00 dx=1 dy=0 wheel=0 ← 数据开始传输
...
[10:56:00] SESSION STATUS CHANGED: Closed (手机休眠/超时)
[10:56:10] SESSION STATUS CHANGED: Active (自动重连)
- 首次连接后可能出现短暂的 Closed→Active 循环,属于正常的连接参数协商
- 手机息屏后连接可能断开,亮屏后自动重连
- 重连时不需要重新配对,使用已存储的 LTK
| 键盘 | 鼠标 | |
|---|---|---|
| Report ID | 1 | 2 |
| 报告长度 | 8 字节 | 4 字节 |
| 格式 | [modifier, key1, key2, ..., key7] |
[buttons, dx, dy, wheel] |
| 示例(按 A) | 00 04 00 00 00 00 00 00 |
— |
| 示例(左键) | — | 01 00 00 00 |
| 示例(释放) | 00 00 00 00 00 00 00 00 |
00 00 00 00 |
当遇到手机连接后不工作(日志中持续出现 No clients are currently subscribed)时,需要在 BLE 协议层抓包,分析手机在 GATT 服务发现阶段的具体行为。
- 在 PC 端编译并以 packaged 方式启动应用(见上方"运行"章节)
- 进入键盘页面,打开 "Discoverable and Connectable" 开关
- 在手机蓝牙设置中搜索并配对电脑
- 配对成功后在 VirtualBT 窗口按几个键
- 检查
%TEMP%\VirtualBT_log.txt:- 如果出现
Sending keyboard report→ 正常工作 - 如果只有
No clients are currently subscribed→ 手机未订阅 HID 服务,需要抓包分析
- 如果出现
从手机端抓包可以看到手机发出和收到的所有 BLE 报文,包括 GATT 服务发现请求、ATT 错误响应等。
# 通过 adb 启用(需要 root 或开发者选项已开启)
adb shell settings put secure bluetooth_hci_log 1
# 或者手动操作:
# 设置 → 系统 → 开发者选项 → 启用蓝牙 HCI 信息收集日志 (Enable Bluetooth HCI snoop log)启用后重启蓝牙(关闭再打开),让日志开始记录。
按上面的复现步骤操作一遍。
# root 手机直接拉取
adb pull /data/misc/bluetooth/logs/btsnoop_hci.log
# 如果上面的路径不存在,试试这些(不同厂商/系统版本路径不同)
adb pull /data/log/bt/btsnoop_hci.log
adb pull /sdcard/btsnoop_hci.log
adb pull /data/misc/bluedroid/btsnoop_hci.log
# 如果权限不够,通过 bug report 导出(不需要 root)
adb bugreport bugreport.zip
# 解压后在 FS/data/misc/bluetooth/logs/ 下找 btsnoop_hci.log# 安装 Wireshark 后直接打开 btsnoop_hci.log
wireshark btsnoop_hci.log重点关注以下过滤器和报文:
# 只看 ATT 协议(GATT 服务发现)
btatt
# 只看 ATT 错误响应
btatt.opcode == 0x01
# 只看 GATT 服务发现请求
btatt.opcode == 0x10 || btatt.opcode == 0x08
# 只看 HID 服务相关(UUID 0x1812)
btatt.uuid16 == 0x1812
# 只看 SMP 配对相关
btsmp
正常工作的手机应该能看到以下报文序列:
LE Create Connection→ 连接建立SMP Pairing Request/Response→ 配对ATT Read By Group Type Request(UUID=0x2800) → 发现所有主服务ATT Read By Type Request(UUID=0x2803) → 发现 HID 服务的特征ATT Write Request(handle=Report CCCD, value=0x0001) → 订阅 HID Report 通知ATT Handle Value Notification→ 收到键盘报告
不工作的手机可能在第 3~5 步缺失或出错。
从 PC 端抓包,适合没有 root 手机或无法在手机端操作的场景。
reg add "HKLM\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters" /v "DebugFlags" /t REG_DWORD /d 0x1 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters" /v "SniffLogPath" /t REG_SZ /d "C:\btsnoop.log" /f在设备管理器中禁用再启用蓝牙适配器,或者重启电脑。
reg delete "HKLM\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters" /v "DebugFlags" /f
reg delete "HKLM\SYSTEM\CurrentControlSet\Services\BTHPORT\Parameters" /v "SniffLogPath" /f
# 重启蓝牙适配器