为小米手环5开发 Android 手机 App,通过蓝牙控制手环

12 次阅读

本文最后更新于 2026年11月6日。

https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-5

https://gadgetbridge.org/gadgets/wearables/xiaomi/#mi-band-5

针对 小米手环 5,我建议不要尝试开发手环固件,也不要一开始就逆向全部蓝牙协议,而是开发一个独立的 Android App,通过 BLE(低功耗蓝牙)直接连接手环。

这个方向是可行的,而且有成熟的开源项目可以借鉴。

我建议项目定位为:

MiBandTool:小米手环 5 开源蓝牙控制中心

目标是不依赖小米运动健康 App、不依赖小米云端,尽可能在本地完成设备连接、通知、震动控制和数据管理。

一、先明确小米手环 5 能实现什么

Produtos com até 15 OFF no PIX KaBuM

Обзор фитнес-браслета Xiaomi Mi Band 5 и сравнение с Mi Band 4

Xiaomi Mi Band 5 Review: $45 Fitness Tracker Packs More Features Than Rivals For Half The Price

7

小米手环 5 有 BLE 蓝牙通信能力,可以围绕以下功能开发:

功能 可实现性 说明
蓝牙连接、断开 可行 BLE GATT
读取设备信息 可行 固件、电量等
控制震动 可行 预设震动或通知
手机通知同步 可行 自己实现通知监听
来电提醒 可行 手机侧监听来电
心率测量 可行 需要正确处理测量协议
步数读取 可行 需要研究对应特征值
自定义表盘 有条件 图片格式、资源和协议限制
屏幕任意绘制 不建议 不是开放式显示设备
安装 APK 到手环 不支持常规方式 不是 Wear OS

这里最值得做的是:把手环作为手机的一个可编程震动与信息输出设备。

二、优先研究的开源项目

我筛选了几个真正有参考价值的项目。这里有一个好消息:小米手环 5 已经有相当成熟的第三方协议实现,不需要从零逆向。

  1. Gadgetbridge

首选参考

GadgetBridge | Пикабу

pikabu.ru

AGPL-3.0 · Android · 开源替代官方运动健康 App

GitHub: Freeyourgadget/Gadgetbridge

这是最重要的参考项目。

已支持小米手环 5 的主要功能:

  • 蓝牙配对和认证

  • 通知和震动

  • 心率、步数、睡眠等数据

  • 闹钟、勿扰模式

  • 音乐控制

  • 设备设置

官方设备支持文档将 Mi Band 5 列为高度支持设备。

Gadgetbridge+1

我的建议:先阅读它的 MiBand5Support、HuamiSupport 和 BLE 通信实现。

  1. miband5-ble

协议研究首选

MIT · Python · 专门针对小米手环 5

GitHub: Erencv/miband5-ble

这是一个专门针对 Mi Band 5 的独立 BLE 协议实现。

已验证的功能包括:

  • AES-128-ECB 认证

  • 实时心率

  • 步数、距离、卡路里

  • 电池电量

  • 历史活动数据

  • 震动和寻找设备

  • 部分设备参数设置

特别有价值的是它提供 PROTOCOL.md,可以直接研究底层数据包。

GitHub+1

建议:用它作为你的协议实验和功能验证工具。

  1. huami-token

GitHub: argrento/huami-token

用于获取已经绑定设备的认证密钥。

小米手环 5 的 BLE 认证不是简单连接即可完成,通常需要此前官方 App 绑定设备时建立的 auth key。

这个密钥是你自己的手环专属凭证,不是所有同型号手环共用的密钥。

  1. BreakMi

GitHub: Skiti/BreakMi

适合研究小米手环旧版认证和通信机制。

但需要注意,部分 Mi Band 5 固件已经更新过认证协议,旧版实验方案不一定适用。它更适合作为协议研究资料,而不是直接作为你的 App 基础。

Skiti/BreakMi · GitHub+1

开源项目怎么选?

项目 用途 我的建议
Gadgetbridge Android 完整实现 重点研究
miband5-ble 5 代协议实验 重点使用
huami-token 获取认证密钥 辅助工具
BreakMi 研究旧协议 了解即可

我更倾向于借鉴 Gadgetbridge 的协议代码,独立开发自己的 Android 界面,而不是直接 fork 一个庞大的项目。

注意:Gadgetbridge 是 AGPL-3.0。如果直接复制它的代码并发布衍生程序,需要遵守相应的开源义务。miband5-ble 是 MIT 许可,适合参考和移植,但仍要检查具体依赖的许可证。

三、最关键的问题:蓝牙认证

小米手环 5 有一个开发门槛:需要获取 auth key。

官方 App 首次绑定设备时,会通过服务端完成认证密钥的配置。之后第三方 App 才能使用这个密钥进行本地 BLE 认证。

基本过程:

  1. 官方 App 绑定手环

    使用 Zepp Life(原小米运动)完成首次绑定。

  2. 获取自己的 auth key

    使用 Gadgetbridge 官方文档介绍的方式,或 huami-token 工具获取。

  3. 保存认证密钥

    放在 Android Keystore 保护的本地存储中,不上传服务器。

  4. Android App 执行 BLE 认证

    连接手环,交换 challenge,完成 AES-128 认证。

  5. 读写手环特征值

    开始读取数据、控制震动、发送通知。

参考:

重要提醒:

  • auth key 相当于设备控制密码,不要上传 GitHub。

  • 获取密钥后不需要每次连接都访问小米服务器。

  • 不要随意恢复手环出厂设置,否则可能需要重新配对和获取密钥。

  • 调试时避免让 Zepp Life 和自制 App 同时占用手环的 BLE 连接。

四、我建议的 App 技术架构

不要引入 Go 后端,也不需要 NAS 参与第一阶段。手环和手机直接通信即可。

MiBandTool 技术架构

Android App(Kotlin)

Material 3 / Jetpack Compose 或简单原生界面

BLE Manager

扫描、连接、断线重连

Auth Manager

认证和密钥管理

Data Manager

心率、步数、电量

Command Manager

震动、提醒、设备控制

Room / SQLite

本地数据存储、历史记录、设置

Bluetooth Low Energy / GATT

Xiaomi Mi Band 5

BLE 服务和特征值

推荐开发环境

项目 选择
语言 Kotlin
IDE Android Studio
UI Jetpack Compose
蓝牙 Android BluetoothLeScanner / BluetoothGatt
数据库 Room / SQLite
加密 Android Keystore + AES
最低系统 Android 8.0 起步即可
运行模式 前台服务(需要持续连接时)

Android 12 及以上要处理 BLUETOOTH_SCAN、BLUETOOTH_CONNECT 等运行时权限;后台长期运行还需要考虑前台服务限制和厂商省电策略。

五、分四个阶段开发

我建议不要一次性做所有功能。先验证最关键的蓝牙链路。

第一阶段:蓝牙连接实验

首先完成

Your First Bluetooth Low Energy App with Flutter

lupyuen.github.io

  • 扫描附近的小米手环 5

  • 显示设备名称和 MAC(系统允许时)

  • 建立 GATT 连接

  • 枚举服务 UUID 和特征值

  • 读取设备型号、固件版本、电量

验收标准:能够稳定连接手环,并读取设备信息。

第二阶段:认证和控制

  • 导入 auth key

  • 实现 AES-128 认证

  • 发送震动指令

  • 实现查找手环

  • 自定义震动次数和时长

验收标准:点击手机按钮,手环成功震动。

第三阶段:数据读取

  • 获取实时心率

  • 获取实时步数

  • 获取电池信息

  • 获取历史活动数据

  • 保存到 SQLite

验收标准:可以查看当天数据和历史曲线。

第四阶段:创意功能

  • 手机通知转发

  • NAS 和 VPS 告警

  • 自定义震动规则

  • 远程智能家居控制

  • 自动化任务联动

验收标准:手环能够成为一个可靠的个人通知终端。

六、我认为最值得做的特色功能

这部分才是自己的 App 相比 Gadgetbridge 最有意思的地方。

功能 具体玩法
NAS 告警 Docker 容器停止,手环震动三次
VPS 告警 SSH 服务不可用,手环发出特殊震动
家庭控制 手机快捷操作触发手环震动反馈
专属闹钟 不同时间使用不同震动模式
专注模式 工作 50 分钟,手环震动提醒休息
智能提醒 重要联系人消息使用特殊震动
心率记录 自己决定采样频率并保存数据
防丢提醒 手机与手环断开时提醒

其中我最喜欢的是自定义震动编码。

例如:

自定义震动编码示例

NAS 异常

短、短、短

VPS 宕机

长、长

重要通知

长、短、长

休息提醒

短、长

示意编码。实际震动时序和强度取决于手环支持的震动指令,未必能精确控制每个脉冲。

这样你甚至可以不看手机,通过震动节奏判断服务器出了什么问题。

七、第一步实际怎么开始?

我建议先做一个独立的最小实验,不要马上搭建完整 App。

先用 Python 验证小米手环 5 的协议。

因为 miband5-ble 已经实现了认证、连接、读取电量和震动,我们可以先复现:

async with await MiBand5.open() as band:    print(await band.battery())    await band.vibrate(1.5)

然后再把 Python 验证成功的协议移植到 Kotlin。

这样可以把问题拆开:

  1. 手环本身是否支持该指令?

  2. auth key 是否正确?

  3. BLE 连接是否稳定?

  4. Kotlin 实现是否正确?

避免同时调试蓝牙、认证和 UI。

一个需要提前确定的事情

小米手环 5 的不同固件版本可能存在协议差异。上面这个 Python 项目明确验证的是 XMSH10HM,固件 V1.0.2.66,不能保证你的固件完全一致。

GitHub+1

八、我的最终推荐

我建议把项目分成两个版本:

  • MiBandTool Lite:纯 Android、Kotlin、BLE、SQLite,不依赖云服务。

  • MiBandTool Plus:增加 NAS、Go 服务、MQTT、智能家居和服务器告警。

先把 Lite 做好,Plus 只需要在后续增加通信模块。

为了确定第一阶段的开发参数,我还想知道你的手环当前状态:

  1. 手环固件版本

不知道

V1.0.2.66

其他版本

  1. 目前是否绑定过官方 App?

已经绑定过 Zepp Life

已经绑定过小米运动健康

从未绑定

  1. 对官方 App 的依赖程度

完全独立,不再依赖官方 App

可以首次借助官方 App,之后独立使用

允许与官方 App 配合使用

确定开发方案

我建议最终做成一个真正轻量、无广告、无账号、无云端依赖的个人手环控制 App。

如果你愿意,还可以持续跟踪相关开源协议项目的更新:

⌚每周跟踪手环开源项目更新Tap to set up

根据你的情况,我建议把 MiBandTool Lite 做成一个真正独立的 Android 应用:

  • 手环:小米手环 5(XMSH10HM)。

  • 手机:Android,直接通过 BLE 连接。

  • 认证:使用自己的手环 Auth Key。

  • 数据:全部保存在手机本地 SQLite。

  • 网络:正常使用不需要互联网。

  • 官方 App:只在获取认证密钥的准备阶段使用,之后不再依赖它。

最重要的一点:先获取 Auth Key,再考虑解除官方 App 绑定。

你目前已经用小米运动健康绑定过手环,这是一个很好的起点。但不要先恢复出厂设置、解除绑定或重新配对。否则可能增加获取原有密钥的难度。

我建议第一版只实现四个功能:

  1. 扫描并连接小米手环 5。

  2. 使用 Auth Key 完成认证。

  3. 读取电池信息和设备信息。

  4. 点击按钮让手环震动。

这四项成功,就证明我们已经掌握了独立控制手环的基本链路。

一、先解决 Auth Key:针对你的小米运动健康

这里需要纠正一个容易混淆的地方:

小米手环 5 属于较早的 Huami 平台设备。Gadgetbridge 将它列为高度支持,但要求 Huami Auth Key。小米运动健康(Mi Fitness)与 Zepp Life 使用的认证后端并不完全相同。

Gadgetbridge+1

建议按下面顺序获取。

方案 A:从手机 App 日志提取

这是我建议优先尝试的方法。

如果你安装的是新版小米运动健康,包名通常需要确认是否为:

com.xiaomi.wearable

用 ADB 查询:

adb shell pm list packages | findstr /i "wearable hm.health"

如果返回:

package:com.xiaomi.wearable

可以检查:

/sdcard/Android/data/com.xiaomi.wearable/files/log/

如果是 Zepp Life,则检查:

/sdcard/Android/data/com.xiaomi.hm.health/files/log/

Gadgetbridge 文档记录了可能包含认证密钥的日志字段:

encryptKey
token
authKey
huamiAuthKey

可以尝试将日志目录复制到电脑,然后在本地搜索这些字段。这个方法不需要 Root,但日志是否包含密钥取决于 App 版本。

Gadgetbridge

方案 B:Xiaomi Cloud Tokens Extractor

项目:

Xiaomi-cloud-tokens-extractor

它可以从小米云端获取已绑定设备的相关信息和认证密钥。Gadgetbridge 官方配对文档也列出了这个工具。

Gadgetbridge+1

获取成功后

你应该得到类似这样的 32 位十六进制字符串:

0123456789abcdef0123456789abcdef

这只是格式示例,不是你的真实密钥。

安全提醒

  • 不要把 Auth Key 发给我或其他人。

  • 不要上传到 GitHub。

  • 不要在日志中打印完整密钥。

  • 不要先解绑或恢复出厂设置。

  • 拿到密钥后再进行独立连接测试。

还有一个重要区别:获得 Auth Key 不等于已经完成独立配对。 我们还需要验证手环是否接受该密钥,以及是否存在其他手机正在占用 BLE 连接。

二、MiBandTool Lite 项目目录

第一版使用 Kotlin + Jetpack Compose,不依赖第三方蓝牙框架。

建议项目名:

MiBandTool

包名:

com.personal.mibandtool

目录如下:

MiBandTool/
├── app/
│   ├── src/main/
│   │   ├── AndroidManifest.xml
│   │   └── java/com/personal/mibandtool/
│   │       ├── MainActivity.kt
│   │       ├── ble/
│   │       │   ├── BleScanner.kt
│   │       │   ├── BleConnection.kt
│   │       │   ├── GattExplorer.kt
│   │       │   └── BandProtocol.kt
│   │       ├── auth/
│   │       │   ├── AuthManager.kt
│   │       │   └── KeyStoreManager.kt
│   │       ├── data/
│   │       │   ├── BandInfo.kt
│   │       │   └── BandRepository.kt
│   │       └── ui/
│   │           ├── HomeScreen.kt
│   │           └── Theme.kt
│   └── build.gradle.kts
├── build.gradle.kts
└── settings.gradle.kts

各模块职责:

模块 负责内容
BleScanner 扫描附近设备
BleConnection BLE 连接和断开
GattExplorer 枚举服务和特征值
AuthManager AES 认证
KeyStoreManager 安全保存 Auth Key
BandProtocol 小米手环 5 专用协议
BandRepository 设备信息和数据管理

不要把认证、扫描、UI 全部写进 MainActivity。后续协议调试会很痛苦。

三、第一版代码:BLE 扫描和连接

第一版先不发送任何未经验证的控制指令。

它能够:

  • 扫描附近 BLE 设备。

  • 展示设备名称和地址。

  • 选择小米手环 5。

  • 建立 GATT 连接。

  • 枚举所有 BLE 服务。

  • 读取标准设备信息和电池特征(如果设备开放)。

这部分可以直接创建 Android Studio 项目运行。

1. Gradle 配置

创建 Empty Activity 项目,选择 Kotlin 和 Jetpack Compose。

app/build.gradle.kts:

plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.personal.mibandtool"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.personal.mibandtool"
        minSdk = 26
        targetSdk = 35
        versionCode = 1
        versionName = "0.1.0"
    }
}

如果使用 Android Studio 自动生成的版本目录,保留它已有的 Compose 插件配置即可。

2. AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-feature
        android:name="android.hardware.bluetooth_le"
        android:required="true" />

    <uses-permission
        android:name="android.permission.BLUETOOTH"
        android:maxSdkVersion="30" />

    <uses-permission
        android:name="android.permission.BLUETOOTH_ADMIN"
        android:maxSdkVersion="30" />

    <uses-permission
        android:name="android.permission.ACCESS_FINE_LOCATION"
        android:maxSdkVersion="30" />

    <uses-permission
        android:name="android.permission.BLUETOOTH_SCAN"
        android:usesPermissionFlags="neverForLocation" />

    <uses-permission
        android:name="android.permission.BLUETOOTH_CONNECT" />

    <application
        android:allowBackup="false"
        android:label="MiBandTool"
        android:theme="@android:style/Theme.Material.Light.NoActionBar">

        <activity
            android:name=".MainActivity"
            android:exported="true">

            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

        </activity>
    </application>
</manifest>

3. MainActivity.kt

下面是第一版完整的扫描、连接和 GATT 枚举代码。


package com.personal.mibandtool

import android.Manifest
import android.bluetooth.*
import android.bluetooth.le.*
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import java.util.UUID

data class BandDevice(
    val name: String,
    val address: String,
    val device: BluetoothDevice
)

class MainActivity : ComponentActivity() {

    private var gatt: BluetoothGatt? = null

    private val bluetoothManager by lazy {
        getSystemService(Context.BLUETOOTH_SERVICE)
                as BluetoothManager
    }

    private val scanner by lazy {
        bluetoothManager.adapter.bluetoothLeScanner
    }

    private var scanCallback: ScanCallback? = null

    private val _devices = mutableStateListOf<BandDevice>()
    private val _logs = mutableStateListOf<String>()

    private var scanning by mutableStateOf(false)

    private fun log(message: String) {
        runOnUiThread {
            _logs.add(0, message)
        }
    }

    private fun hasPermissions(): Boolean {
        val permissions = if (Build.VERSION.SDK_INT >= 31) {
            arrayOf(
                Manifest.permission.BLUETOOTH_SCAN,
                Manifest.permission.BLUETOOTH_CONNECT
            )
        } else {
            arrayOf(
                Manifest.permission.ACCESS_FINE_LOCATION
            )
        }

        return permissions.all {
            ContextCompat.checkSelfPermission(
                this, it
            ) == PackageManager.PERMISSION_GRANTED
        }
    }

    fun startScan() {
        if (!hasPermissions()) {
            log("请先授予蓝牙权限")
            return
        }

        stopScan()
        _devices.clear()

        val callback = object : ScanCallback() {

            override fun onScanResult(
                callbackType: Int,
                result: ScanResult
            ) {
                val device = result.device
                val name = result.scanRecord?.deviceName
                    ?: device.name
                    ?: "Unknown"

                val address = try {
                    device.address
                } catch (e: SecurityException) {
                    "Unknown"
                }

                if (_devices.none {
                    it.address == address
                }) {
                    _devices.add(
                        BandDevice(name, address, device)
                    )
                    log("发现设备: $name")
                }
            }

            override fun onScanFailed(errorCode: Int) {
                scanning = false
                log("扫描失败: $errorCode")
            }
        }

        scanCallback = callback

        try {
            scanner.startScan(callback)
            scanning = true
            log("开始扫描 BLE 设备")
        } catch (e: Exception) {
            log("启动扫描失败: ${e.message}")
        }
    }

    fun stopScan() {
        try {
            scanCallback?.let {
                scanner.stopScan(it)
            }
        } catch (_: Exception) {
        }

        scanCallback = null
        scanning = false
    }

    fun connect(device: BluetoothDevice) {
        stopScan()
        gatt?.close()
        gatt = null

        log("正在连接设备...")

        try {
            gatt = device.connectGatt(
                this,
                false,
                object : BluetoothGattCallback() {

                    override fun onConnectionStateChange(
                        g: BluetoothGatt,
                        status: Int,
                        newState: Int
                    ) {
                        if (status != BluetoothGatt.GATT_SUCCESS) {
                            log("连接失败,状态码: $status")
                            g.close()
                            return
                        }

                        when (newState) {
                            BluetoothProfile.STATE_CONNECTED -> {
                                log("BLE 已连接")
                                g.discoverServices()
                            }

                            BluetoothProfile.STATE_DISCONNECTED -> {
                                log("BLE 已断开")
                            }
                        }
                    }

                    override fun onServicesDiscovered(
                        g: BluetoothGatt,
                        status: Int
                    ) {
                        if (status != BluetoothGatt.GATT_SUCCESS) {
                            log("服务发现失败: $status")
                            return
                        }

                        log("发现 ${g.services.size} 个服务")

                        g.services.forEach { service ->
                            log("服务 UUID: ${service.uuid}")

                            service.characteristics.forEach { char ->
                                log(
                                    "  特征: ${char.uuid} " +
                                    "属性=${char.properties}"
                                )

                                if (
                                    service.uuid == BATTERY_SERVICE &&
                                    char.uuid == BATTERY_LEVEL
                                ) {
                                    if (
                                        char.properties and
                                        BluetoothGattCharacteristic.PROPERTY_READ != 0
                                    ) {
                                        g.readCharacteristic(char)
                                    }
                                }

                                if (
                                    service.uuid == DEVICE_INFO_SERVICE &&
                                    (
                                        char.uuid == MODEL_NUMBER ||
                                        char.uuid == FIRMWARE_REVISION
                                    )
                                ) {
                                    if (
                                        char.properties and
                                        BluetoothGattCharacteristic.PROPERTY_READ != 0
                                    ) {
                                        g.readCharacteristic(char)
                                    }
                                }
                            }
                        }
                    }

                    override fun onCharacteristicRead(
                        g: BluetoothGatt,
                        characteristic: BluetoothGattCharacteristic,
                        value: ByteArray,
                        status: Int
                    ) {
                        if (status == BluetoothGatt.GATT_SUCCESS) {
                            val result = if (
                                characteristic.uuid == BATTERY_LEVEL
                            ) {
                                value.firstOrNull()?.toInt()?.and(0xff)
                                    ?.let { "$it%" }
                                    ?: "空"
                            } else {
                                value.toString(Charsets.UTF_8)
                            }

                            log("${characteristic.uuid}: $result")
                        }
                    }

                    @Suppress("DEPRECATION")
                    override fun onCharacteristicRead(
                        g: BluetoothGatt,
                        characteristic: BluetoothGattCharacteristic,
                        status: Int
                    ) {
                        if (Build.VERSION.SDK_INT < 33 &&
                            status == BluetoothGatt.GATT_SUCCESS
                        ) {
                            val value = characteristic.value ?: return
                            val result = value.joinToString(" ") {
                                "%02X".format(it)
                            }
                            log("${characteristic.uuid}: $result")
                        }
                    }
                },
                BluetoothDevice.TRANSPORT_LE
            )
        } catch (e: Exception) {
            log("连接异常: ${e.message}")
        }
    }

    companion object {
        val BATTERY_SERVICE: UUID =
            UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")

        val BATTERY_LEVEL: UUID =
            UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")

        val DEVICE_INFO_SERVICE: UUID =
            UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb")

        val MODEL_NUMBER: UUID =
            UUID.fromString("00002a24-0000-1000-8000-00805f9b34fb")

        val FIRMWARE_REVISION: UUID =
            UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
    }

    override fun onDestroy() {
        stopScan()
        gatt?.close()
        gatt = null
        super.onDestroy()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                val permissionLauncher =
                    rememberLauncherForActivityResult(
                        ActivityResultContracts.RequestMultiplePermissions()
                    ) {
                        if (hasPermissions()) startScan()
                        else log("蓝牙权限未授予")
                    }

                Column(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(16.dp)
                ) {
                    Text(
                        "MiBandTool Lite",
                        style = MaterialTheme.typography.headlineMedium
                    )

                    Spacer(Modifier.height(12.dp))

                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        Button(onClick = {
                            if (hasPermissions()) {
                                startScan()
                            } else {
                                val permissions =
                                    if (Build.VERSION.SDK_INT >= 31) {
                                        arrayOf(
                                            Manifest.permission.BLUETOOTH_SCAN,
                                            Manifest.permission.BLUETOOTH_CONNECT
                                        )
                                    } else {
                                        arrayOf(
                                            Manifest.permission.ACCESS_FINE_LOCATION
                                        )
                                    }

                                permissionLauncher.launch(permissions)
                            }
                        }) {
                            Text("扫描设备")
                        }

                        OutlinedButton(onClick = {
                            stopScan()
                        }) {
                            Text("停止扫描")
                        }
                    }

                    Spacer(Modifier.height(12.dp))

                    Text("发现的设备")

                    LazyColumn(
                        modifier = Modifier.weight(1f)
                    ) {
                        items(_devices) { item ->
                            Card(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .padding(vertical = 4.dp)
                            ) {
                                Column(
                                    modifier = Modifier.padding(12.dp)
                                ) {
                                    Text(item.name)
                                    Text(
                                        item.address,
                                        style = MaterialTheme.typography.bodySmall
                                    )

                                    Button(onClick = {
                                        connect(item.device)
                                    }) {
                                        Text("连接")
                                    }
                                }
                            }
                        }
                    }

                    Text("调试日志")

                    LazyColumn(
                        modifier = Modifier.height(190.dp)
                    ) {
                        items(_logs) {
                            Text(
                                it,
                                style = MaterialTheme.typography.bodySmall
                            )
                        }
                    }
                }
            }
        }
    }
}

代码说明:

  • 这是第一版 BLE 连接测试器,不是完整认证客户端。

  • 标准电池服务和设备信息服务可能不存在于手环公开的 GATT 服务中。

  • 手环的 Huami 私有服务需要后续结合实际枚举结果和协议文档解析。

  • Android BLE 的 GATT 操作应当串行化;这版只用于初步发现和测试,不适合直接当作长期运行的连接管理器。

四、针对小米手环 5 的真正协议 UUID

这里有一个重要细节:不能完全照搬普通 BLE 电池读取代码。

miband5-ble 的逆向协议文档给出了 Mi Band 5 的实际 GATT 映射。它使用 Huami 私有 UUID,而不只是标准 BLE 服务。

GitHub

功能 UUID
设备信息服务 180A
软件版本 2A28
硬件版本 2A27
序列号 2A25
心率服务 180D
心率数据 2A37
寻找设备/震动 1802 / 2A06
Huami 主服务 FEE0
电池信息 00000006-0000-3512-2118-0009AF100700
实时步数 00000007-0000-3512-2118-0009AF100700
Huami 认证服务 FEE1
认证特征值 00000009-0000-3512-2118-0009AF100700

因此,上面的第一版代码需要针对手环增加以下两个常量:

val HUAMI_BATTERY_INFO = UUID.fromString(
    "00000006-0000-3512-2118-0009AF100700"
)

val HUAMI_AUTH = UUID.fromString(
    "00000009-0000-3512-2118-0009AF100700"
)

读取电池时,不能按照标准电池特征值的第一个字节解析。

Mi Band 5 的电池数据格式中:

  • byte[1]:电量百分比

  • byte[2]:充电状态,00 正常,01 充电中

例如:

0F 3F 00 B2 07 ...

表示电量 63%,正常状态。

建议第一版先完整打印 GATT UUID 和属性,不要急着读取全部特征值。 后续增加操作队列,保证一次只执行一个 GATT 操作,避免 Android 返回 GATT_BUSY。

五、第二阶段:认证代码设计

等你拿到 Auth Key 后,再实现 AuthManager.kt。

小米手环 5 使用 AES-128-ECB Challenge-Response 认证。

认证流程已经在开源项目中验证:

GitHub+1

BLE 认证过程

  1. App 向认证特征值写入:

    02 00
    
  2. 手环返回随机挑战值:

    10 02 01 + 16字节随机数
    
  3. App 使用 Auth Key 加密随机数:

    AES-128-ECB
    
  4. App 写入:

    03 00 + 16字节加密结果
    
  5. 手环返回:

    10 03 01
    

    认证成功

Kotlin 加密部分可以这样实现:

import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec

fun encryptChallenge(
    authKey: ByteArray,
    challenge: ByteArray
): ByteArray {

    require(authKey.size == 16)
    require(challenge.size == 16)

    val cipher = Cipher.getInstance("AES/ECB/NoPadding")

    cipher.init(
        Cipher.ENCRYPT_MODE,
        SecretKeySpec(authKey, "AES")
    )

    return cipher.doFinal(challenge)
}

注意:这只是 AES 加密部分。完整 AuthManager 还要处理通知订阅、认证状态机、超时、错误响应和 GATT 写入队列。

六、第三阶段:实现震动控制

这是我建议的第一个真正可见的功能。

认证成功后,使用标准 Immediate Alert 特征值:

Service:        1802
Characteristic: 2A06

发送:

03

停止:

00

这个指令已经在 Mi Band 5 的实机协议实验中验证可以触发震动。

GitHub

最终 UI 可以是:

MiBandTool Lite

UI 原型

Xiaomi Mi Band 5 / Sports Modes / Heart Rate / Sleep Tracker

Mi Smart Band 5

XMSH10HM

已连接(示意)

63%

电池电量(示意)

—

心率

震动测试(认证后开放)读取设备信息这是未来完成认证后的界面示意,不代表当前已经连接了你的手环。

七、实际开发顺序和验收标准

阶段 任务 验收结果
0 获取 Auth Key 得到本地 16 字节密钥
1 运行第一版 APK 能扫描并连接手环
2 GATT 枚举 能识别 FEE0、FEE1 等服务
3 BLE 认证 收到 10 03 01
4 电池读取 能解析实际电量
5 震动控制 手机按钮能控制手环
6 数据读取 获取步数和心率

开发时建议以 miband5-ble 的 MIT 实现作为协议实验参考,以 Gadgetbridge 的 GPL-3.0 实现作为协议交叉验证资料。不要直接复制 Gadgetbridge 代码到闭源项目。

参考资料:

八、我们现在最应该做的事

我建议暂时不要继续增加代码,而是先完成两个准备工作:

  1. 确认你手机上小米运动健康的实际包名。

  2. 获取手环固件版本和 Auth Key。

这样后续我就可以按照你的真实固件版本编写协议代码,而不是盲目套用别人测试的版本。

请先运行这两条 ADB 命令:

adb shell pm list packages | findstr /i "wearable hm.health"

然后:

adb shell dumpsys bluetooth_manager

第一条确认 App 类型,第二条辅助确认蓝牙状态(输出可能较长,不必全部发送)。

另外,第一版 APK 运行后,请把扫描结果中手环的设备名称,以及 GATT 服务 UUID 列表发给我。地址和序列号可以打码。

下一步我可以据此继续完成 AuthManager.kt + KeyStoreManager.kt + 震动控制,让 MiBandTool 从一个 BLE 扫描器真正变成可以独立控制小米手环 5 的 App。