❤️ 心率传感器 (HRM)
基于 BLE HRM Profile 的心率监测设备,实时上报心率和 RR 间期数据。
📐 系统架构
flowchart TB
subgraph Sensor["心率传感器"]
HR[心率检测模块] --> MCU[BLE MCU]
MCU --> ANT[天线]
end
subgraph Phone["手机 App"]
ANT2[天线] --> MCU2[手机 BLE]
MCU2 --> APP[健身 App]
end
ANT -.->|BLE 广播/连接 | ANT2
style Sensor fill:#e3f2fd,stroke:#007bff
style Phone fill:#d4edda,stroke:#28a745
📋 什么是 Profile?
Profile(配置文件) 是蓝牙联盟 (SIG) 定义的标准应用规范,确保不同厂商的设备可以互操作。
为什么需要 Profile?
- 统一数据格式: 所有 HRM 设备使用相同的数据结构
- 即插即用: App 无需适配每个设备
- 互操作性: Nordic 芯片可连接 iOS/Android
- 降低开发成本: 无需自定义协议
HRM Profile 组成
- 服务 UUID: 0x180D (心率服务)
- 特征值 UUID: 0x2A37 (心率测量)
- 数据格式: 标志位 + 心率值 + 可选字段
- 操作流程: 发现→启用通知→接收数据
📋 HRM Profile 结构
HRM Profile 定义了一个服务和多个特征值,用于标准化心率数据的传输。
服务与特征值关系
flowchart TB
subgraph HRM["心率服务 0x180D"]
HRM_Char[心率测量特征值 0x2A37]
HRM_CCCD[CCCD 0x2902]
BODY[身体位置特征值 0x2A38]
CTRL[控制点特征值 0x2A39]
end
HRM_Char --> HRM_CCCD
subgraph Data["数据格式"]
Flags[标志位 1B]
BPM[心率值 1B]
Energy[能量消耗 2B]
RR[RR 间期 2B*N]
end
style HRM fill:#e3f2fd,stroke:#007bff
style Data fill:#fff3cd,stroke:#ffc107
特征值详细说明
| 特征值 | UUID | 权限 | 用途 | 是否必需 |
|---|---|---|---|---|
| 心率测量 | 0x2A37 | Notify | 实时推送心率和 RR 间期 | ✅ 必需 |
| 身体位置 | 0x2A38 | Read | 设备佩戴位置 (手腕/胸部等) | ⭕ 可选 |
| 控制点 | 0x2A39 | Write | 重置能量消耗累计值 | ⭕ 可选 |
| CCCD | 0x2902 | Read/Write | 启用/禁用通知 | ✅ 必需 |
特征值属性说明
❤️ 心率测量 (0x2A37)
- 操作: Notify (设备主动推送)
- 数据: 标志位 + 心率值 + 可选字段
- CCCD: 0x2902 (启用通知)
- 触发: 每次心跳或定时上报
👤 身体位置 (0x2A38)
- 操作: Read (手机读取)
- 值: 0=其他,1=胸部,2=手腕
- 用途: 优化算法精度
- 示例: 0x01 = 胸部心率带
🎮 控制点 (0x2A39)
- 操作: Write (手机写入)
- 值: 0x01 = 重置能量累计
- 用途: 清零卡路里计数
- 响应: 无需确认
📊 心率测量数据格式
// 心率测量特征值 (0x2A37) 数据格式
// 启用 Notify 后,传感器主动推送数据
typedef struct {
uint8_t flags; // 标志位
uint8_t heart_rate; // 心率值 (BPM)
uint16_t energy_expended; // 能量消耗 (kJ) - 可选
uint16_t rr_interval[]; // RR 间期 (1/1024 秒) - 可选
} heart_rate_measurement_t;
// 标志位定义
#define HRM_FLAG_HR_16BIT 0x01 // 0: 8bit, 1: 16bit
#define HRM_FLAG_CONTACT_DET 0x02 // 接触检测
#define HRM_FLAG_CONTACT_STAT 0x04 // 接触状态
#define HRM_FLAG_ENERGY_EXP 0x08 // 能量消耗存在
#define HRM_FLAG_RR_INTERVAL 0x10 // RR 间期存在
// 示例数据:正常心率 72 BPM,有 RR 间期
uint8_t hrm_data[] = {
0x16, // Flags: RR 间期存在
0x48, // Heart Rate: 72 BPM
0x00, 0x04, // RR Interval 1: 1024 * 0.0039 = 4ms (正常)
0x00, 0x04 // RR Interval 2: 4ms
};
📈 完整工作流程
sequenceDiagram
participant HRM as 心率传感器
participant Phone as 手机 App
Note over HRM,Phone: 1. 广播阶段
HRM->>Phone: ADV_IND (HRM Service 0x180D)
Note over HRM,Phone: 2. 连接阶段
Phone->>HRM: CONNECT_IND
HRM-->>Phone: 连接完成
Note over HRM,Phone: 3. 服务发现
Phone->>HRM: 发现服务 0x180D
Phone->>HRM: 发现特征值 0x2A37
Note over HRM,Phone: 4. 启用通知
Phone->>HRM: Write CCCD (0x0001)
HRM-->>Phone: Write Response
Note over HRM,Phone: 5. 数据推送
loop 每秒
HRM->>Phone: Handle Value Notification (心率 72)
end
Note over HRM,Phone: 6. 断开连接
Phone->>HRM: 断开连接
🔬 实际心率值传输详解
以用户心率为 75 BPM 为例,详细展示每一层协议传输的具体数值。
步骤 1: 传感器检测心率
// 光电传感器 (PPG) 检测心跳
// 检测到一次心跳,计算心率为 75 BPM
uint8_t heart_rate_bpm = 75; // 实际心率值
// 同时测量 RR 间期 (两次心跳间隔)
// 假设间隔为 800ms = 819 个单位 (1 单位 = 1/1024 秒)
uint16_t rr_interval = 819;
步骤 2: 构建 HRM 数据
// 构建心率测量数据包 (5 bytes)
uint8_t hrm_payload[] = {
0x10, // [0] Flags:
// bit0=0 (8-bit HR),
// bit4=1 (RR interval present)
0x4B, // [1] Heart Rate: 75 BPM (0x4B = 75)
0x33, 0x03 // [2-3] RR Interval: 0x0333 = 819 (819/1024 = 0.8 秒)
};
// 标志位详解:
// 0x10 = 0001 0000
// bit4=1 → 有 RR 间期
// bit0=0 → 8 位心率值 (0-255 BPM 足够)
步骤 3: ATT 层封装
// ATT Handle Value Notification (Opcode = 0x1B)
// 特征值句柄 = 0x0015 (假设)
uint8_t att_pdu[] = {
0x1B, // Opcode: Handle Value Notification
0x15, 0x00, // Handle: 0x0015 (心率特征值句柄)
// 下面是 GATT 数据
0x10, // Flags
0x4B, // Heart Rate: 75
0x33, 0x03 // RR Interval: 819
};
// 总长度:7 bytes
步骤 4: L2CAP 层封装
// L2CAP B-Frame (面向连接的信道)
// CID = 0x0004 (动态分配的 ATT 信道)
uint8_t l2cap_packet[] = {
0x07, 0x00, // L2CAP Length: 7 bytes (ATT PDU 长度)
0x04, 0x00, // CID: 0x0004 (ATT 信道)
// 下面是 ATT PDU
0x1B, 0x15, 0x00, 0x10, 0x4B, 0x33, 0x03
};
// 总长度:11 bytes
步骤 5: HCI ACL 数据包
// HCI ACL Data Packet (Host → Controller)
// 连接句柄 = 0x0001
uint8_t hci_acl_packet[] = {
// HCI ACL Header
0x01, 0x00, // Handle: 0x0001 (连接句柄)
0x0B, 0x00, // Data Total Length: 11 bytes
// ACL Data
0x07, 0x00, // L2CAP Length
0x04, 0x00, // CID
0x1B, 0x15, 0x00, 0x10, 0x4B, 0x33, 0x03
};
// 总长度:15 bytes
步骤 6: LL 层传输
// Link Layer Data PDU
// 分为 2 个 LL 数据包发送 (因为单个 LL PDU 最大 27 bytes)
// LL PDU #1 (包含大部分数据)
// LL PDU #2 (如果有剩余)
// 实际空中传输使用 2M PHY 或 1M PHY
// 每个数据包包含:
// - LL Header (2 bytes)
// - Payload (数据)
// - MIC (加密校验,4 bytes)
// - CRC (3 bytes)
步骤 7: 手机接收并解析
// 手机收到数据后逐层解析
// 1. 解析 L2CAP → 提取 ATT PDU
val attPdu = l2capPayload // [0x1B, 0x15, 0x00, 0x10, 0x4B, 0x33, 0x03]
// 2. 解析 ATT → 提取 GATT 数据
val opcode = attPdu[0] // 0x1B = Notification
val handle = attPdu[1] or (attPdu[2] shl 8) // 0x0015
val gattData = attPdu.drop(3) // [0x10, 0x4B, 0x33, 0x03]
// 3. 解析 HRM 数据
val flags = gattData[0] // 0x10
val hrFormat = flags and 0x01 // 0 = 8-bit
val hasRR = (flags and 0x10) != 0 // true = 有 RR 间期
val heartRate = gattData[1].toInt() and 0xFF // 0x4B = 75 BPM
if (hasRR) {
val rrInterval = ((gattData[2].toInt() and 0xFF) or
((gattData[3].toInt() and 0xFF) shl 8))
// 0x0333 = 819 → 819/1024 = 0.8 秒
val rrSeconds = rrInterval / 1024.0
}
// 显示结果:心率 75 BPM, RR 间期 0.8 秒
完整数据流图
flowchart LR
subgraph Sensor["传感器"]
HR[心率 75 BPM] --> PPG[PPG 检测]
end
subgraph Device["设备固件"]
PPG --> HRM[HRM 数据 0x10,0x4B,0x33,0x03]
HRM --> ATT[ATT PDU 0x1B,0x15,0x00...]
ATT --> L2CAP[L2CAP 0x07,0x00,0x04,0x00...]
L2CAP --> HCI[HCI ACL Packet]
HCI --> LL[LL Data PDU]
LL --> RF[RF 射频]
end
subgraph Phone["手机"]
RF2[RF 接收] --> HCI2[HCI ACL]
HCI2 --> L2CAP2[L2CAP 解析]
L2CAP2 --> ATT2[ATT 解析]
ATT2 --> GATT2[GATT 通知回调]
GATT2 --> APP[App 显示:75 BPM]
end
RF -.->|BLE 空中传输 | RF2
style Sensor fill:#d4edda,stroke:#28a745
style Device fill:#e3f2fd,stroke:#007bff
style Phone fill:#fff3cd,stroke:#ffc107
实际抓包数据示例
# Wireshark/BT 抓包显示
Frame 1234: 27 bytes on wire
Bluetooth HCI ACL Packet
Handle: 0x0001
Data Length: 11 bytes
L2CAP
Length: 7
CID: 0x0004 (ATT)
ATT
Opcode: Handle Value Notification (0x1B)
Handle: 0x0015
Value: 104b3303
Heart Rate Measurement
Flags: 0x10
..00 0000 = Heart Rate Value Format: uint8 (0)
.001 .... = Sensor Status: RR Interval present (1)
Heart Rate: 75 (0x4B)
RR-Interval: 0.800 sec (819)
💻 固件代码示例
#include "nrf_sdm.h"
#include "nrf_sdh.h"
#include "nrf_sdh_ble.h"
#include "ble_hrs.h"
// 心率服务句柄
BLE_HRS_DEF(m_hrs);
// 心率测量数据
static uint8_t heart_rate = 72;
// 初始化心率服务
void hrs_init(void) {
ble_hrs_init_t hrs_init_struct = {0};
// 配置心率测量特征值
hrs_init_struct.evt_handler = hrs_evt_handler;
hrs_init_struct.supported_body_locations = BLE_HRS_BODY_LOCATION_CHEST;
// 添加 RR 间期支持
hrs_init_struct.qr_code_supported = false;
// 初始化服务
ble_hrs_init(&m_hrs, &hrs_init_struct);
}
// 心率服务事件处理
void hrs_evt_handler(ble_hrs_t * p_hrs, ble_hrs_evt_t * p_evt) {
switch (p_evt->evt_type) {
case BLE_HRS_EVT_CCCD_ENABLED:
// 客户端启用了通知
start_heart_rate_measurement();
break;
case BLE_HRS_EVT_CCCD_DISABLED:
// 客户端禁用了通知
stop_heart_rate_measurement();
break;
default:
break;
}
}
// 测量并上报心率
void update_heart_rate(uint8_t bpm) {
uint8_t hrm_data[BLE_HRS_MAX_DATA_LEN];
uint16_t hrm_len = 0;
// 构建心率数据
hrm_data[hrm_len++] = 0x00; // Flags: 8bit HR, no RR
hrm_data[hrm_len++] = bpm; // Heart rate value
// 发送通知
ble_hrs_heart_rate_measurement_send(&m_hrs, hrm_data, hrm_len);
}
// 带 RR 间期的心率上报
void update_heart_rate_with_rr(uint8_t bpm, uint16_t *rr_intervals, uint8_t count) {
uint8_t hrm_data[BLE_HRS_MAX_DATA_LEN];
uint16_t hrm_len = 0;
// Flags: 8bit HR + RR intervals present
hrm_data[hrm_len++] = 0x10;
hrm_data[hrm_len++] = bpm;
// 添加 RR 间期
for (int i = 0; i < count; i++) {
hrm_data[hrm_len++] = rr_intervals[i] & 0xFF;
hrm_data[hrm_len++] = (rr_intervals[i] >> 8) & 0xFF;
}
ble_hrs_heart_rate_measurement_send(&m_hrs, hrm_data, hrm_len);
}
📱 手机 App 代码示例
// Android - 连接心率传感器
class HeartRateMonitor(private val context: Context) {
private var bluetoothGatt: BluetoothGatt? = null
private var heartRateCharacteristic: BluetoothGattCharacteristic? = null
// HRM Service 和 Characteristic UUID
companion object {
val HRM_SERVICE_UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val HRM_MEASUREMENT_UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
// 连接设备
fun connect(address: String) {
val device = bluetoothAdapter.getRemoteDevice(address)
bluetoothGatt = device.connectGatt(context, false, gattCallback)
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// 发现 HRM 服务
val hmsService = gatt.getService(HRM_SERVICE_UUID)
heartRateCharacteristic = hmsService?.getCharacteristic(HRM_MEASUREMENT_UUID)
// 启用通知
enableHeartRateNotification()
}
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
if (characteristic.uuid == HRM_MEASUREMENT_UUID) {
parseHeartRate(characteristic.value)
}
}
}
// 启用通知
private fun enableHeartRateNotification() {
bluetoothGatt?.setCharacteristicNotification(heartRateCharacteristic, true)
val cccd = heartRateCharacteristic?.getDescriptor(CCCD_UUID)
cccd?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
bluetoothGatt?.writeDescriptor(cccd)
}
// 解析心率数据
private fun parseHeartRate(data: ByteArray) {
val flags = data[0].toInt()
val hrFormat = flags and 0x01
val heartRate = if (hrFormat == 0) {
data[1].toInt() and 0xFF // 8-bit
} else {
((data[1].toInt() and 0xFF) or ((data[2].toInt() and 0xFF) shl 8)) // 16-bit
}
Log.d("HRM", "心率:$heartRate BPM")
// 检查是否有 RR 间期
if (flags and 0x10 != 0) {
parseRRIntervals(data, hrFormat)
}
}
}
🔍 广播数据配置
// 广播数据:让手机快速识别 HRM 设备
// 在广播中包含 HRM Service UUID
uint8_t adv_data[] = {
// Flags
0x02, 0x01, 0x06,
// 16-bit Service UUIDs (HRM Service)
0x03, 0x03, 0x0D, 0x18, // 0x180D = Heart Rate
// 设备名称
0x0A, 0x09, 'H', 'R', 'M', '-', 'D', 'e', 'v', 'i', 'c', 'e'
};
// 扫描响应数据
uint8_t scan_rsp[] = {
// 完整设备名称
0x0A, 0x09, 'H', 'R', 'M', '-', 'D', 'e', 'v', 'i', 'c', 'e',
// 外观 (Appearance): 心率带
0x03, 0x19, 0x03, 0x04 // 0x0403 = Heart Rate Sensor
};
⚡ 功耗优化技巧
| 优化项 | 配置 | 效果 |
|---|---|---|
| 连接间隔 | 100-500ms | 降低通信频率 |
| 从机延迟 | 0-4 | 允许跳过事件 |
| 广播间隔 | 500-1000ms | 降低待机电流 |
| 传感器采样 | 按需唤醒 | 仅在连接时测量 |
| RR 间期上报 | 批量发送 | 减少数据包数量 |
🛠️ 调试要点
常见问题
- 通知未启用:检查 CCCD 写入
- 数据解析错误:确认标志位
- 连接不稳定:调整连接参数
- 功耗过高:优化广播间隔
推荐工具
- nRF Connect: 查看服务/特征值
- Heart Rate Simulator: 模拟 HRM 设备
- Wireshark: 空口抓包分析
- Segger RTT: 固件日志输出