这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » STM32L562E-DK解密小米LYWSD03MMC温湿度计

共1条 1/1 1 跳转至

STM32L562E-DK解密小米LYWSD03MMC温湿度计

高工
2026-09-10 17:16:10     打赏
从零移植:STM32L562E-DK + BlueNRG-MS 被动扫描解密小米 LYWSD03MMC 温湿度计

平台:Zephyr RTOS 4.4.0 / STM32L562E-DK / BlueNRG-MS(SPI HCI) 目标:不连接、不配对、不改温度计固件,纯被动广播接收 + AES-CCM 解密 + LVGL 上屏

image.png


1. 项目背景

小米米家蓝牙温湿度计 2(LYWSD03MMC)通过 BLE 广播周期性地发出温湿度数据。它采用的是小米私有的 MiBeacon 协议(Service Data UUID 0xFE95),官方固件的数据帧是 AES-CCM 加密的,需要设备的 bindkey 才能解出明文。

本文记录在 STM32L562E-DK 开发板上,利用板载 BlueNRG-MS 蓝牙控制器(通过 SPI 跑 HCI),实现一个纯被动观察者(Observer):不建立任何 BLE 连接,抓广播、解密、解析温湿度并显示在板载 240x240 LCD 上。

最终效果:

PKT #19 RSSI=-67 TEMP=25.8C
PKT #21 RSSI=-64 HUM=70.5%

LCD 上实时显示大号温度、湿度、电量、RSSI 和帧计数。

2. 硬件平台

部件说明
主控板STM32L562E-DK(Cortex-M33,512KB Flash / 192KB RAM)
蓝牙控制器板载 BlueNRG-MS,HCI 走 SPI1(Zephyr 里节点为 hci_spi)
显示屏板载 1.54" 240x240 ST7789V TFT(并行接口)
被测设备小米 LYWSD03MMC,官方固件(未刷机)

BlueNRG-MS 只支持 BT 4.1,但对被动扫描来说完全够用 —— 米家温湿度计发的是传统 BLE 4.x 广播(非 BT5 扩展广播)。

3. 软件环境

  • Zephyr v4.4.0(west 工作区)

  • Zephyr SDK 1.0.1,ZEPHYR_TOOLCHAIN_VARIANT=zephyr

  • 构建烧录命令:

west build -b stm32l562e_dk/stm32l562xx app/stm32l563e_dk_ble -p
west flash

工程结构(标准 Zephyr 应用):

app/stm32l563e_dk_ble/
├── CMakeLists.txt
├── prj.conf
├── app.overlay          # 只干一件事:使能 &hci_spi
└── src/
   ├── main.c           # 扫描 + 解密 + 业务逻辑
   ├── lvgl_ui.c        # LVGL 界面
   └── lvgl_ui.h

4. 整体架构

                ┌────────────────────────────────────────────┐
LYWSD03MMC     │ STM32L562                                  │
 (BLE 广播) ──▶│ BlueNRG-MS ─SPI HCI─▶ Zephyr BT Host       │
               │                    │                       │
               │   scan_cb(BT RX 线程, 栈~1KB)              │
               │      │ 只拷贝帧, 立即返回                   │
               │      ▼                                       │
               │   K_FIFO + mem_slab                         │
               │      │                                       │
               │      ▼                                       │
               │   主线程(栈 8KB)                             │
               │      ├─ AES-CCM 解密(PSA crypto, 吃栈大)    │
               │      ├─ 解析事件对象 → 温湿度/电量            │
               │      └─ LVGL 界面刷新 + 串口日志              │
               └────────────────────────────────────────────┘

为什么不能直接在扫描回调里解密:扫描回调运行在 Zephyr 蓝牙接收线程上,栈默认只有约 1KB,而 mbedTLS/PSA 的软件 AES-CCM 解密一次要吃 2KB 以上的栈 —— 直接在回调里解密会栈溢出死机(实际踩过这个坑,见第 8 节)。因此回调里只做"拷贝 + 入队",解密放到栈足够大的主线程。

5. MiBeacon 协议分析

5.1 广播格式

温湿度计的广播包里,数据在 Service Data(AD type 0x16),16-bit UUID = 0xFE95 中。去掉 UUID 后的帧结构:

偏移   字段                长度(字节)
0      Frame Control(FC)   2
2      Product ID(PID)     2      LYWSD03MMC = 0x055B(小端 5B 05)
4      帧计数器             1
5      MAC                 6      广播序(逆序),如 07 9A 99 38 C1 A4 → A4:C1:38:99:9A:07
11     [capability]        0~1    FC bit0x20 置位时存在
...    payload             N

FC 第一个字节的关键位(注意:网上很多资料位定义是错的,以下经 ESPHome 源码和实测验证):

含义
0x08帧已加密
0x20带 capability 字节
0x40带数据(has data)

5.2 两种帧

信标帧(FC = 0x3058,无数据,占绝大多数):

30 58 5B 05 0A 07 9A 99 38 C1 A4 08
│FC  │PID  │cnt│MAC            │capability

约每 2 秒一帧,帧计数器不动,不含任何传感器数据。

加密数据帧(FC = 0x5858,约 10 分钟一帧,或数值变化时立即发):

58 58 5B 05 13 07 9A 99 38 C1 A4 │ 7A 1D 78 81 29 │ 00 00 00 │ E3 BC 3B A0
│FC  │PID  │cnt│MAC             │ 密文(5)      │扩展计数(3)│ MIC(4)

密文长度 = 帧长 - 11(帧头) - 3(扩展计数器) - 4(MIC)。

5.3 解密后的事件对象

明文是一个或多个"事件对象",格式:事件ID(2,小端) + 长度(1) + 数据:

事件 ID内容数据格式
0x1004温度s16 小端,0.1 °C
0x1006湿度u16 小端,0.1 %
0x100A电量u8,%(官方固件发得极少,可能几小时一帧)
0x100D温度+湿度s16 + u16,0.1 单位

实测一帧:raw=58585B0513079A9938C1A47A1D788129000000E3BC3BA0 解密后明文为 04 10 02 02 01,即事件 0x1004、长度 2、值 0x0102 = 258 → 25.8 °C

6. AES-CCM 解密细节

6.1 参数

  • 算法:AES-CCM-128

  • 密钥:设备 bindkey(16 字节,绑定米家时生成)

  • nonce(12 字节) = MAC(6,广播序) + PID(2) + 帧计数器(1) + 扩展计数器(3)

  • AAD = 单字节 0x11(这是最容易漏掉的一点,没有它 MIC 校验必失败)

  • MIC(tag):4 字节,追加在密文后面送验

6.2 先用 Python 验证(强烈推荐)

把算法在电脑上跑通再移植,可以省掉大量抓瞎时间(pycryptodome):

from Crypto.Cipher import AES

key  = bytes.fromhex("93bb3b15d59082e334e428327f9a6369")  # 换成你的 bindkey
data = bytes.fromhex("58585B0513079A9938C1A47A1D788129000000E3BC3BA0")

pid, cnt, mac = data[2:4], data[4:5], data[5:11]
ct, extc, mic = data[11:-7], data[-7:-4], data[-4:]
nonce = mac + pid + cnt + extc

c = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4)
c.update(b"\x11")                       # AAD
pt = c.decrypt_and_verify(ct, mic)
print(pt.hex())                          # -> 0410020201 = 25.8 °C

进一步可以用 ble_monitor(Home Assistant 插件)源码里的官方测试向量验证自己的解密管线,排除代码问题后再怀疑密钥:

# 测试向量来自 ble_monitor test_xiaomi_parser.py
key  = bytes.fromhex("e9ea895fac7cca6d30532432a516f3a8")
data = bytes.fromhex("58585b0550f4830238c1a495ef58763c26000097e2abb5")
# 应解出湿度 46.7%

6.3 bindkey 的获取与巨坑

用电脑浏览器(Chrome/Edge,需开启 chrome://flags/#enable-experimental-web-platform-features)打开 pvvx 的 TelinkMiFlasher 页面,连接温度计后,页面下方直接显示 Mi绑定密钥(32 位十六进制),复制即可。

巨坑警告:该页面的 "登录(Login)"和"做激活(Do Activation)"每次点击都会重新生成一对 token/bindkey 并写入温度计。也就是说:

  1. 你每次登录页面,旧密钥立刻作废;

  2. 登录还可能导致温度计停止发送数据帧(帧计数器卡住不动),需要抠电池重启才能恢复;

  3. 所以正确姿势是:登录一次 → 抄下密钥 → 再也不要碰这个页面

如果之后解密失败,第一反应就该是"密钥是不是又被轮换了"。

7. Zephyr 侧实现要点

7.1 prj.conf 关键配置

CONFIG_BT=y
CONFIG_BT_OBSERVER=y

# PSA crypto:AES-CCM 必须显式打开,否则运行时报 -134 (PSA_ERROR_NOT_SUPPORTED)
CONFIG_PSA_WANT_ALG_CCM=y
CONFIG_PSA_WANT_KEY_TYPE_AES=y

# printk 默认不支持 %f,否则打印出 *float*
CONFIG_CBPRINTF_FP_SUPPORT=y

# 主线程要跑 AES-CCM 解密,栈给足
CONFIG_MAIN_STACK_SIZE=8192

# LVGL(240x240 ST7789V)
CONFIG_DISPLAY=y
CONFIG_LVGL=y
CONFIG_LV_Z_MEM_POOL_SIZE=32768
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_48=y

PSA crypto 库本身已被 Zephyr 蓝牙协议栈初始化(bt_enable() 时会调 psa_crypto_init()),应用层直接用即可,只需开启所需算法。

7.2 扫描回调:只过滤、拷贝、入队

static void scan_cb(const bt_addr_le_t *addr, int8_t rssi,
                   uint8_t adv_type, struct net_buf_simple *ad)
{
   struct mi_ctx ctx = {0};
   struct mi_frame *frame;

   bt_data_parse(ad, parse_ad, &ctx);        // 提取 UUID 0xFE95 的 Service Data
   if (!ctx.found || ctx.payload_len < 11)
       return;

   /* 忽略无数据的信标帧 */
   if (!(ctx.payload[0] & (MI_FC_ENCRYPTED | MI_FC_HAS_DATA)))
       return;

   /* 拷贝帧,移交主线程(回调里绝不干重活) */
   if (k_mem_slab_alloc(&mi_frame_slab, (void **)&frame, K_NO_WAIT) != 0)
       return;
   frame->rssi = rssi;
   frame->len = ctx.payload_len;
   memcpy(frame->payload, ctx.payload, ctx.payload_len);
   k_fifo_put(&mi_frame_fifo, frame);
}

7.3 主线程:解密 + 解析 + 上屏

/* nonce = MAC(广播序) + PID + 帧计数器 + 扩展计数器 */
memcpy(nonce, payload + 5, 6);
memcpy(nonce + 6, payload + 2, 2);
nonce[8] = payload[4];
memcpy(nonce + 9, payload + payload_len - 7, 3);

/* PSA AEAD 的输入是 密文||tag */
memcpy(ct_tag, payload + enc_off, ct_len);
memcpy(ct_tag + ct_len, payload + payload_len - 4, 4);

static const uint8_t aad = 0x11;
st = psa_aead_decrypt(mi_key_id,
       PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, 4),
       nonce, 12, &aad, 1,
       ct_tag, ct_len + 4,
       plain, sizeof(plain), &plain_len);

密钥在 bt_enable() 之后导入一次:

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, 4));
psa_import_key(&attr, mi_bindkey, 16, &mi_key_id);

主循环同时驱动 LVGL:

while (true) {
   frame = k_fifo_get(&mi_frame_fifo, K_MSEC(10));
   if (frame) {
       process_frame(frame);          // 解密 + 解析 + 更新 UI
       k_mem_slab_free(&mi_frame_slab, (void *)frame);
   }
   lvgl_lock();
   lv_timer_handler();
   lvgl_unlock();
}

因为 FIFO 等待和解密、LVGL 刷新都在同一个主线程,天然没有并发问题。

8. 踩坑记录(本文最有价值的部分)

  1. 解析对象搞错:最初代码找的是 Manufacturer Data(厂商自定义字段 0xFF),而小米走的是 Service Data 0xFE95,导致什么都收不到。

  2. FC 位定义错误:很多资料把加密位写成 0x40,实测(对照 ESPHome xiaomi_ble.cpp)加密位是 0x08,0x40 是 has_data,0x20 是 capability。

  3. AAD 漏掉:MiBeacon 的 AES-CCM 带 1 字节 AAD = 0x11,漏了 MIC 校验永远失败。

  4. 密钥轮换:TelinkMiFlasher 的"登录/做激活"每次都会重置 bindkey 并让温度计停止发数据帧(需抠电池恢复)。抄密钥后千万别再点。

  5. CCM 算法未启用:Zephyr 4.4 的 TF-PSA-Crypto 按算法裁剪,没开 CONFIG_PSA_WANT_ALG_CCMpsa_aead_decrypt 返回 -134(NOT_SUPPORTED)。

  6. 栈溢出死机:在 BT RX 线程的扫描回调里做软件 AES-CCM,栈溢出 USAGE FAULT: Stack overflow。解法:回调只拷贝入队,解密移到主线程,主栈给 8KB。

  7. %f 打印成 *float*:Zephyr printk 默认不含浮点支持,需要 CONFIG_CBPRINTF_FP_SUPPORT=y

  8. 串口被独占:Windows 下串口同时只能一个程序打开,MobaXterm 等终端工具占着 COM 口时,脚本读取只会得到 "Permission denied" 或一片安静。

9. LVGL 界面

界面文件独立为 lvgl_ui.c,通过简单的 setter 接口与业务解耦:

void lvgl_ui_set_temp(int16_t temp_x10);
void lvgl_ui_set_hum(uint16_t hum_x10);
void lvgl_ui_set_batt(uint8_t pct);
void lvgl_ui_set_rssi(int8_t rssi);
void lvgl_ui_set_pkt(uint32_t cnt);

布局(240x240,深色背景):顶部标题 + MAC,居中大号温度(Montserrat 48),下方湿度(20 号),底部左侧电量、右侧 RSSI,最底部帧计数。占位符 --.- 在首帧数据到来后自动替换为实测值。

资源占用:整固件 Flash 约 404KB / 512KB(77%),RAM 约 78KB / 192KB(40%),LVGL 是大头,BLE + 解密部分只占约 60KB。

10. 官方固件的行为规律(实测总结)

  • 广播间隔约 2.1 秒,平时只发无数据的信标帧;

  • 数据帧约 10 分钟一帧,温湿度变化时立即发(调试时用手捂住传感器即可触发);

  • 电量帧(0x100A)极少发送,可能几小时才有一帧;

  • 电池电压低于约 2.7V(CR2032 快没电)时行为会变得不稳定,调试前建议换新电池;

  • 数据帧和信标帧交替出现,可用 FC 第一个字节区分:0x30 = 信标,0x58 = 加密数据帧。

11. 参考资料

  • pvvx/ATC_MiThermometer — 第三方固件与 TelinkMiFlasher 刷机/取密钥工具

  • ESPHome xiaomi_ble.cpp — MiBeacon 帧结构与解密参数的权威参考(FC 位、AAD=0x11、nonce 构造)

  • ble_monitor — 带官方测试向量的 Python 解密实现

  • Zephyr 文档:Bluetooth Observer、PSA Crypto、LVGL 集成







关键词: STM32L562E-DK     LYWSD03MMC         

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]