目的

在骑行圈大家应该都早有耳闻:Polar H10是最准的心率带。在互联网上,很多心率带的评测,都会以H10作为标准。

我在去年上半年了解到了Bigrun Team心率带,这个心率带对标H10心率带,并且同样有Polar H10的ECG功能。

Polar H10的ECG功能文档中写到了H10的采样率为130Hz,同时还能够记录加速度传感器的数据,采样率为25Hz、50Hz、100Hz或者200Hz,范围有2G、4G或者8G。

Bigrun Team的心率带在淘宝页面中提到心率带的采样率为1000Hz,我想要通过程序读取到ECG信号,但是Bigrun Team并没有公布蓝牙协议的相关文档,也没有SDK,于是只能自己来逆向了。

步骤

数据准备

使用jadx,反编译apk文件,由于ECTRI并未对代码进行混淆,所以能够轻松获取到程序的源代码

除了对源代码进行分析,同时需要结合手机上的蓝牙抓包,能够更快的定位代码(ECTRI的代码太多了)

这里使用小米手机,在开发者模式中打开hci ,然后使用官方APP进行一些蓝牙操作,最后再adb bugreport xiaomi 即可导出其中的蓝牙日志FS\data\misc\bluetooth\logs\BT_HCI_YYYY_MMDD_hhmmss.cfa.curf 。这个文件可以直接使用Wireshark打开。

蓝牙数据分析和代码分析

初步分析

第一步使用nRF Connect 来初步获取到心率带的相关蓝牙特征

例如可以看到两个未知服务

  • F000EFE0-0451-4000-0000-00000000B000
    F000EFE1-0451-4000-0000-00000000B000
    F000EFE3-0451-4000-0000-00000000B000

  • 0xFEE7
    0xFEC9
    0xFEA1
    0xFEA2

随后在代码中搜索这两个服务的UUID(或者特征的),即可发现前者用来传输高级数据,后者可能是接入小程序的蓝牙服务(结合搜索0xFEE7,可以看到是微信小程序接入)

再结合属性:
F000EFE1-0451-4000-0000-00000000B000 WRITE, WRITE NO RESPONSE
F000EFE3-0451-4000-0000-00000000B000 NOTIFY

以及代码:

package com.manridy.manbledevice;
......
public class ManConstants {
    ......
    public static final UUID SERVICE_UUID = UUID.fromString("F000EFE0-0451-4000-0000-00000000B000");
    public static final UUID TX_CHAR_UUID = UUID.fromString("F000EFE1-0451-4000-0000-00000000B000");
    public static final UUID RX_CHAR_UUID = UUID.fromString("F000EFE3-0451-4000-0000-00000000B000");
    ......
}

可以推断EFE1用来向设备发送控制指令,而EFE3将会回应数据

蓝牙抓包分析

使用筛选器筛选出与心率带相关的事件,例如

(_ws.col.def_src == "") || (_ws.col.def_src == "")

即可筛选出手机与心率带之间的通讯
也可以进一步筛选服务的UUID,例如且选中

btatt.service_uuid128 == f0:00:ef:e0:04:51:40:00:00:00:00:00:00:00:b0:00

或者筛选特征

btatt.characteristic_uuid128 == f0:00:ef:e3:04:51:40:00:00:00:00:00:00:00:b0:00

筛选出写入相关的数据包

((_ws.col.def_src == "") || (_ws.col.def_src == "")) && (btatt.opcode == 0x52)

查看写入数据,然后在nRF Connect 程序中重放,就可以分析出各个指令的功能

例如发送以下数据,都只是会在EFE3中回复一个数据,可以推断为读取配置相关的查询

fc0f600000000000000000000000000000000000
fc0f620000000000000000000000000000000000
fc0f610000000000000000000000000000000000
fc42060000000000000000000000000000000000
fc420b0000000000000000000000000000000000
fc42010000000000000000000000000000000000

然后发送以下数据,都会开启某个数据流(发送后EFE3会不断恢复大量数据)

fc14040001000000000000000000000000000000
fc00250204234721025500000000000000000000

这些指令都有一个共同点:开头是fc,接下来在代码中搜索0xfc或者其对应的十进制数-4。前者没有出现过,后者找到最有可能的代码是com.manridy.manbledevice.bean.type.ManBeanEnum

代码分析

package com.manridy.manbledevice.bean.type;
......
public enum ManBeanEnum {
    ......
    WriteDate(new byte[]{-4, 0}),
    SystemWriteSportModel(new byte[]{-4, 15, 96}),
    SystemWriteXO(new byte[]{-4, 15, 97}),
    SystemWriteSR(new byte[]{-4, 15, 98}),
    ReadEcgInfoSpeed(new byte[]{-4, ManConstants.ReadEcgInfo, 6}),
    ReadEcgInfoSignal(new byte[]{-4, ManConstants.ReadEcgInfo, 1}),
    ReadEcgAppSR(new byte[]{-4, ManConstants.ReadEcgInfo, 11}),
    ReadSportsRealRimeDataHR(new byte[]{-4, 20, 6}),
    ReadSportsRealRimeDataRRI(new byte[]{-4, 20, 7}),
    ReadGSensor(new byte[]{-4, 15}),
    ......
}

再结合ManConstants

package com.manridy.manbledevice;
......
public class ManConstants {
    ......
    public static final byte ReadEcgInfo = 66;
    ......
}

就可以发现这里的byte数组和指令头一一对应

接下来再深挖使用了这个ManBeanEnum的代码,如com.manridy.manbledevice.bean.read中的各个类

再搜索com.manridy.manbledevice.bean.read的各个类名,调查到com.manridy.manbledevice.ManAnalysisUtils这个类,用来处理接收到的蓝牙数据

同时也可以找到发送数据的类com.manridy.manmodel.device.adapter.send.ManSendDevice类,可以找到发送指令的方法

疑问

在GSensor处的解析代码存疑

package com.manridy.manbledevice.bean.read;
......
public class ReadGSensorBean extends ManBean<ReadGSensorBean> {
    ......
    public ReadGSensorBean response(String str) {
        String replaceAll = str.replaceAll(" ", "");
        String substring = replaceAll.substring(8, 12);
        String substring2 = replaceAll.substring(12, 16);
        String substring3 = replaceAll.substring(16, 20);
        String substring4 = replaceAll.substring(16, 20);
        String substring5 = replaceAll.substring(20, 24);
        String substring6 = replaceAll.substring(24, 28);
        int hexStringToDecimal = BleUtils.hexStringToDecimal(substring);
        int hexStringToDecimal2 = BleUtils.hexStringToDecimal(substring2);
        int hexStringToDecimal3 = BleUtils.hexStringToDecimal(substring3);
        int hexStringToDecimal4 = BleUtils.hexStringToDecimal(substring4);
        int hexStringToDecimal5 = BleUtils.hexStringToDecimal(substring5);
        int hexStringToDecimal6 = BleUtils.hexStringToDecimal(substring6);
        setX1Gravity(hexStringToDecimal);
        setY1Gravity(hexStringToDecimal2);
        setZ1Gravity(hexStringToDecimal3);
        setX2Gravity(hexStringToDecimal4);
        setY2Gravity(hexStringToDecimal5);
        setZ2Gravity(hexStringToDecimal6);
        return this;
    }
    ......
}

此处的字符串切片z1和x2一样,不符合常理

通过对加速度数据的分析

0f12 05c6    00ea 0106 ff8a   00ed 0104 ff95    ff28702b
0f12 05c7    00ef 0109 ff8d   00f2 010a ff90    ff28702b
0f12 05c8    00ee 0108 ff8e   00eb 010a ff84    ff28702b

0f12 05cc    00f1 010a ff8f   00f3 010e ff8a    00000000
0f12 05cd    00ed 0106 ff90   00ec 0104 ff93    00000000
0f12 05ce    00eb 0102 ff95   00e5 00fc ff97    00000000

0f12 05de    00ec 0109 ff86   00e2 0100 ff87    01270427
0f12 05df    00eb 010b ff81   00e4 0103 ff85    01270427
0f12 05e0    00ec 010a ff84   00eb 010c ff7e    01270427
0f12 05e1    00ef 010c ff88   00e7 010c ff79    01270427
0f12 05e4    00ee 010d ff82   00e9 0104 ff8d    0c270e27

0f12 05eb    00f4 010f ff89   00f4 010d ff8e    1b287426
0f12 05ec    00f0 010c ff89   00e7 0102 ff8c    1b287426

0f12 05f0    00e9 0100 ff96   00ed 0104 ff95    00000000
0f12 05f1    00ec 0103 ff94   00ef 010a ff8a    00000000
0f12 05f2    00ec 0109 ff86   00ec 0109 ff87    00000000

可以发现这些数据都符合一个格式

0f 12          → 指令头
05c6           → 递增计数器(2字节)
00ea 0106 ff8a → 数据段1(6字节)
00ed 0104 ff95 → 数据段2(6字节)
ff28 702b      → 未解析尾部(4字节)

于是我修改了python的解析代码

class GSensorParser:
    @staticmethod
    def parse_gsensor(data: bytes) -> Dict[str, int]:
        if len(data) < 12:
            return {}

        return {
            "x1": bytes_to_signed_short(data[0], data[1]),
            "y1": bytes_to_signed_short(data[2], data[3]),
            "z1": bytes_to_signed_short(data[4], data[5]),
            "x2": bytes_to_signed_short(data[6], data[7]),
            "y2": bytes_to_signed_short(data[8], data[9]),
            "z2": bytes_to_signed_short(data[10], data[11]),
        }

这样能符合常理一些

最后

此心率带有3个模式:APP中是普通,EC和HRV三种,代码中对应的是Sport,ECG和HRV。

这三个模式下,采样10s的数据,获得的ECG总点数都在1250左右

与ReadEcgAppSr读取的125对应

我不禁要问:真的有宣传中的1000Hz采样率吗?