【瑞萨BLE/WIFI模块测评】小米温湿度计接入 RA4M2 全记录
本帖最后由 eefocus_3914144 于 2026-9-11 06:51 编辑# 小米温湿度计接入 RA4M2 全记录:DA14531 刷 HCI + Zephyr 监听 + MiBeacon 解密
> 目标:用一块**没有蓝牙的瑞萨 RA4M2 开发板**,读取 **小米米家蓝牙温湿度计 2(LYWSD03MMC,原厂加密固件)** 的温湿度,本地 OLED 显示。最终效果:温度 22.4℃、湿度 67.4%、电量 100% 实时上屏。
> 本文是端到端完整过程:方案选型、DA14531 蓝牙模块固件改造、Zephyr BLE 监听、MiBeacon 协议解析与 AES-CCM 解密,以及每一步的实测日志。
>
> 环境:Zephyr 4.4.0 / RA-Eco-RA4M2-100PIN /SF-BLE-DA14531 蓝牙 PMOD 模块 / e2studio 26.4.2 / J-Link。
> !(https://www.eefocus.com/forum/data/attachment/forum/202609/11/063702ira41laem8ualalg.jpg)
## 1. 方案选型:为什么必须给蓝牙模块换固件
读小米温湿度的本质是**接收 BLE 广播包**(温湿度计是从机,周期广播,不需要连接)。手头模块是欣瑞利(Superfly)定制的 DA14531 PMOD 模块,出厂固件是"AT 指令 + 透传"的**纯从机**固件,根本不会扫描。评估了两条改造路线:
| 路线 | 结论 |
|---|---|
| 刷 Renesas CodeLess 固件,用 AT+GAPSCAN 扫描 | **死路**:扫描结果只有 MAC/RSSI,**拿不到广播 payload**([官方文档](https://lpccs-docs.renesas.com/UM-140-DA145x-CodeLess/atcommands.html)),而 MiBeacon 数据就在 payload 里 |
| 刷 **HCI 固件**,RA4M2 跑 Zephyr 蓝牙协议栈(observer) | ✅ 能拿到完整原始广播 PDU,Zephyr 还有官方 DA1453x HCI 驱动 |
选定路线 2,但有两个硬约束:Renesas 预编译 HCI 二进制**强制 RTS/CTS 流控**,而这块模块只引出 TX/RX;且模块 OTP 全空(没有出厂配置)。所以要用 Renesas **SDK6 自编译**一版:2 线 UART、P0_6/P0_7、无流控、关睡眠的 HCI 固件。
## 2. 硬件连接
```
小米温湿度计BLE 广播 ( DA14531 模块(插 PMOD2)
│ UART(SCI0,115200 8N1,无流控)
▼
RA4M2 开发板(Zephyr BLE host,observer)
│ I2C(SCI3,SCL3=P408/SDA3=P409)
▼
SSD1306 OLED(0x3C)
```
- 模块 UART:DA14531 P0_6(TX)→ P410(RA RX),P0_7(RX)← P411(RA TX),3.3V 直连;
- OLED:板载 I2C 插座,板载 1.5K 上拉,Zephyr `renesas,ra-i2c-sci` 驱动(详见姐妹篇《Zephyr 驱动 OLED 分享》[【瑞萨BLE/WIFI模块测评】Zephyr 驱动OLED - 瑞萨电子 - 与非网](https://www.eefocus.com/forum/thread-236382-1-1.html));
- 调试日志:SEGGER RTT(不占串口)。
## 3. DA14531 固件改造
### 3.1 固件配置(SDK6 hci 工程,e2studio+LLVM 编译)
SDK 6.0.24 的 `projects/target_apps/hci/hci` 只有 Keil 工程,按官方迁移指南把 `prox_reporter/e2studio` 工程复制改造。关键配置修改:
| 文件 | 修改 | 原因 |
|---|---|---|
| `user_periph_setup.h` | UART1: TX=P0_6 / RX=P0_7,`UART_AFCE_DIS` | 默认 TX=P0_0/RX=P0_1 是 SPI Flash 引脚;模块无流控 |
| `user_config.h` | `ARCH_SLEEP_OFF` | 无流控时睡眠唤醒字节会吃掉 HCI 命令 |
| `da14531_config_advanced.h` | **`#undef CFG_TRNG`** | **模块 OTP 全空无生产 trim,TRNG_ACQUIRE 初始化断言失败(最后的拦路虎)** |
⚠️ 两个用 hardfault 换来的教训:
- **不要**自作主张把 P0_8/P0_9 配置成输出高(我以为能防复位)——`GPIO_ConfigurePin` 在 `periph_init` 里直接 BKPT 断言;
- 断言定位方法:HardFault 里查 `hci.map` 符号 + 反汇编,看到 `bkpt` 指令往上追调用栈,非常高效。
### 3.2 烧录:绕开三座大山
1. **芯片睡死连不上 SWD**:DA14531 深度睡眠时 DAP 断电。解法:J-Link nRESET 经 10pin 线恰好落在模块 J2-10(GPIO_RESET,高有效),`JLINKARM_ClrRESET()` 把芯片按在 POR 态,连接 100% 稳定;
2. **SEGGER flash loader 不认模块的 Puya P25Q40**(JEDEC `85 60 13`,擦除行、编程 -5):弃用 loader,经 SWD 直写 DA14531 的 SPI 控制器寄存器,手工页编程(`tools/spi_flash_program.py`);
3. **镜像格式错误(最大的坑)**:烧什么都不启动,最后用一块没动过的好模块,读出它 Flash 的真实布局:
```70
└─ 0x7050 魔数 + 大端长度 ┘ └─ 裸固件(向量表直接开始)
```
```
**ROM booter 认的是 8 字节头 + 裸固件**,不是 mkimage 的 64 字节 AN-B-001 头(那是给二级 bootloader 用的)。正确打包:
```
```
img = b"\x70\x50\x00\x00\x00\x00" + struct.pack(">H", len(hci_bin)) + hci_bin
```
另有两个小坑:编程前先发 `0xAB` 唤醒深度掉电的 Flash(睡着时读写全 0,会误报 verify MISMATCH);每 4KB 扇区先擦后写。
### 3.3 验证
烧录后 Zephyr 侧 `bt_enable` + 被动扫描,RTT 出现滚动广播即成功:
```
scan start ret=0
adv 28:B9:E7:97:89:55 (random) rssi=-39 type=3 len=31
MiBeacon A4:C1:38:99:9A:07 (public) len=12: 30585b05b3079a9938c1a408
```
## 4. MiBeacon 协议与解密
小米原厂固件的温湿度数据在 Service Data(UUID `0xFE95`)里,**AES-CCM 加密**,需要设备的 **bindkey**(16 字节,米家绑定时生成,可用 (https://github.com/PiotrMachowski/Xiaomi-cloud-tokens-extractor) 或 TelinkMiFlasher 提取)。
帧布局(去掉 2 字节 UUID 后):
```
FC(2) PID(2) frame_cnt(1) MAC(6) payload...
加密帧(FC bit0x08): ciphertext(N) ext_counter(3) MIC(4)
```
解密参数(权威参考:(https://github.com/esphome/esphome/blob/dev/esphome/components/xiaomi_ble/xiaomi_ble.cpp)):
- AES-CCM-128,**tag 4 字节**,**AAD = 0x11**(1 字节,漏了 MIC 校验永远失败);
- nonce(12) = MAC(广播序,6) + PID(2) + frame_cnt(1) + ext_counter(3)。
解密后是"事件对象"序列:`事件ID(2) + 长度(1) + 数据`:
| ID | 含义 | 格式 |
|---|---|---|
| 0x1004 | 温度 | s16,0.1℃ |
| 0x1006 | 湿度 | u16,0.1% |
| 0x100A | 电量 | u8,% |
| 0x100D | 温度+湿度 | s16+u16 |
**bindkey 大坑**:TelinkMiFlasher 页面的"登录/激活"**每次点击都会轮换密钥**并让温度计停发数据帧(需抠电池恢复)。抄下密钥后千万别再点。
Zephyr 侧实现(`src/mibeacon.c`,移植自本工作区 `app/stm32l563e_dk_ble`):PSA Crypto 导入 bindkey,`psa_aead_decrypt`(CCM/4 字节 tag)解密,再解析事件。配置只需:
```conf
CONFIG_PSA_WANT_ALG_CCM=y
CONFIG_PSA_WANT_KEY_TYPE_AES=y
CONFIG_MAIN_STACK_SIZE=8192 # PSA 栈消耗大
```
架构注意:扫描回调跑在 BT RX 线程(栈小),帧只拷贝进 FIFO,解密和显示放主线程。
## 5. 最终效果(实机 RTT 日志)
```
*** Booting Zephyr OS build v4.4.0-24-g58069e646a84 ***
app start
oled init ret=0
mibeacon init ret=0
scan start ret=0
PKT #184 RSSI=-24 BATT=100%
PKT #186 RSSI=-23 HUM=65.9%
PKT #188 RSSI=-23 TEMP=23.6C
```
OLED 显示(温/湿/电分散在不同事件帧里,应用缓存最近值整屏重绘):
```
RA4M2 MiBeacon
T: 22.4 C
H: 67.4 %
B:100% -23dBm #188
```
## 6. 踩坑清单(全文浓缩)
| 坑 | 现象 | 解法 |
|---|---|---|
| 模块原厂固件是从机 | 不会扫描 | 刷 HCI 固件,主机跑 Zephyr 协议栈 |
| 官方 HCI 二进制要流控 | 模块只有 TX/RX | SDK6 自编译 2 线无流控版 |
| SDK 默认 UART 引脚 | P0_0/P0_1 是 SPI Flash | 改 P0_6/P0_7 |
| 芯片深睡 DAP 断电 | SWD 连不上 | nRESET 经 J2-10 按 POR 下连接 |
| SEGGER loader 白名单 | P25Q40 编程 -5 | 手工寄存器页编程 |
| 镜像格式 | 烧啥都不启动 | 8 字节 0x7050 头 + 裸固件 |
| 乱加 GPIO 配置 | periph_init 断言 | 回退,别动 P0_8/P0_9 |
| OTP 无生产 trim | TRNG 断言 | `#undef CFG_TRNG` |
| Flash 深度掉电 | 读写全 0 假 MISMATCH | 编程前 0xAB 唤醒 |
| MiBeacon 解密失败 | MIC 校验不过 | AAD=0x11 不能漏;bindkey 别被轮换 |
| OLED 显示互相覆盖 | 事件分帧到达 | 缓存最近值整屏重绘 |
## 7. 复用指引
- DA14531 烧录工具:`tools/spi_flash_program.py`(改 `IMG` 路径即用);
- HCI 固件重编:e2studio-cli 无头构建,命令见 docs/09 第 1 节;
- MiBeacon 解密:`src/mibeacon.c` 单文件可移植,只需 PSA Crypto;
- 显示:`src/oled_ssd1306.c`,I2C SSD1306 通用;
- 想恢复原模块:出厂固件 bin 在资料包,按同样的 8 字节头打包烧回即可。
后续计划:DA16200 WiFi 模块(插 PMOD1/SCI4)把数据 AT 指令推上物联网平台。
## 8. 蓝牙模块源码
### 8.1 mibeacon.c
```
/*
* 小米 MiBeacon 帧解析 + AES-CCM 解密,接口见 mibeacon.h
*/
#include <errno.h>
#include <string.h>
#include <psa/crypto.h>
#include <zephyr/sys/printk.h>
#include "mibeacon.h"
#define MI_PAYLOAD_MAX 32
/* 小米 MiBeacon 事件对象 ID(小端) */
#define MI_EVT_TEMP 0x1004U /* s16, 0.1 ℃ */
#define MI_EVT_HUM 0x1006U /* u16, 0.1 % */
#define MI_EVT_BATT_PCT0x100AU /* u8, % */
#define MI_EVT_TEMP_HUM0x100DU /* s16 0.1℃ + u16 0.1% */
/* FC 首字节标志位 */
#define MI_FC_ENCRYPTED0x08U
#define MI_FC_CAPABILITY 0x20U
#define MI_FC_HAS_DATA 0x40U
#define MI_TAG_LEN 4U
#define MI_AAD 0x11U
/* 设备 bindkey:A4:C1:38:99:9A:07(TelinkMiFlasher 读取,
* 注意 flasher 页面每次"登录/激活"都会轮换密钥,轮换后必须更新这里) */
static const uint8_t mi_bindkey = {
0x93, 0xbb, 0x3b, 0x15, 0xd5, 0x90, 0x82, 0xe3,
0x34, 0xe4, 0x28, 0x32, 0x7f, 0x9a, 0x63, 0x69,
};
static psa_key_id_t mi_key_id;
int mibeacon_init(void)
{
psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT;
psa_status_t st;
psa_set_key_type(&attr, PSA_KEY_TYPE_AES);
psa_set_key_bits(&attr, 128);
psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_DECRYPT);
psa_set_key_algorithm(&attr,
PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM,
MI_TAG_LEN));
st = psa_import_key(&attr, mi_bindkey, sizeof(mi_bindkey), &mi_key_id);
if (st != PSA_SUCCESS) {
printk("mibeacon: bindkey import failed %d\n", st);
return (int)st;
}
return 0;
}
static void decode_events(const uint8_t *p, uint8_t len,
struct mi_sensor_data *sd)
{
uint8_t off = 0;
while (off + 3 <= len) {
uint16_t eid = (uint16_t)p | ((uint16_t)p << 8);
uint8_t elen = p;
off += 3;
if (off + elen > len) {
return;
}
switch (eid) {
case MI_EVT_TEMP:
if (elen == 2) {
sd->temp_x10 = (int16_t)((uint16_t)p |
((uint16_t)p << 8));
sd->has_temp = true;
}
break;
case MI_EVT_HUM:
if (elen == 2) {
sd->hum_x10 = (uint16_t)p |
((uint16_t)p << 8);
sd->has_hum = true;
}
break;
case MI_EVT_BATT_PCT:
if (elen == 1) {
sd->batt_pct = p;
sd->has_batt = true;
}
break;
case MI_EVT_TEMP_HUM:
if (elen == 4) {
sd->temp_x10 = (int16_t)((uint16_t)p |
((uint16_t)p << 8));
sd->hum_x10 = (uint16_t)p |
((uint16_t)p << 8);
sd->has_temp = true;
sd->has_hum = true;
}
break;
default:
break;
}
off = (uint8_t)(off + elen);
}
}
static int decrypt_frame(const uint8_t *payload, uint8_t payload_len,
struct mi_sensor_data *sd)
{
uint8_t enc_off = 11;
uint8_t ct_len;
uint8_t nonce;
uint8_t ct_tag;
uint8_t plain;
size_t plain_len = 0;
static const uint8_t aad = MI_AAD;
psa_status_t st;
if (payload & MI_FC_CAPABILITY) {
enc_off++;
}
if (payload_len < enc_off + MI_TAG_LEN + 3 + 1) {
return -EINVAL;
}
ct_len = (uint8_t)(payload_len - enc_off - 3 - MI_TAG_LEN);
/* nonce = MAC(广播序) + PID + frame_cnt + ext_counter */
memcpy(nonce, payload + 5, 6);
memcpy(nonce + 6, payload + 2, 2);
nonce = payload;
memcpy(nonce + 9, payload + payload_len - 7, 3);
/* PSA AEAD 输入为 ciphertext || tag */
memcpy(ct_tag, payload + enc_off, ct_len);
memcpy(ct_tag + ct_len, payload + payload_len - MI_TAG_LEN,
MI_TAG_LEN);
st = psa_aead_decrypt(mi_key_id,
PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM,
MI_TAG_LEN),
nonce, sizeof(nonce), &aad, sizeof(aad),
ct_tag, (size_t)ct_len + MI_TAG_LEN,
plain, sizeof(plain), &plain_len);
if (st != PSA_SUCCESS) {
return (int)st;
}
decode_events(plain, (uint8_t)plain_len, sd);
return 0;
}
int mibeacon_process(const uint8_t *payload, uint8_t len,
struct mi_sensor_data *sd)
{
uint8_t off = 11;
memset(sd, 0, sizeof(*sd));
if (len < 11) {
return -EINVAL;
}
if (!(payload & (MI_FC_ENCRYPTED | MI_FC_HAS_DATA))) {
return -ENODATA; /* 周期无数据信标帧,忽略 */
}
if (payload & MI_FC_ENCRYPTED) {
return decrypt_frame(payload, len, sd);
}
if (payload & MI_FC_CAPABILITY) {
off++;
}
if (off < len) {
decode_events(payload + off, (uint8_t)(len - off), sd);
}
return 0;
}
```
### 8.2 mibeacon.h
```
/*
* 小米 MiBeacon(UUID 0xFE95)帧解析 + AES-CCM 解密
*
* MiBeacon V4 帧(service data 去掉 2 字节 UUID 后):
* FC(2) PID(2) frame_cnt(1) MAC(6) payload...
* 加密帧(FC bit0x08):ciphertext(N) ext_counter(3) MIC(4)
* 解密:AES-CCM-128,tag 4,AAD 0x11,nonce(12) = MAC + PID + frame_cnt + ext_counter
*/
#ifndef MIBEACON_H
#define MIBEACON_H
#include <stdbool.h>
#include <stdint.h>
struct mi_sensor_data {
bool has_temp;
bool has_hum;
bool has_batt;
int16_t temp_x10;/* 0.1 ℃ */
uint16_t hum_x10;/* 0.1 % */
uint8_t batt_pct;
};
/* 初始化:导入设备 bindkey 到 PSA。返回 0 成功 */
int mibeacon_init(void);
/* 处理一帧 MiBeacon payload(不含 UUID):解密(如加密)+ 解析事件。
* 返回 0 成功(明文帧也返回 0);<0 解密失败或格式错误 */
int mibeacon_process(const uint8_t *payload, uint8_t len,
struct mi_sensor_data *sd);
#endif /* MIBEACON_H */
```
页:
[1]