Remote Test
Windows 10 下通过 C# UWP 实现蓝牙语音遥控器测试的笔记

引言

BLE 设备遵循通用属性协议(Generic Attribute, GATT)规范。GATT 规范下包含 GATT Service 和 GATT Characteristic 等,两者是从属关系,后者属于前者。每一个 GATT 都有一个独特的 UUID,对其进行标识。

简单理解,GATT Service 说明了该 BLE 设备提供什么服务,GATT Characteristic 说明这个服务的特征。把 BLE 设备比作一个办事大厅,GATT Service 是不同的办事窗口,GATT Characteristic 是这个窗口要填的单、会给你的东西。

更多细节详见蓝牙官网

BLE 支持

新建 UWP 项目,或者新建 WinForm 项目后导入 Windows.Devices、Windows.Foundation、Windows.Security、Windows.Storage 四个 winmd 文件。

发现设备

相关功能代码中引入 DeviceWatcher BluetoothLEAdvertisementWatcher

public void StartBleDeviceWatcher()
{
    // 需要监听的属性
    string[] requestedProperties =
    {
        "System.Devices.Aep.Category",
        "System.Devices.Aep.ContainerId",
        "System.Devices.Aep.DeviceAddress",
        "System.Devices.Aep.IsConnected",
        "System.Devices.Aep.IsPaired",
        "System.Devices.Aep.IsPresent",
        "System.Devices.Aep.ProtocolId",
        "System.Devices.Aep.Bluetooth.Le.IsConnectable",
        "System.Devices.Aep.SignalStrength"
    };
    // 代表 BLE 设备
    string aqsAllBluetoothLEDevices = "(System.Devices.Aep.ProtocolId:=\"{bb7bb05e-5972-42b5-94fc-76eaa7084d49}\")";

    if (deviceWatcher == null || deviceWatcher.Status == DeviceWatcherStatus.Stopped)
    {
        deviceWatcher = //DeviceInformation.CreateWatcher();
                DeviceInformation.CreateWatcher(
                    aqsAllBluetoothLEDevices,
                    requestedProperties,
                    DeviceInformationKind.AssociationEndpoint);

        // Register event handlers before starting the watcher.
        deviceWatcher.Added += DeviceWatcher_Added; // 设备添加后触发事件
        deviceWatcher.Updated += DeviceWatcher_Updated; // 设备信息更新后触发事件
        deviceWatcher.Stopped += DeviceWatcher_Stopped; // DeviceWatcher 停止后触发事件
    }

    if (advertisementWatcher == null || advertisementWatcher.Status == BluetoothLEAdvertisementWatcherStatus.Stopped)
    {

        advertisementWatcher = new BluetoothLEAdvertisementWatcher()
        {
            ScanningMode = BluetoothLEScanningMode.Active
        };
        advertisementWatcher.Received += AdvertisementWatcher_Received; // 更新设备元信息,
    }
    deviceWatcher.Start();
    advertisementWatcher.Start();
}

配对设备

设备配对需要经历连接配对两个过程。一般来说设备只要被选择,就已连接。配对相关代码如下:

public async Task DoInAppPairingAsync()
{
    // 这个 DeviceInfo 是由 DeviceWatcher 搜索得到的
    DeviceInfo.Pairing.Custom.PairingRequested += (sender, args) => {
        args.Accept();
    };
    await Task.Run(() => DeviceInfo.Pairing.Custom.PairAsync(DevicePairingKinds.ConfirmOnly).Completed =
        (info, status) =>
        {
            if (status == AsyncStatus.Completed)
            {
                var result = info.GetResults();
                if (result.Status.Equals(DevicePairingResultStatus.Paired) ||
                    result.Status.Equals(DevicePairingResultStatus.AlreadyPaired))
                {
                    // 这里的 IsPaired 是我封装的成员变量
                    IsPaired = true;
                    // throw new Exception(result.Status.ToString());
                }
                else
                {
                    IsPaired = false;
                }

            };
    });
}

按键测试

正常的 BLE 遥控器,会提供 HID (Human Interface Device) 的 GATT Service。见蓝牙规范网站GATT Service | Human Interface Device XML View,其下规范了规范该服务的 GATT Characteristics。

操蛋的是,我的 BLE 遥控器提供了 HID Service,但是其下却无法发现任何 Characteristc,也就是说,我无法通过 Characteristc 去获取遥控器的按键报告。

幸运的是,将遥控器与电脑配对以后,电脑自动识别了该遥控器,并将其注册为「符合蓝牙低能耗 GATT 的 HID 设备」:
Remote HID.png

注意到硬件 ID 路径包含的 {00001812-0000-1000-8000-00805f9b34fb} 实际上就是 GATT HID Service 的 UUID。

键盘钩子监听按键事件

我们通过创建键盘钩子来监听按键事件,这里的钩子是全局钩子而非线程钩子,因为不知道为什么,线程钩子在转换键盘按键数据的时候报错。代码就不放出来了,网上一堆,关键字“C# 键盘钩子”。

由于全局钩子会拦截系统全局的按键,而我们不希望在除了测试界面之外的其他界面拦截和监听按键,所以要在测试界面窗体监听 Activated 和 Deactivate:

private void MainForm_Activated(object sender, EventArgs e)
{
    // FormActivated 是自定义的窗体内私有成员,方便其他地方调用
    FormActivated = true;
    // 激活窗体时安装钩子
    kHook.Start();
}
private void MainForm_Deactivate(object sender, EventArgs e)
{
    FormActivated = false;
    // 窗体失焦后卸载钩子
    kHook.Stop();
}

一些按键是带有默认事件的,比如 Keys.BrowserHome,这个按键会触发打开浏览器事件,我们不希望在测试过程中按下这个键打开浏览器,所以我们的键盘钩子必须有一个阻止按键的列表,当按键的键值在阻止按键列表内时,return 1,代表我们已经处理了这个按键,不希望钩子链中的其他钩子继续处理。

/// <summary>
/// 阻止按键 KeyCode 列表
/// <summary>
public List<int> PreventKeys;

if (PreventKeys!= null && PreventKeys.Contains((int)keyData))
{
    // 不继续处理
    return 1;
}
else
{
    // 让之后的钩子链处理
    return CallNextHookEx(hKeyboardHook, nCode, wParam, lParam);
}

试着按下一些按键,识别结果如下:

遥控器按键识别按键
PowerKeys.None
UpKeys.Up
DownKeys.Down
LeftKeys.Left
RightKeys.Right
VolumeUpKeys.VolumeUp
VolumeDownKeys.VolumeDown
VoiceKeys.VolumeMute
HomeKeys.BrowserSearch
Google PlayKeys.BrowserHome
BackKeys.BrowserBack
OKNo response
YouTubeNo response
NETFLIXNo response
MenuNo response

一些按键根本就是错乱的,这遥控器 is a piece of shit 特么就是有问题的。这和我没关系。暂且不管。

要解决按键无法识别的问题,需要我们直接监听 HID 设备和电脑的通讯。C# 下,我们使用 HidLibrary.

HidLibrary 监听 HID over GATT

HidLibrary 原本是用来监听 USB HID 设备的,但实际上,当 HID over GATT 被系统识别以后,系统会将其映射为一个虚拟 HID,作为系统的输入设备,此时其通讯规范和 USB HID 通讯规范一致,所以我们可以用 HidLibrary 来监听 BLE 遥控器。

HidDevice remoteGattHID;
HidEnumerator enumerator = new HidEnumerator(); // HID 枚举器
var devices = enumerator.Enumerate();
foreach (HidDevice device in devices)
{
    string devicePath = device.DevicePath;
    // 根据实际情况枚举出设备,这里 deviceInfo 是我的蓝牙设备的封装
    if (devicePath.Contains("00001812-0000-1000-8000-00805f9b34fb")
        && devicePath.Contains(deviceInfo.BluetoothAddressAsString.Replace(":", ""))
        && !devicePath.Contains("kbd"))
    {
        remoteGattHID = device;
        break;
    }
}
if (remoteGattHID != null)
{
    if (!remoteGattHID.IsOpen)
    {
        remoteGattHID.OpenDevice();
    }
    remoteGattHID.MonitorDeviceEvents = true;
    // 异步监听 HID 设备报告,防止阻塞
    await OnReportAsync(await remoteGattHID.ReadReportAsync(10));
}

我们获取了 HID over GATT 设备,接下来就要进行监听,上面代码中的 OnReportAsync 即是监听回调:

private async Task OnReportAsync(HidReport report)
{
    // process your data here
    if (report != null && report.Data.Length > 0)
    {
        int code = 0, offset = 0;
        for (int i = 0; i < report.Data.Length; i++)
        {
            byte currentByte = report.Data[i];
            if (currentByte != 0x00)
            {
                code += (report.Data[i]) << ((i - offset) * 8);
            }
            else if(code != 0)
            {
                // 处理多按键同时按下的情况
                offset = i + 1;
                Invoke(
                    new MethodInvoker(() =>
                    {
                        // FormActivated 在上面的代码里出现过
                        // 是用来标识当前测试界面是否处于激活状态的
                        if (FormActivated)
                        {
                            ShowInfo(MsgType.Info, string.Format("HID Report: 0x{0:x}", code));
                            code = 0;
                        }
                    }));
            }
        }
    }
    // we need to start listening again for more data
    await Task.Delay(200);
    await OnReportAsync(await remoteGattHID.ReadReportAsync(1));
}

这里的监听是异步的,这是为了防止不断监听造成的主线程阻塞。要知道这样的递归很容易出现阻塞。

好了,这样我们就能监听到上面无法监听到的 OK、Menu、NETFLIX 和 YouTube 键值了。

遥控器按键HID 数据
OK0x41
Menu0xFC
YouTube0xFB
NETFLIX0xFA

GDI+ with XML 画遥控器界面

监听到了键值,我们需要在 WinForm 上做遥控器的界面,按下按键后,WinForm 遥控器界面上的按键对应闪烁变化,毕竟总不能对着一堆输出日志看吧。

首先确定界面样式,按钮设计为圆角矩形。真实遥控器按键样式五花八门,但具体到测试时的样式上,只需要对上相应位置即可。而且测试界面按钮上一般还会有横排文字,所以一般来说,按钮宽度会比高度高得多,则圆角矩形的半径即为二分之一按钮高度。文字居中。

按钮最终样式是下面这样的:
Button Design

做遥控器界面,我们需要一堆按键的集合,我们把按键集合写成 xml 文件,即是一个 keyLayout,此处我们最关注的点是每个按键的位置、宽高、文字和键值,其他一些参数可以根据需求添加。我根据我的实际需要创建了以下内容的 xml 文件。其中一些按键的键值实际上是错的,不过我暂时不关心,先实现功能再说。

<?xml version="1.0" encoding="utf-8"?>
<keyLayouts>
    <keyLayout name="My KeyLayout" w="221" h="500">
        <prevents>0x25,0x26,0x27,0x28,0xA6,0xAA,0xAC,0xAD,0xAE,0xAF</prevents>
        <key page="0x07" code="0x0000" w="auto" h="30" x="170"    y="28">power</key>
        <key page="0x0C" code="0x00E2" w="49"   h="30" x="center" y="63">voice</key>
        <key page="0x07" code="0x0026" w="49"   h="30" x="center" y="98">︿</key>
        <key page="0x07" code="0x0025" w="49"   h="30" x="50"     y="134">&lt;</key>
        <key page="0x07" code="0x0027" w="49"   h="30" x="170"    y="134">&gt;</key>
        <key page="0x07" code="0x0028" w="49"   h="30" x="center" y="169">﹀</key>
        <key page="0x0C" code="0x0041" w="49"   h="30" x="center" y="134">OK</key>
        <key page="0x0C" code="0x0224" w="49"   h="30" x="50"     y="204">←</key>
        <key page="0x0C" code="0x0221" w="49"   h="30" x="center" y="204">home</key>
        <key page="0x0C" code="0x00FC" w="49"   h="30" x="170"    y="204">menu</key>
        <key page="0x0C" code="0x00EA" w="49"   h="30" x="50"     y="239">voice-</key>
        <key page="0x0C" code="0x00E9" w="49"   h="30" x="170"    y="239">voice+</key>
        <key page="0x0C" code="0x00FB" w="90"   h="30" x="center" y="274">YouTube</key>
        <key page="0x0C" code="0x00FA" w="90"   h="30" x="center" y="309">NETFLIX</key>
        <key page="0x0C" code="0x0223" w="90"   h="30" x="center" y="344">Google Play</key>
    </keyLayout>
</keyLayouts>

注意这里<key>标签内的x属性值和w属性值。除了是整数外,x属性有额外值center表示居中,w属性有额外值auto表示其值由文字宽度自动调整。

获取文字宽度,用 Graphics.MeasureString 方法。获取到以后,结合上面的 Button Design 图即可计算出文字所要绘制到的位置。如果是自动宽度,则根据文字宽度自动算出 Button 宽度,再计算位置。

在 WinForm 下我们使用 GDI+ 来进行绘图。首先是对 Graphics 类做方法扩展,让其可以画圆角矩形。代码可参考C# 画圆角矩形。我在个人代码里把它做成了扩展方法。此处不做赘述。

在 C# 内我们将每个 key 封装成 RemoteKey 类。其下有绘制激活前、激活时和激活后的方法。将一个 keyLayout 封装成 KeyLayout 类。然后是读 XML 文件来画基本遥控器界面。
KeyLayout

画出来的遥控器界面,还行

触发遥控器按键有 2 个位置,一个是键盘钩子监听,另一个是 HID Report 监听。触发后,可根据每个 key 的实际位置(我们已经将其封装了),重绘背景颜色即可。