Your Soul, Your Beats! —— 小米手环实时心率采集

Your Soul, Your Beats! —— 小米手环实时心率采集

随便写写 Go 4216 字 / 9 分钟

上周吃完饭后去协会坐了会,听 Kevin 说华为手环可以向官方提交申请,从而获得手环的数据访问权限。他还提到之前就有 Vtuber 直播恐怖游戏时,画面上会显示实时心率以增强节目效果。 这确实是个很 Geek 很酷的玩法哦!要是我能将我的实时心率展示在我的 GitHub Profile 上,岂不是帅炸了!

说做就做!晚上回到宿舍后我大致搜索了下目前市面上的手环以及其二开的难度,最后选择了小米手环 6 NFC 版。 原以为像这种手环至少得八九百,没想到 NFC 版才 279!直接京东下单!

第二天早上快递就送到了。恰逢学校体测,我试着带上手环跑了 1000 米,在小米官方的小米运动 App 上能看到心率检测的效果还不错,只是我自己散步一样地“跑”了五分钟太拉胯了。

数据从何而来?

那么接下来就来看下我们应该如何拿到心率数据吧。 首先需要思考的问题是我们的数据从何处获得。小米手环与手机连接时,会将相关信息同步至小米运动 App,我们当然可以对这个 App 抓个包拿到接口,请求小米的服务器获取我们的数据。但这绕了整整一圈可太麻烦了,手环就戴在我手上,为何不直接通过蓝牙连接手环读取数据呢? 这就是我的思路,我想直接在通过蓝牙协议与小米手环进行通信,获取真正的“一手数据”。 考虑到使用 iPhone 与手环建立长连接通信的话,iOS 应用保活方面估计会有一堆麻烦事,况且写 Swift 不如让我去死。不如直接在 MacBook 上跑个后台进程一直用蓝牙与手环交互来的方便。反正我 Mac 也基本是随身带的。😁

获取小米手环 Auth Key

心率信息并不像设备的电池电量、时间信息等直接就能获得,在通过蓝牙获取小米手环的心率信息之前,是需要先与手环进行验证的。 验证的步骤大致如下:

  1. 向小米手环请求一个随机数。
  2. 接收到随机数后,使用该手环的 Auth Key 对随机数进行 AES 对称加密。
  3. 将加密后的信息发回给手环。
  4. 验证通过。

对于 Android 手机而言,获取 Auth Key 的方法十分简单(大概): 访问这个网站:http://www.freemyband.com/ 并根据页面上的指引,下载一个魔改过的小米运动 App,打开后与手环配对,之后就可以在手机 /sdcard/freemyband 目录获取到手环的 Auth Key。

可惜我的老旧安卓机十分的垃圾,它的配置跑不起来上文中提到的 App,因此以上步骤我并未实际测试过。最后我是使用一台越狱的 iPad 进行操作。 我在 iPad 上安装好小米运动的 App,与小米手环成功配对后。SSH 连上 iPad,在

/var/mobile/Containers/Data/Application/<MiFit_App_UUID>/Documents

目录下找到了 HMDBDeviceInfoDataBaseV2.sqlite 这样一个 SQLite 数据库,scp 将其拖到电脑上打开,在 device_info 表的 deviceOAuthKey 字段中获取到了手环的 Auth Key。

该 Auth Key 在设备恢复出产设置后才会改变,因此一般来说我们拿到过一次记下来即可。

检测电脑蓝牙是否正常

现在让我们来试试通过 MacBook 的蓝牙与小米手环进行通信。在使用 Go 编写真正的代码前,我们得先测试下 Mac 的蓝牙,免得后面调试了半天代码最后发现是电脑连不上小米手环。 这里推荐使用 Bluetility 来进行测试:https://github.com/jnross/Bluetility 直接终端运行:

brew install --cask bluetility

安装成功后打开 App,你会在最右侧的 Devices 列表中看到附件发现的蓝牙设备。找到并点击你的小米手环(我的是 Mi Smart Band 6);右侧 Services 会出来一列,点击 Battery;再在 Characteristics 中点击 Battery Level,这时便读取到了这个 Characteristics 的数据,我们需要关注该数据转换为十进制时的结果,我这里是 100,即当前手环电量为 100%。

好!这说明我们的蓝牙没有问题,下面就是开始写代码了!

小试牛刀:获取电量信息

我们需要找到一个 Go 的 BLE (Bluetooh Low Energy) 库,从而实现与蓝牙设备的通信。在 GitHub 上简单的搜索过后,你可能很轻易的就发现了 github.com/go-ble/ble 这么一个库。

有坑注意
github.com/go-ble/ble 已不再对 macOS 平台进行维护,以至于你在 macOS 下使用该库连代码编译都不通过!

不过我看到这个库有不少的 Fork,可以尝试使用 Find useful forks 看一下:https://useful-forks.github.io/?repo=go-ble%2Fble ,其中 star 数排名第二的 Fork github.com/JuulLabs-OSS/ble 增加了对 macOS Mojave 与 Catalina 的支持,我们最后用的就是它!

设置蓝牙设备

首先我们需要设置好我们 MacBook 本机的蓝牙设备,这里全部使用默认的即可:

d, err := darwin.NewDevice()
if err != nil {
	return nil, errors.Wrap(err, "new device")
}
ble.SetDefaultDevice(d)

发现附近的蓝牙设备并连接

设置好蓝牙设备后,之后代码的流程与上述使用 Bluetility App 的操作流程其实是一样的。 我们需要发现附近的蓝牙设备,ble.Connect 方法会在发现新的设备时调用其中的匿名函数,入参为设备的信息,我们通过设备信息(设备名、设备 UUID)等来判断这是否是我们想要连接的目标设备,如果确认连接则返回 true,否则返回 false。 下面的代码一直返回 false,并将发现的设备信息打印出来,你可以在打印出的设备信息中找到自己小米手环的特征。这里我建议还是使用 a.Addr() 这个值辨别设备比较稳健。

ctx := context.Background()
client, _ := ble.Connect(ctx, func(a ble.Advertisement) bool {
	fmt.Printf("%s - %s\n", a.LocalName(), a.Addr().String())
	return false
})

发现设备 Services

在使用 Bluetility App 获取设备电量时,我们最终通过读取 Characteristics 中的数据获取到设备的电量信息,那现在通过 ble 库连上了设备,我发现其 Client 下就有 ReadCharacteristic(),那么我是不是可以直接传入 Characteristics 的 UUID 去读电量了呢? 答案是不行的,我们的操作需要一步步来。与使用 Bluetility App 图形化操作一致,我们需要先发现设备下所有的 Services,再去发现 Service 下的 Characteristics,这时才能读取对应 Characteristics 中的数据。 发现 Services 的代码很简单,我们也不需要设置过滤条件。

services, err := client.DiscoverServices(nil)
if err != nil {
	return nil, errors.Wrap(err, "discover services")
}

找到 Battery Service

根据 Bluetooth GATT,全天下蓝牙设备的电池电量都从 UUID 为 0000180f-0000-1000-8000-00805f9b34fb 的 Service 中获取。我们对上面的 services 进行遍历,获取到 UUID 为 180f 的 Service,即为电池电量 Battery Service。

for _, service := range services {
	service := service
	case "180f": // Battery
		miband.battery = service
	}
}

找到 Battery Characteristic

同理,我们再寻找 Battery Service 下的所有的 Characteristics。遍历获取到的 characteristics,找到 UUID 为 2a19 的 Characteristic。

characteristics, err = client.DiscoverCharacteristics(nil, miband.battery)
if err != nil {
	return nil, errors.Wrap(err, "discover battery service characteristics")
}
for _, characteristic := range characteristics {
	characteristic := characteristic
	if characteristic.UUID.String() == "2a19" {
		miband.batteryCharacteristic = characteristic
	}
}

读取电池电量信息

终于,在这么一环套一环之后,我们拿到了这个 Characteristic,这时才能够使用 ReadCharacteristic() 方法来读取其中的内容。

data, err := m.client.ReadCharacteristic(m.batteryCharacteristic)
if err != nil {
	return 0, errors.Wrap(err, "read characteristic")
}
return int(data[0]), nil

将返回的数据中的第一个 byte 转换为十进制,这就是小米手环的电池电量了! 至此,你已经学会了从 Device -> Service -> Characteristic 的过程,并成功读取到了 Characteristic 中的数据。那么接下来来获取心率信息吧~(笑)

使用 Auth Key 进行验证

还记得上文中我们获取到的小米手环 Auth Key 吗?现在我们要使用它来进行验证。 下文中的交互参考自前人的小米手环通信 Python 实现:https://sourcegraph.com/github.com/satcar77/miband4@master/-/blob/miband.py 我只是在这基础上用 Go 重写了一遍,做了点微小的贡献。😊

参照上文过程,获取到 NotifyService (UUID: fee1) 以及其下的 AuthCharacteristic (UUID: 000000090000351221180009af100700)。我们将使用 AuthCharacteristic 来进行验证的通信。

  1. 注册 Notification Handler 因为蓝牙的发送与接收是异步的,所以我们需要 Subscribe 来自小米手环 Characteristic 传回的消息,根据返回的消息做下一步处理。
err = client.Subscribe(miband.authCharacteristic, false, miband.handleAuthNotification)

handleAuthNotification 方法如下,其实就是一个大大的 switch-case,对返回消息的前三位进行判断,从而进行下一步操作。

func (m *MiBand) handleAuthNotification(data []byte) {
	switch string(data[:3]) {
	case "\x10\x01\x01":
		log.Trace("[Auth] Start to request random number...")
		if err := m.requestRandomNumber(); err != nil {
			log.Error("[Auth] Failed to request random number: %v", err)
		}

	case "\x10\x01\x04":
		m.state = AuthKeySendingFailed
		log.Error("[Auth] Failed to send key.")

	case "\x10\x02\x01":
		log.Trace("[Auth] Start to send encrypt random number...")
		randomNumber := data[3:]
		if err := m.sendEncryptRandomNumber(randomNumber); err != nil {
			log.Error("[Auth] Failed to send encrypt random number: %v", err)
		}

	case "\x10\x02\x04":
		m.state = AuthRequestRandomNumberError
		log.Error("[Auth] Failed to request random number.")

	case "\x10\x03\x01":
		m.state = AuthSuccess
		log.Trace("[Auth] Success!")
		close(m.authed)

	case "\x10\x03\x04":
		m.state = AuthEncryptionKeyFailed
		log.Error("[Auth] Encryption key auth fail, sending new key...")
		err := m.sendKey()
		if err != nil {
			log.Error("[Auth] Failed to send new key: %v", err)
		}

	default:
		m.state = AuthFailed
		log.Error("Auth failed: %v", data[:3])
	}
}
  1. 向 AuthCharacteristic 发送 \x02\x00。 我们先主动发送 \x02\00
miband.client.WriteCharacteristic(miband.authCharacteristic, []byte("\x02\x00"), false)

之后会收到来自小米手环的以 \x10\x02\x01 开头的消息,前三位之后的消息即为返回随机数。我们使用 Auth Key 对这个随机数进行 AES 加密后发回给小米手环。 这时若 Auth Key 验证成功,将收到 \x10\x03\x01 开头的消息,至此整个验证结束。

有坑注意
Go 的 crypto 标准库中不带 AES ECB 模式的加密,曾有人向 crypto 源码提交过支持 AES ECB 模式的 Pull Requests,但被 Cox 因该加密模式不安全给拒绝了。因此我这里很投机取巧地将当时被拒掉的代码直接复制过来使用了。代码见:https://github.com/wuhan005/mebeats/blob/master/cryptoutil/aes.go

获取实时心率信息

发现 Service、Characteristic

与上述操作相同,我们需要先 Discover 到相应的 Service 以及 Service 下的 Characteristic。 它们分别是:

  • HeartRate Service (UUID: 180d)
  • HeartRate Control Characteristic (UUID: 2a39) 用于控制心率模块,如开始一次心率检测,设置自动心率检测频率等
  • HeartRate Measure Characteristic (UUID: 2a37) 订阅该 Characteristic 以接收设备发送的心率信息

订阅 Measure Characteristic

err := m.client.Subscribe(m.heartRateMeasureCharacteristic, false, m.handleHeartRateNotification)

这个 Characteristic 返回的内容很简单,就是心率信息,我们取第二个 byte,转为十进制即可。

func (m *MiBand) handleHeartRateNotification(data []byte) {
	m.currentHeartRate = int(data[1])
	log.Trace("Heart rate: %d", m.currentHeartRate)
}

开启实时心率获取

这里是向 HeartRate Control Characteristic 发送消息,首先是停止之前正在进行的自动与手动心跳检测,再开启一次手动心跳检测:

// Stop continuous.
err = m.client.WriteCharacteristic(m.heartRateControlCharacteristic, []byte("\x15\x02\x00"), false)
if err != nil {
	return errors.Wrap(err, "stop continuous")
}

// Stop manual.
err = m.client.WriteCharacteristic(m.heartRateControlCharacteristic, []byte("\x15\x01\x00"), false)
if err != nil {
	return errors.Wrap(err, "stop manual")
}

// Start manual.
err = m.client.WriteCharacteristic(m.heartRateControlCharacteristic, []byte("\x15\x01\x01"), false)
if err != nil {
	return errors.Wrap(err, "start manual")
}

go func() {
	for {
		time.Sleep(12 * time.Second)
		log.Trace("Send ping...")
		err = m.client.WriteCharacteristic(m.heartRateControlCharacteristic, []byte("\x16"), false)
		if err != nil {
			log.Error("Failed to send ping: %v", err)
		}
	}
}()

如果我们仅发送上述的三个消息,那么在一开始的十几秒内,每隔四五秒我们就能收到一次心率信息,在之后会变成一分钟才收到一次。因此我起了个协程,每隔 12 秒 ping 一下。这样就能在短间隔内不断收到新的心率数据了。

至于这数据能怎样玩出花来,那就看各位的想象力了。

最后说几句

这个项目的完整代码见:https://github.com/wuhan005/mebeats ,现在你也可以在我的 GitHub Profile 看到我的实时心跳了💓! 需要注意的是,后端返回图片时记得加上这个响应头,这样 GitHub 才不会缓存这张图片。

cache-control: no-cache,max-age=0,no-store,s-maxage=0,proxy-revalidate

项目的后端使用的是 Flamego 框架,虽然她现在还不是很完备,但还是很希望大家都去体验下。

周二的时候我发了条 Twitter 介绍了下这个项目,被大佬转发后没想到居然火了。🔥 Twitter 一天内涨了一百多 fo,连带着这个项目直接收获 100+ stars!GitHub Follower 也破 200 了😄 真的有点受宠若惊啊,还好这项目代码质量不赖,不算太丢人哈哈哈。 同时 Twitter 上也有人提出对于 Apple Watch,可以使用 Short Cuts 读取健康 App 的心率数据,然后触发 GitHub Actions 更新 GitHub Profile README,这也是一个很棒的思路。

期间还有了个小插曲,跑我这个心跳服务的机器还被人 DDoS 了,收到腾讯云的报警后我赶紧换了机器 + 上 CloudFlare CDN,人红是非多啊……

今后如果还有空继续改进这个项目的话,我其实是想再加一个 Web 界面让用户能手动选择想要连接的设备的。同时能够对每一次的心跳数据进行保存。我大概算了下,如果一次心跳使用 8 bits (0~255) 来表示,类似 Redis BitMap 的方式,一秒一次心跳记录,一年下来也就 8 * 3600 * 24 * 365 = 252,288,000 bits,约等于 30 Mb。完全没有问题!