玩转小米手环7表盘安装蓝牙传输协议

缘起

总所周知,小米手环7是基于zeppos系统制作的,因此他可以支持运行js脚本,于是一堆表盘小程序被各路大神制作了出来。然而制作出来安装却成了问题。一开始只有替换法安装,也就是把官方的表盘替换成制作好的表盘,如果用的是小米运动健康APP则更加困难,需要在表盘下载好的一瞬间替换进去。而且下次安装检测到文件对不上又会重新下载。因此我开发了“偷天换日”APP,以自动化的方式监听文件是否下载完成,然后替换进去。但这终究是下下策。于是我瞄上了蓝牙协议,要是可以通过蓝牙直接把表盘安装进手环不是美滋滋?

当然这篇文章主要是成功后的复盘

查找资料

说干就干,我开始在网上收集资料,查看手机是如何与手环进行蓝牙通信的。然后就知道了小米手环7是用的蓝牙4.0(支持低功耗蓝牙BLE)进行通信。这里简单介绍一下BLE。

BLE的所有数据通信是通过ATT协议来实现的,然后有个叫GATT的东西,可以通过他来查询到服务端支持的server(服务)和characteristic(特征)。一个服务下有若干个特征。而手环就是服务端,连接手环的手机是客户端。服务和特征是我们主要进行通信的通道。还有,客户端可以向一个特征进行写入操作和订阅操作。客户端向服务端写入内容后,服务端只能返回一个空白的写入响应,表示我收到你的包了,并不能直接像HTTP那样返回一个自定义的响应内容。客户端需要订阅服务端的特征,来接收自定义的消息通知。

这是目前需要用到的基础知识,更详细的可以在网上自己学习。

在知道了这些后,我尝试在GitHub上搜索相关的仓库,以期望可以看到前辈的智慧结晶,好站在巨人的肩膀上。事实我站上去了,但又没完全站上去:)

当我在GitHub上搜索 miband 时,我得到了这些东西:
QQ截图20220717160444.png
最终我选定了 https://github.com/satcar77/miband4 这个仓库,因为他支持安装表盘。
接下来就是按照他的源代码进行通信,结果完全不行。毕竟是小米手环4的。而且时间也是2021年的了。
但是也不是完全没有收获。至少知道了安装表盘进行通信的服务UUID以及特征UUID。以及其他的一些东西。

蓝牙抓包

既然这里走不通,就只能进行抓包了。网上搜索安卓手机抓包蓝牙,发现可以直接在开发者设置中开启蓝牙日志,然后就能得到一个蓝牙日志文件。我是用的小米手机,所以可以通过这个帖子里提到的方法找到日志文件:
https://www.jianshu.com/p/6977585dbf4c

帖子里没有提到具体的文件名,但是通过翻看,不难发现就是 /sdcard/MIUI/debug_log/common/com.android.bluetooth/btsnoop_hci.log 文件

将文件发送到电脑,用 Wireshark 打开,就可以看到发送和接收的数据包了。

这里提一嘴,在抓包开始时,蓝牙连接会断开,因此需要在小米运动健康里重新连上手环,这期间会产生大量的数据包,为了在分析数据时与安装表盘的数据区分开来,需要在连上手环后打开表盘安装界面,安静的等待1分钟,再点击表盘同步。同步完成后挂到后台赶紧关闭蓝牙抓包。这样分析数据时可以通过时间把无关的包区分开来。

着手分析

用 Wireshark 打开日志后会看到以下界面:
2.png

这里解释一下各个字段的意思:

column注释
No.第几个包。
Time通信的开始时间,一开始不是这个格式,可以通过右键它修改显示类型。
Source发数据包的设备
Destination接收数据包的设备
Protocol通信协议
length传输的包大小,单位是字节
info表示注释

可以看到有很多无关的信息,那么如何快速找到开始通信的位置呢?
我想既然是同步表盘,那一定要把表盘发送出去对吧?那么就去看看表盘文件的 16 进制值吧,只需要看头 10 个字节应该就可以了。把抓包时同步的表盘复制出来用MT管理器点击查看文件,选择以 16 进制的方式打开,然后在 wireshark 中按 ctrl +f 搜素,搜素内容选择 16 进制值,搜索文件的头10个字节,就能找到一个包。如果找到的包过于多了点就多搜几个字节。搜出来果然不负所望:
3.png

可以看到在这个包下面都是256个字节的包,说明这里已经在传输表盘了,我们需要往前再找找,看看手机和手环都互相倾诉了什么。由于我在同步表盘前等了一会儿,所以顺着时间往上找,找到时间断层较大的地方就说明到头了,

4.png

看绿色框住的地方,差了几秒,这几秒就是我等待的时间,是的,我没等1分钟:)
看红色框住的地方,表示在发送写入请求,小米运动向手环发了一个 0xd0

点开 Handle, 可以看到 Service UUID 以及 UUID。他们两个就是服务ID和特征ID了,记录下来有用,自己发送的时候就要往这里面发。对比之前看到的手环4的python源码,发现UUID还是一样的没变。1531开头的特征是用来发送命令的,1532应该就是用来发送表盘文件的了。知道了这些含义后,只需要记录下来收发顺序和内容方便分析。

我是这样的:send 表示发送16进制值,rcvd 表示接收16进制值通知。最右边的表示特征的UUID简写

  1. send d0 -> 1531
  2. rcvd 10 d0 01 05 00 20 <- 1531
  3. send d1 -> 1531
  4. rcvd 10 d1 01 00 <- 1531
  5. send d2 08 b7 a0 04 00 77 74 5b 5d 00 20 00 ff -> 1531
  6. rcvd 10 d2 01 b7 a0 04 00 77 74 5b 5d -> 1531
  7. send d3 01 -> 1531
  8. rcvd 10 d3 01 <- 1531
  9. send 表盘文件 -> 1532
    ...
  10. rcvd 10 d4 b7 a0 04 00 00 <- 1531
  11. send d5 -> 1531
  12. rcvd 10 d5 01 <- 1531
  13. send d6 -> 1531
  14. rcvd 10 d6 01 <- 1531

收集整理好了后就是找规律,可以到一开始是发送的d0,然后就d1 d2 d3 d4 ...

所以我猜这应该是表示步骤,手环通知的 10d1、10d2、10d3 应该就是表示执行成功。后面的就是数据,但是没看懂表示什么意思。可能是表示手环空间的意思吧。然后 d2 这一步骤发送的数据相比前面的多多了,结合米环4的发包情况来看,可能是文件长度和crc的值,需要再安装一个其他的表盘来看看会不会不一样。

发送完表盘文件后又发送了 d5 和 d6 。我猜应该就是执行启用表盘的命令。

再次抓包找不同

还是按照同样的方式抓包,这次安装另外一个表盘,看看与上一个表盘同步时发送和接收的数据不同之处。结果发现果然其他的地方都一样,就d2那一步骤时,内容不一样。而且发送完表盘文件后手环发送的通知 10 d4 后面跟的内容和d2发送的前半截是一样的。

通过分析 d2 这一步骤发送的内容,可以看到两个表盘都以 d2 08 开始 00 20 00 ff 结束。但是中间部分的内容怎么来的呢?

寻求前人的智慧

在最开始看米环4的表盘安装python代码时,留意到了一段发送表盘文件长度的代码,在 dfuUpdate 函数里有以下片段:

crc=0xFFFF
with open(fileName,"rb") as f:
    crc = zlib.crc32(f.read())
print('CRC32 Value is-->', crc)
# input('Press Enter to Continue')
payload = b'\x01\x08'+struct.pack("<I",fileSize)[:-1]+b'\x00'+struct.pack("<I",crc)
char.write(payload,withResponse=True)

那么有没有可能,中间的这段内容就是文件长度 + crc32(文件bytes) 呢?

试试就知道了,我 app 是 flutter 写的,用的 dart 语言,所以需要把这段python的代码翻译过来。

通过百度,学习了 struct.pack 函数,知道了他可以把python的值转换为16进制字符串,因为python没有Byte类。所以用字符串表示。<I 表示格式,< 是以小端序存储,I 表示以4个字节存储。什么是小端序可以自行百度学习。python代码中转换后用了切片,去掉了最后一个字节。那么就是说这里只有三个字节用于存储文件大小的16进制值,并且以小端序存储。

接着就是中间以0x00分隔开与crc32的16进制值,他也是4个字节的小端序存储。crc32是一种冗余校验码,详情百度。

好消息是dart有现成的 crc 算法库,不用手撸了。
按照以上思路计算文件大小并填充进去:[d2, 08, length, 00, crc32, 00, 20, 00, ff]
就可以得到:[d2, 08, b7, a0, 04, 00, 77, 74, 5b, 5d, 00, 20, 00, ff]

文件很大,要一点一点的发出去,一次发多少?

搞明白了最难懂的一部分后,接下来就是发送表盘文件了。
根据前面抓包分析的,发送表盘文件时一个数据包有 256 个字节,选中value(发送的数据)部分,发现数据包的前12个字节没被选中,前面的12个字节应该是表示特征UUID以及操作方式什么的,是蓝牙数据包的封装部分。

那么就表示发送一次需要 256 - 12 = 244 个字节。再看发送的最后一个包,是没有256个字节的。那么就表示最后一段数据不足 244 个字节时,按其本身长度发送即可,不需要补码什么的。

开始尝试

我走到这一步以为就全都明白了,开始了尝试。先连接手环,然后监听1531特征的通知。开始传输

  1. send d0 等待通知。
  2. send d1 等待通知。
  3. send [d2, 08, b7, a0, 04, 00, 77, 74, 5b, 5d, 00, 20, 00, ff] 等待通知
  4. send [d3, 01] 等待通知
  5. 目前为止,发送的和收到的都跟小米运动一样。
  6. 遍历bytes,每次发送244个byte,完了等待通知。
  7. send d5 等待通知。
  8. send d6 等待通知。

打包APP后试了下,到第五步的时候都是正常的,手环通知的数据也跟官方的一样。按理说我第6步发送文件的时候,手环上应该显示表盘同步中才对。然后他一点动静都没有,试了几次手环还死机重启了。

是哪里不对呢?

想了三四天,试了三四个表盘,也抓了三四次包。看着都是正常的。最后我猜可能问题就出在发送文件的部分了,我又对着抓的包一条一条的看,包括1条又1条的256字节数据包。突然发现一段数据后小米运动会发送一个152字节的数据包,然后等待通知。收到通知后才会继续发送256字节的包。赶紧看了一下152字节的数据包里都有什么,结果发现还是表盘文件的字节码。

这一刻我明白了“同步”的意义,同步就是发一会儿数据等一下手环,等手环缓过来了再接着发。。。。

赶紧数了一下,发现每发33个256字节的数据包,就会发一个152字节的数据包。然后等手环通知,手环的通知内容是已发送了多少字节的字节码,以小端序存储。

已发送的长度,每个元素是一个字节,以16进制显示: [10, d4, 41, cb, 12, 00, 00]。

10,d4 是固定的字节,后面的 41,cb,12 是已发送的文件长度,因为是小端序,所以我们把他倒过来,就是: 12cb41。

将 12cb41 转换成10进制就是 1231681。如果将第一次同步得到的通知这么转换,就能得到 8192 这个数字。他刚好就是 244*33+140得出来得值。 所以可以推断出是长度的值。为什么我知道通知的值也是小端序?因为既然我发送的时候是小端序,那么接收的时候应该也是,只要加以验证就能确定。

慢慢的拉到整个同步过程的底部,以防止又漏掉什么信息。可以看到在发送完以后也会收到通知,内容是已同步的长度。在发送完以后一定要等这个通知来了并且验证一下长度是不是和文件长度一致,验证通过了才能接着发d5和d6。

再试一次

这次以这样的顺序进行通信:

  1. send d0 等待通知。
  2. send d1 等待通知。
  3. send [d2, 08, b7, a0, 04, 00, 77, 74, 5b, 5d, 00, 20, 00, ff] 等待通知。
  4. send [d3, 01] 等待通知。
  5. 遍历 bytes。

    1. 发送 33 次 244 字节的包,文件剩余长度不足 244 按剩余长度发送。
    2. 发送 1 次 140 字节的包,等待手环通知。
    3. 重复以上步骤,直到数据发完。
  6. 等待通知已同步长度==表盘文件长度。
  7. send d5 等待通知。
  8. send d6 等待通知。

按照这个顺序和规律来发送,终于是把表盘给同步过去了。

最后贴上需要通信的 service 以及 characteristic 的完整 UUID:
[表盘同步 Service UUID: 000015300000351221180009af100700]
[发送指令以及订阅通知的 characteristic UUID: 000015310000351221180009af100700]
[发送表盘文件的 characteristic UUID: 000015320000351221180009af100700]

该项目已开源到GitHub,这是GitHub链接:https://github.com/jethroHuang/installWatchFaceForMiBand7

鸣谢

本次逆向过程,参考了以下文章:
小米手机抓取蓝牙日志
struct.pack用法
Python基础之:struct和格式化字符
CRC32是什么?

感谢米坛的GiveMeFive帮我确定抓包的方向。

最后,flutter 牛逼!蓝牙通信过程调用起来真的超方便。