eefocus_3914144 发表于 2026-9-11 06:44:40

【瑞萨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]
查看完整版本: 【瑞萨BLE/WIFI模块测评】小米温湿度计接入 RA4M2 全记录