从呼吸心率到离床报警:60G雷达睡眠仪全栈开发实录(硬件+MQTT+小程序)
目录
- 一、引言:项目背景与研究意义
- 二、系统总体设计
- 三、硬件选型与接线设计
- 四、ESP32-S3 软件开发环境搭建
- 五、60GHz 毫米波雷达驱动与协议解析
- 六、SGP30 空气质量采集
- 七、光敏电阻采样与自动起夜灯
- 八、WS2812 RGB 灯带驱动
- 九、海凌科 V20 语音控制
- 十、OLED 显示界面
- 十一、按键、指示灯与网页配网
- 十二、MQTT 接入华为云物联网平台
- 十三、Qt 上位机应用
- 十四、微信小程序应用
- 十五、系统联调与分步调试
- 十六、常见问题分析与功能扩展建议
- 十七、总结
一、引言:项目背景与研究意义
睡眠是人体恢复精力、维持生理机能的重要过程。世界卫生组织的调查显示,全球约有三分之一的人存在不同程度的睡眠问题,而睡眠呼吸暂停、夜间心率异常等情况还可能诱发更严重的心血管疾病。对睡眠过程中的呼吸、心率、体动、离床等信息进行长期、无感的监测,对于评估睡眠质量、发现健康隐患具有现实意义。
传统的睡眠监测设备多为可穿戴式,需要用户佩戴手环、胸带或指夹入睡,设备的束缚感本身就会影响睡眠;专业的多导睡眠监测仪(PSG)虽然准确,但价格昂贵、需要在医院由专业人员操作,不适合家庭长期使用。
毫米波雷达是一种非接触式的感知手段。60GHz 频段雷达可以穿透被褥、不采集影像,在不侵犯隐私的前提下,捕捉人体胸腔起伏引起的毫米级位移,从而推算呼吸频率、心率以及体动事件,并进一步划分清醒、浅睡、深睡等睡眠阶段。相比摄像头方案,雷达不受光线影响、不涉及隐私问题;相比可穿戴方案,雷达实现了真正的"无感监测"。
本项目以乐鑫 ESP32-S3 为主控,以云帆瑞达 R60ABD1 60GHz 毫米波雷达为核心感知器件,配合 SGP30 空气质量传感器、光敏电阻、WS2812 RGB 灯带、海凌科 V20 语音模块、OLED 显示屏、蜂鸣器、按键与指示灯,设计并实现了一台集睡眠监测、环境监测、语音交互、自动起夜灯、云端数据展示于一体的睡眠监护仪。
下面是当前做好的设备实物与微信小程序:





下面是正在打印的外壳:




本项目的主要工作与特点如下:
- 采用 60GHz 毫米波雷达实现非接触式呼吸、心率、睡眠分期、翻身与离床监测,共获取 17 项睡眠与人体参数;
- 设计"环境光 + 人体存在"双条件触发的自动起夜灯,夜间人体活动时自动点亮暖白色灯带,20 秒后自动熄灭;
- 接入离线语音识别模块,无需联网即可完成开灯、关灯、变色、照明模式、睡眠模式等语音控制;
- 通过 MQTT 协议接入华为云物联网平台,并分别使用 Qt5 与微信小程序完成两种应用侧客户端的开发;
- 设备支持 AP 热点 + 网页配网,WiFi 凭据保存在 NVS 中,掉电不丢失。
二、系统总体设计
2.1 功能需求分析
- 雷达睡眠监测:采用 R60ABD1 检测实时呼吸、实时心率、睡眠质量评分、深睡时长、浅睡时长、清醒时长、翻身次数、离床次数、平均呼吸、平均心跳、活动幅度,并判断人体是否存在;
- 自动起夜灯:集成光敏电阻,夜间检测到人体时自动开灯,灯带采用 WS2812,该功能可在 APP 和微信小程序上开启或关闭;
- 语音控制:采用海凌科 V20 语音识别模块,支持开灯、关灯、变颜色、照明模式(白色灯)、睡眠模式(橙色暖灯);
- 空气质量检测与报警:采用 SGP30 检测二氧化碳(CO2)当量与 TVOC 含量,气体浓度超出安全范围时蜂鸣器报警,同时 APP 和小程序弹窗提示;
- 状态指示:绿色指示灯在程序正常运行时闪烁,黄色指示灯点亮表示正在联网或配网,熄灭表示联网成功;
- 本地显示:0.96 寸 I2C 接口 OLED 显示心率、呼吸、睡眠质量等信息;
- 按键操作:两个按键分别用于显示屏翻页和进入配网模式;
- 数据上云:ESP32 工作在 STA 模式连接路由器,通过 MQTT 将数据上传至华为云物联网平台,APP 与小程序获取云端数据展示;
- 网页配网:按下配网键后 ESP32 切换为 AP 模式散发热点,用户使用浏览器登录网页完成配网。
2.2 系统总体架构
系统在逻辑上分为感知层、控制层、网络层、平台层与应用层五个层次,如图 2 所示。
- 感知层:R60ABD1 雷达、SGP30 气体传感器、光敏电阻,分别负责人体睡眠参数、空气质量与环境光的采集;
- 控制层:ESP32-S3 通过两路 UART、两路 I2C、一路 ADC 以及多个 GPIO 完成对全部外设的驱动,并运行自动起夜灯、语音响应、睡眠数据持久化等业务逻辑;
- 网络层:ESP32-S3 通过 WiFi 连接路由器,经 MQTT 协议与云端建立长连接;
- 平台层:华为云物联网设备接入服务(IoTDA)完成设备认证、属性接收、设备影子存储;
- 应用层:Qt 上位机与微信小程序通过华为云应用侧 API 获取设备影子并展示,运行在 PC 与手机上。

2.3 数据参数定义
设备上传至云端的属性以 A1~A17、A22 编号,并包含一个功能开关 LED_Auto,如表 2-1 所示。该编号与雷达协议字段、华为云产品模型、Qt 与小程序解析代码完全一致,是贯穿整个系统的数据约定。
表 2-1 设备属性定义表
| 属性名 | 含义 | 类型 | 单位 |
|---|---|---|---|
| A1 | 二氧化碳含量 | 整数 | ppm |
| A2 | TVOC 含量 | 整数 | ppb |
| A3 | 人体感应(0 无人 / 1 有人) | 整数 | --- |
| A4 | 活动状态(0 静止 / 1 活跃) | 整数 | --- |
| A5 | 心率 | 整数 | BPM |
| A6 | 呼吸 | 整数 | BPM |
| A7 | 体动幅度 | 整数 | --- |
| A8 | 清醒时长 | 整数 | 分钟 |
| A9 | 浅睡时长 | 整数 | 分钟 |
| A10 | 深睡时长 | 整数 | 分钟 |
| A11 | 睡眠总时长 | 整数 | 分钟 |
| A12 | 睡眠质量评分 | 整数 | --- |
| A13 | 离床时长 | 整数 | 分钟 |
| A14 | 离床次数 | 整数 | --- |
| A15 | 翻身次数 | 整数 | --- |
| A16 | 平均呼吸 | 整数 | BPM |
| A17 | 平均心跳 | 整数 | BPM |
| A22 | 人体距离 | 整数 | cm |
| LED_Auto | 自动起夜灯开关(0 关 / 1 开) | 整数 | --- |
在固件中,上述字段由一个全局结构体统一承载,定义于 radar_r60abd1.h:
c
typedef struct
{
float A1; // 二氧化碳含量(ppm)
float A2; // TVOC 含量(ppb)
int A3; // 人体感应(0:无人,1:有人)
int A4; // 活动状态(0:静止,1:活跃)
int A5; // 心率(BPM)
int A6; // 呼吸(BPM)
int A7; // 体动幅度
int A8; // 清醒时长(分钟)
int A9; // 浅睡时长(分钟)
int A10; // 深睡时长(分钟)
int A11; // 睡眠总时长(分钟)
int A12; // 睡眠质量评分
int A13; // 离床时长(分钟)
int A14; // 离床次数
int A15; // 翻身次数
int A16; // 平均呼吸
int A17; // 平均心跳
int A22; // 人体距离(cm)
} DeviceData_t;
DeviceData_t DeviceData; // 全局设备数据
int LED_Auto = 0; // 自动起夜灯开关(0:关闭,1:开启)
三、硬件选型与接线设计
3.1 主控:ESP32-S3
主控选用乐鑫 ESP32-S3。它是一款基于 Xtensa LX7 双核 32 位处理器的物联网 SoC,主频最高 240MHz,内置 WiFi 与蓝牙,拥有丰富的 GPIO、UART、I2C、SPI、ADC、RMT 等外设资源。ESP32-S3 的优势在于:
- 双核性能足以同时运行雷达解析、MQTT 通信、网页配网等多个任务;
- 内置 WiFi,无需外接网络模组;
- RMT 外设非常适合驱动 WS2812 这类对时序要求严格的单线器件;
- 配套的 ESP-IDF 开发框架成熟,NVS 分区可方便地保存配网信息与睡眠统计。

3.2 60GHz 雷达:R60ABD1
雷达选用云帆瑞达 R60ABD1。该模组工作在 60GHz 频段,内置睡眠监测算法,通过 UART 以 115200 波特率输出结构化数据,可直接给出呼吸、心率、睡眠分期、翻身、离床、活动、人体存在与人体距离等结果,开发者只需解析串口协议,无需自行实现信号处理算法。

3.3 空气质量传感器:SGP30
SGP30 是一颗集成了气体传感器与信号处理芯片的数字空气质量传感器,通过 I2C 接口输出 CO2 当量(ppm)与 TVOC(ppb)两项结果。TVOC 即总挥发性有机物,室内的烟雾、煤气、酒精、装修挥发物等都会使其数值上升,可作为综合空气质量的参考指标。传感器 I2C 地址为 0x58,时钟最高 100kHz。

3.4 灯带与语音模块
- WS2812 RGB 灯带:每颗灯珠内置控制 IC,单根信号线即可级联,每颗灯珠可独立设置 24 位颜色。本工程使用 10 珠灯带,5V 供电。
- 海凌科 V20 语音识别模块:离线语音方案,内置麦克风,预先学习词条后通过 UART 输出命令码,无需联网即可完成语音控制,供电 5V、波特率 115200。

3.5 显示与交互器件
- OLED 显示屏:0.96 寸,128×64 分辨率,I2C 接口,3.3V 供电,用于本地分页显示;
- 蜂鸣器:有源蜂鸣器,高电平鸣响,5V 供电,用于操作提示与报警;
- 按键:两个轻触按键,K1 用于翻页,K2 用于配网,采用 GPIO 输入;
- 指示灯:绿色 LED1 为运行指示灯,黄色 LED2 为配网指示灯,均为低电平点亮。

3.6 完整接线表
全部硬件的引脚连接关系如表 3-1 所示,接线时应严格按照本表连接,并注意不同模块的供电电压(3.3V 与 5V 不可接反)。
表 3-1 硬件连线表
| 模块 | 模块引脚 | ESP32-S3 引脚 | 供电 | 接口说明 |
|---|---|---|---|---|
| R60ABD1 雷达 | TXD | IO4 | 5V | ESP32 串口1 接收 |
| R60ABD1 雷达 | RXD | IO5 | 5V | ESP32 串口1 发送 |
| 光敏电阻 | AO | IO1 | 3.3V | ADC 采集 |
| OLED 显示屏 | SCL | IO17 | 3.3V | I2C(I2C0)时钟 |
| OLED 显示屏 | SDA | IO18 | 3.3V | I2C(I2C0)数据 |
| V20 语音模块 | TXD | IO6 | 5V | ESP32 串口2 接收 |
| V20 语音模块 | RXD | IO7 | 5V | ESP32 串口2 发送 |
| WS2812 灯带 | DIN | IO11 | 5V | RMT 单线信号 |
| 蜂鸣器 | I/O | IO12 | 5V | 高电平鸣响 |
| LED1(绿色运行灯) | I/O | IO13 | 3.3V | 低电平点亮 |
| LED2(黄色配网灯) | I/O | IO14 | 3.3V | 低电平点亮 |
| K1(翻页键) | I/O | IO15 | 3.3V | 输入 |
| K2(配网键) | I/O | IO16 | 3.3V | 输入 |
| SGP30 | SCL | IO8 | 5V | I2C(I2C1)时钟 |
| SGP30 | SDA | IO9 | 5V | I2C(I2C1)数据 |
需要特别说明两点:
- OLED 与 SGP30 虽然都是 I2C 设备,但工程中分别使用了 I2C0(IO17/IO18) 与 I2C1(IO8/IO9) 两条独立总线,避免了不同电压(3.3V/5V)器件共线的电平问题,也省去了地址冲突的麻烦;
- 工程源码中光敏 ADC 的通道配置(ADC1_CH9)与本表的 IO1 存在差异,复现时如果按照本表把光敏接在 IO1 上,需要在
adc.c中把 ADC 通道改为 IO1 对应的 ADC1_CH0;反之若沿用源码配置,则应将光敏接在 GPIO10 上。本文后续以需求表的 IO1 接线为准进行说明。

电源方面,雷达、灯带、语音模块与蜂鸣器均为 5V 器件,总电流在灯带全亮时可达 1A 以上,使用 5V/2A 电源供电,不要仅依赖电脑 USB 口,否则灯带点亮时可能因压降导致 ESP32 反复重启。
四、ESP32-S3 软件开发环境搭建
4.1 开发环境
ESP32 端采用 VSCode + ESP-IDE(ESP-IDF 插件)5.3 开发。环境搭建步骤如下:
- 从乐鑫官方下载 ESP-IDF v5.3 对应的在线/离线安装包,安装时勾选 VSCode 集成;
- 安装完成后打开 VSCode,左侧出现 Espressif 图标即表示插件安装成功;
- 在插件中选择 ESP-IDF 版本(v5.3.x)、目标芯片(esp32s3)与串口驱动;
- 使用 USB 线连接 ESP32-S3,在 VScode 底部选择对应串口与烧录速度。

4.2 工程结构
打开工程后,目录结构如下:
text
504_esp32_oled_sta_mqtt_wifi/
├── CMakeLists.txt # 顶层 CMake
├── main/
│ ├── CMakeLists.txt # 组件编译配置
│ ├── main.c # 主程序(初始化 + 主循环 + 业务逻辑)
│ └── hardware/ # 全部硬件驱动
│ ├── adc.c/.h # 光敏 ADC
│ ├── beep.c/.h # 蜂鸣器
│ ├── delay.c/.h # 毫秒延时
│ ├── iic_oled.c/.h # OLED 底层与页面
│ ├── oled_fontdata.c # OLED 中文字库
│ ├── key.c/.h # 按键扫描
│ ├── led.c/.h # 指示灯
│ ├── radar_r60abd1.c/.h # 60G 雷达驱动与协议解析
│ ├── sgp30.c/.h # SGP30 驱动
│ ├── sleep_storage.c/.h # 睡眠数据 NVS 持久化
│ ├── voice_uart.c/.h # V20 语音串口
│ ├── wifi_mqtt.c/.h # MQTT 客户端
│ ├── wifi_provision.c/.h # AP 配网与 NVS 凭据
│ ├── wifi_sta.c/.h # WiFi STA 连接
│ └── ws2812.c/.h # WS2812 灯带(RMT)
└── ...

main/CMakeLists.txt 中通过 SRCS 把所有驱动源文件加入编译,新增驱动文件后必须同步登记到此处。
4.3 编译与烧录
点击底部工具栏的"Build"(快捷键一般为 Ctrl+E 再按 B)完成编译,首次编译需要数分钟。编译通过后点击"Flash"烧录,再点击"Monitor"打开串口监视器,波特率使用 115200,即可看到程序打印的日志。
五、60GHz 毫米波雷达驱动与协议解析
雷达是本项目最核心的器件,本章完整讲解其通信协议、初始化流程与字段解析代码,建议读者结合模组官方协议手册一起阅读。
5.1 雷达监测原理
60GHz 调频连续波(FMCW)雷达向人体发射电磁波并接收回波。人在呼吸时胸腔周期性起伏,心跳时心脏搏动引起胸壁的微小振动,这些运动调制在回波相位中。模组内部算法对相位信号进行采样与谱分析,分离出呼吸(约 0.1~0.5Hz)与心跳(约 0.8~2.0Hz)信号,得到呼吸率与心率;同时通过体动事件的统计,结合时间与姿态模型,划分清醒、浅睡、深睡阶段,并输出睡眠评分、离床与翻身统计。整个过程不采集影像、不受光照影响,对人体完全无辐射伤害(发射功率为毫瓦级)。
5.2 串口协议帧格式
雷达与 ESP32 通过 UART 通信,波特率 115200、8 位数据位、无校验、1 位停止位。数据以帧为单位,帧格式如图 11 所示:
text
帧头(2B) 控制字(1B) 命令字(1B) 长度(2B) 数据区(NB) 校验和(1B) 帧尾(2B)
53 59 ctrl cmd lenH lenL data checksum 54 43
- 帧头固定为
0x53 0x59(字符 "SY"),帧尾固定为0x54 0x43(字符 "TC"); - **控制字(ctrl)**标识数据类别,如
0x80人体存在信息、0x81呼吸信息、0x84睡眠信息、0x85心率信息; - **命令字(cmd)**在该类别下进一步区分具体参数;
- 长度为数据区字节数,高字节在前;
- 校验和为帧头之后到数据区末尾所有字节的累加和取低 8 位。
5.3 数据帧与字段映射
模组主动上报的主要数据帧与 DeviceData 字段的映射关系如表 5-1 所示。
表 5-1 雷达数据帧映射表
| 控制字 | 命令字 | 数据位置 | 映射字段 | 含义 |
|---|---|---|---|---|
| 0x80 | 0x01/0x81 | data6 | A3 | 人体存在(1 有人,0 无人) |
| 0x80 | 0x02/0x82 | data6 | A4 | 运动状态(0x02 表示活跃) |
| 0x80 | 0x03/0x83 | data6 | A7 | 体动幅度 |
| 0x80 | 0x04/0x84 | data6...7 | A22 | 人体距离(cm,高字节在前) |
| 0x81 | 0x02/0x82 | data6 | A6 | 实时呼吸(次/分) |
| 0x84 | 0x03/0x83 | data6 | A8 | 清醒时长(分钟) |
| 0x84 | 0x04/0x84 | data6 | A9 | 浅睡时长(分钟) |
| 0x84 | 0x05/0x85 | data6 | A10 | 深睡时长(分钟) |
| 0x84 | 0x06/0x86 | data6 | A12 | 睡眠质量评分 |
| 0x84 | 0x0C/0x8D | data6 | A3 | 综合帧中的人体存在 |
| 0x84 | 0x0C/0x8D | data8 | A16 | 平均呼吸 |
| 0x84 | 0x0C/0x8D | data9 | A17 | 平均心跳 |
| 0x84 | 0x0C/0x8D | data10 | A15 | 翻身次数 |
| 0x84 | 0x0D | data6 | A12 | 整晚报告:评分 |
| 0x84 | 0x0D | data7...8 | A11 | 整晚报告:睡眠总时长 |
| 0x84 | 0x0D | data11 | A13 | 整晚报告:离床时长 |
| 0x84 | 0x0D | data12 | A14 | 整晚报告:离床次数 |
| 0x84 | 0x0D | data13 | A15 | 整晚报告:翻身次数 |
| 0x84 | 0x0D | data14 | A16 | 整晚报告:平均呼吸 |
| 0x84 | 0x0D | data15 | A17 | 整晚报告:平均心跳 |
| 0x85 | 0x02/0x82 | data6 | A5 | 实时心率(次/分) |
5.4 串口初始化与接收任务
雷达使用 UART1,TX 接 IO5(连雷达 RXD)、RX 接 IO4(连雷达 TXD)。初始化代码如下:
c
#define RADAR_UART_NUM UART_NUM_1
#define RADAR_UART_TX_IO 5 // ESP32 TX -> 雷达 RXD
#define RADAR_UART_RX_IO 4 // ESP32 RX <- 雷达 TXD
#define RADAR_UART_BAUD 115200
static void radar_rx_task(void *arg)
{
uint8_t buf[256];
while (1) {
int len = uart_read_bytes(RADAR_UART_NUM, buf, sizeof(buf),
pdMS_TO_TICKS(100));
if (len > 0) {
R60A_data_analysis(buf, len); // 送入协议解析函数
}
}
}
void Radar_Init(void)
{
uart_config_t cfg = {
.baud_rate = RADAR_UART_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
ESP_ERROR_CHECK(uart_param_config(RADAR_UART_NUM, &cfg));
ESP_ERROR_CHECK(uart_set_pin(RADAR_UART_NUM,
RADAR_UART_TX_IO, RADAR_UART_RX_IO,
UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
ESP_ERROR_CHECK(uart_driver_install(RADAR_UART_NUM, 1024 * 2, 0, 0, NULL, 0));
xTaskCreate(radar_rx_task, "radar_rx", 4096, NULL, 5, NULL);
}
5.5 协议解析函数
R60A_data_analysis 先在字节流中查找帧头 0x53 0x59,再按长度字段取出整帧、校验和验证,最后根据控制字与命令字把数据搬运到 DeviceData 的对应字段。解析流程如图 12 所示,核心代码如下(已作简化,保留全部分支结构):
c
void R60A_data_analysis(uint8_t *buf, int len)
{
for (int i = 0; i < len - 1; i++) {
if (buf[i] != 0x53 || buf[i + 1] != 0x59) continue; // 找帧头
uint8_t ctrl = buf[i + 2];
uint8_t cmd = buf[i + 3];
int data_len = (buf[i + 4] << 8) | buf[i + 5]; // 数据区长度
uint8_t *data = &buf[i + 6]; // 数据区指针
// 校验和:ctrl..data 末尾累加取低 8 位
uint8_t sum = 0;
for (int k = 2; k < 6 + data_len; k++) sum += buf[i + k];
if (sum != buf[i + 6 + data_len]) continue; // 校验失败,丢弃
switch (ctrl) {
case 0x80: // 人体存在类
if (cmd == 0x01 || cmd == 0x81) {
DeviceData.A3 = data[6];
if (DeviceData.A3 == 0) { // 无人时清空生命体征
DeviceData.A5 = DeviceData.A6 =
DeviceData.A7 = DeviceData.A22 = 0;
}
}
else if (cmd == 0x02 || cmd == 0x82)
DeviceData.A4 = (data[6] == 0x02) ? 1 : 0;
else if (cmd == 0x03 || cmd == 0x83)
DeviceData.A7 = data[6];
else if (cmd == 0x04 || cmd == 0x84)
DeviceData.A22 = (data[6] << 8) | data[7];
break;
case 0x81: // 呼吸
if (cmd == 0x02 || cmd == 0x82)
DeviceData.A6 = data[6];
break;
case 0x85: // 心率
if (cmd == 0x02 || cmd == 0x82)
DeviceData.A5 = data[6];
break;
case 0x84: // 睡眠信息
if (cmd == 0x03 || cmd == 0x83)
DeviceData.A8 = data[6];
else if (cmd == 0x04 || cmd == 0x84)
DeviceData.A9 = data[6];
else if (cmd == 0x05 || cmd == 0x85)
DeviceData.A10 = data[6];
else if (cmd == 0x06 || cmd == 0x86)
DeviceData.A12 = data[6];
else if (cmd == 0x0C || cmd == 0x8D) { // 实时综合帧
DeviceData.A3 = data[6];
DeviceData.A16 = data[8];
DeviceData.A17 = data[9];
DeviceData.A15 = data[10];
}
else if (cmd == 0x0D) { // 整晚睡眠报告
DeviceData.A12 = data[6];
DeviceData.A11 = (data[7] << 8) | data[8];
DeviceData.A13 = data[11];
DeviceData.A14 = data[12];
DeviceData.A15 = data[13];
DeviceData.A16 = data[14];
DeviceData.A17 = data[15];
}
// 睡眠类字段同步到 NVS 持久化缓存
sleep_storage_update_field_by_cmd(cmd, data);
break;
}
i += 6 + data_len + 2; // 跳过本帧,继续查找下一帧
}
}

5.6 雷达初始化配置与周期查询
雷达上电后,ESP32 需先下发若干配置指令,使其进入睡眠监测场景。radar_configure() 函数依次完成设置睡眠模式、设置场景、查询上报状态等操作,两条关键指令的原始报文如下:
c
// 设置睡眠模式:53 59 84 0F 00 01 01 41 54 43
static const uint8_t CMD_SET_SLEEP_MODE[] = {
0x53,0x59, 0x84,0x0F, 0x00,0x01, 0x01, 0x41, 0x54,0x43
};
// 设置睡眠场景:53 59 08 16 00 01 01 CC 54 43
static const uint8_t CMD_SET_SCENE[] = {
0x53,0x59, 0x08,0x16, 0x00,0x01, 0x01, 0xCC, 0x54,0x43
};
随后启动一个周期查询任务,每 5 秒查询一次人体存在、呼吸与心率,保证在模组未自动上报时数据仍能持续刷新:
c
static void poll_task(void *arg)
{
while (1) {
radar_send_query(0x80, 0x81); // 查询人体存在
radar_send_query(0x81, 0x82); // 查询呼吸
radar_send_query(0x85, 0x82); // 查询心率
vTaskDelay(pdMS_TO_TICKS(5000));
}
}

六、SGP30 空气质量采集
SGP30 使用 I2C1(SDA=IO9、SCL=IO8),与 OLED 的 I2C0 分开。ESP-IDF v5.3 使用新版 driver/i2c_master.h 驱动,初始化代码如下:
c
static i2c_master_bus_handle_t s_bus = NULL;
static i2c_master_dev_handle_t s_dev = NULL;
/* CRC8:多项式 0x31,初值 0xFF */
static uint8_t crc8(const uint8_t *data, int len)
{
uint8_t crc = 0xFF;
for (int i = 0; i < len; i++) {
crc ^= data[i];
for (int b = 0; b < 8; b++)
crc = (crc & 0x80) ? ((crc << 1) ^ 0x31) : (crc << 1);
}
return crc;
}
void SGP30_Init(void)
{
i2c_master_bus_config_t bus_cfg = {
.i2c_port = I2C_NUM_1, // 避开 OLED 的 I2C_NUM_0
.sda_io_num = 9,
.scl_io_num = 8,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = true,
};
ESP_ERROR_CHECK(i2c_new_master_bus(&bus_cfg, &s_bus));
i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x58, // SGP30 7 位地址
.scl_speed_hz = 100000, // SGP30 要求 100kHz
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(s_bus, &dev_cfg, &s_dev));
i2c_master_probe(s_bus, 0x58, 100); // 探测器件
uint8_t rst[2] = {0x00, 0x06}; // 软复位命令
i2c_master_transmit(s_dev, rst, 2, 100);
vTaskDelay(pdMS_TO_TICKS(50));
uint8_t init_cmd[2] = {0x20, 0x03}; // 初始化空气质量测量
i2c_master_transmit(s_dev, init_cmd, 2, 100);
vTaskDelay(pdMS_TO_TICKS(20));
}
SGP30 的测量命令为 0x2008,一次测量返回 6 个字节:CO2 高字节、CO2 低字节、CRC、TVOC 高字节、TVOC 低字节、CRC。主循环中的读取流程为"发命令 → 等待 ≥30ms → 读 6 字节 → CRC 校验 → 拼接结果":
c
uint32_t SGP30_Read(void)
{
uint8_t rx[6] = {0};
i2c_master_receive(s_dev, rx, 6, 100);
// 分别校验两组数据的 CRC
if (crc8(&rx[0], 2) != rx[2] || crc8(&rx[3], 2) != rx[5])
return 0;
uint16_t co2 = (rx[0] << 8) | rx[1];
uint16_t tvoc = (rx[3] << 8) | rx[4];
return ((uint32_t)co2 << 16) | tvoc;
}
/* 主循环中 */
SGP30_Write(0x20, 0x08); // 触发测量
vTaskDelay(pdMS_TO_TICKS(30)); // 等待测量完成
uint32_t sgp30_dat = SGP30_Read(); // 读取结果
CO2Data = (sgp30_dat >> 16) & 0xFFFF; // A1
TVOCData = sgp30_dat & 0xFFFF; // A2

需要注意:SGP30 上电后需要约 15 秒预热,预热期间读数固定为 400ppm/0ppb,属于正常现象;长期使用还需按手册要求进行湿度补偿与基线保存,以保证读数稳定。
关于气体超限报警,需求中规定"气体含量超出安全范围时蜂鸣器报警"。当前固件中蜂鸣器主要承担按键提示与心跳提示,应用侧(Qt/小程序)已实现心率、呼吸异常弹窗。读者如需启用 CO2/TVOC 阈值报警,可在读取结果后增加类似如下判断(具体安全阈值应以室内空气标准与传感器手册标定为准):
c
#define CO2_ALARM_THRESHOLD 2000 // ppm,示例阈值
#define TVOC_ALARM_THRESHOLD 1000 // ppb,示例阈值
if (CO2Data > CO2_ALARM_THRESHOLD || TVOCData > TVOC_ALARM_THRESHOLD) {
BEEP_On();
/* 报警状态上报,由 APP 弹窗提示 */
} else {
BEEP_Off();
}
七、光敏电阻采样与自动起夜灯
7.1 光敏 ADC 驱动
光敏电阻与一个定值电阻组成分压电路,光线越暗,分压点电压越低(具体变化方向取决于模块接法,以所用模块为准)。ESP32 通过 ADC 读取该电压。新版 ESP-IDF 使用 ADC 校准驱动,可直接输出毫伏值:
c
static adc_oneshot_unit_handle_t s_adc_handle;
static adc_cali_handle_t s_cali_handle;
esp_err_t adc_init(void)
{
adc_oneshot_unit_init_cfg_t init_cfg = { .unit_id = ADC_UNIT_1 };
adc_oneshot_new_unit(&init_cfg, &s_adc_handle);
adc_oneshot_chan_cfg_t chan_cfg = {
.atten = ADC_ATTEN_DB_12, // 量程约 0~3.3V
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
// IO1 对应 ADC1 通道 0;如沿用源码 CH9 则接 GPIO10
adc_oneshot_config_channel(s_adc_handle, ADC_CHANNEL_0, &chan_cfg);
adc_cali_line_fitting_config_t cali_cfg = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
adc_cali_create_scheme_line_fitting(&cali_cfg, &s_cali_handle);
return ESP_OK;
}
int adc_read_voltage_mv(void)
{
int raw = 0, mv = 0;
adc_oneshot_read(s_adc_handle, ADC_CHANNEL_0, &raw);
adc_cali_raw_to_voltage(s_cali_handle, raw, &mv);
return mv;
}
7.2 自动起夜灯逻辑
自动起夜灯是本项目的一个实用设计,其触发需要同时满足"环境够暗"与"有人活动"两个条件,避免白天误亮,也避免无人时空耗。相关参数与状态变量如下:
c
#define AUTO_LED_DARK_THRESHOLD 1800 // ADC 电压阈值(mV),低于此值算"暗"
// 根据实际环境调整,建议 1000~2500
#define AUTO_LED_ON_MS 20000 // 起夜灯单次点亮时长:20 秒
static int64_t g_auto_led_start = 0; // 本次点亮的起始时刻(us)
static bool g_auto_led_on = false;
自动灯任务函数在主循环中被周期调用:
c
static void auto_led_task(int light_mv)
{
// 功能总开关:LED_Auto != 1 时强制熄灯并退出
if (LED_Auto != 1) {
if (g_auto_led_on) {
ws2812_clear();
g_auto_led_on = false;
}
return;
}
int64_t now_us = esp_timer_get_time();
if (g_auto_led_on) {
// 已点亮:到 20 秒则熄灭,回到检测状态
int64_t elapsed_ms = (now_us - g_auto_led_start) / 1000;
if (elapsed_ms >= AUTO_LED_ON_MS) {
ws2812_clear();
g_auto_led_on = false;
}
return;
}
// 灯灭状态:环境暗 且 检测到有人 → 点亮白色灯带
bool is_dark = (light_mv < AUTO_LED_DARK_THRESHOLD);
if (is_dark && DeviceData.A3 == 1) {
ws2812_set_all(COLOR_WHITE);
ws2812_refresh();
g_auto_led_on = true;
g_auto_led_start = now_us;
}
}

阈值 1800mV 需要根据实际光敏模块与安装环境标定:可在傍晚把串口监视器打印的 voltage=xxx mV 记录下来,取"开灯/不开灯"临界点附近的电压填入宏定义即可。LED_Auto 开关状态由云端属性同步,用户在 APP 或小程序中关闭该功能后,即使夜间有人,灯带也不会点亮。
八、WS2812 RGB 灯带驱动
WS2812 采用单根信号线 NRZ 编码,对时序要求严格:每位数据的高低电平宽度决定该位是 0 还是 1。ESP32-S3 的 RMT 外设可以精确发射任意波形,是驱动 WS2812 的最佳选择。
工程中使用 10MHz 分辨率(tick=100ns)配置 RMT,并通过自定义编码器把 GRB 字节流编码成 RMT 码元:
c
#define WS2812_LED_NUM 10 // 灯珠数量
#define WS2812_RMT_GPIO 11 // 信号线引脚
/* 10MHz → 1 tick = 100ns */
#define T0H_TICKS 3 // 300ns
#define T0L_TICKS 9 // 900ns
#define T1H_TICKS 9 // 900ns
#define T1L_TICKS 3 // 300ns
#define RST_TICKS 2800 // 复位 280us(实际取更大余量)
static rmt_channel_handle_t s_chan;
static uint8_t s_grb_buf[WS2812_LED_NUM * 3]; // GRB 发送缓存
对外提供一组简洁的 API,内部按 WS2812 的 GRB 字节序组包:
c
void ws2812_set_pixel(int idx, uint8_t r, uint8_t g, uint8_t b)
{
if (idx < 0 || idx >= WS2812_LED_NUM) return;
s_grb_buf[idx * 3 + 0] = g; // WS2812 顺序为 G-R-B
s_grb_buf[idx * 3 + 1] = r;
s_grb_buf[idx * 3 + 2] = b;
}
void ws2812_set_all(uint32_t rgb)
{
uint8_t r = (rgb >> 16) & 0xFF;
uint8_t g = (rgb >> 8) & 0xFF;
uint8_t b = rgb & 0xFF;
for (int i = 0; i < WS2812_LED_NUM; i++)
ws2812_set_pixel(i, r, g, b);
}
void ws2812_refresh(void)
{
rmt_transmit_config_t cfg = { .loop_count = 0 };
rmt_transmit(s_chan, s_encoder, s_grb_buf, sizeof(s_grb_buf), &cfg);
rmt_tx_wait_all_done(s_chan, pdMS_TO_TICKS(100));
}
void ws2812_clear(void)
{
memset(s_grb_buf, 0, sizeof(s_grb_buf));
ws2812_refresh();
}
常用颜色以宏定义给出,例如:
c
#define COLOR_RED 0xFF0000
#define COLOR_GREEN 0x00FF00
#define COLOR_BLUE 0x0000FF
#define COLOR_WHITE 0xFFFFFF
灯带供电务必使用 5V 且电流裕量充足;信号从 DIN 端进入、DOUT 端级联,接反方向灯珠不亮。3.3V 的 IO 电平驱动 5V 灯带时,大多数模块可直接识别,若出现颜色错乱可在信号线串接电阻或加电平转换。
九、海凌科 V20 语音控制
9.1 命令码与串口帧
语音模块使用 UART2(ESP32 TX=IO7 接模块 RXD,ESP32 RX=IO6 接模块 TXD),波特率 115200。识别成功后,模块向 ESP32 发送固定的三字节帧 00 00 <cmd>,命令码定义在 voice_uart.h:
c
typedef enum {
VOICE_CMD_LIGHT_ON_1 = 0x01, // 打开灯
VOICE_CMD_LIGHT_OFF_1 = 0x02, // 关闭灯
VOICE_CMD_LIGHT_ON_2 = 0x03, // 开灯
VOICE_CMD_LIGHT_OFF_2 = 0x04, // 关灯
VOICE_CMD_AUTO_MODE = 0x05, // 自动模式
VOICE_CMD_MANUAL_MODE = 0x06, // 手动模式
VOICE_CMD_COLOR_RED = 0x07, // 红色
VOICE_CMD_COLOR_WHITE = 0x08, // 白色
VOICE_CMD_COLOR_BLUE = 0x09, // 蓝色
VOICE_CMD_COLOR_GREEN = 0x10, // 变颜色(循环切换)
VOICE_CMD_SLEEP_MODE = 0x11, // 睡眠模式
} voice_cmd_t;
typedef void (*voice_callback_t)(voice_cmd_t cmd);
接收任务过滤出帧头 00 00,取第三字节作为命令码并回调:
c
static void voice_rx_task(void *arg)
{
uint8_t buf[32];
while (1) {
int len = uart_read_bytes(UART_NUM_2, buf, sizeof(buf),
pdMS_TO_TICKS(100));
for (int i = 0; i + 2 < len; i++) {
if (buf[i] == 0x00 && buf[i + 1] == 0x00)
s_callback((voice_cmd_t)buf[i + 2]);
}
}
}
9.2 命令处理回调
on_voice_command 是业务处理函数,内部维护一张 12 色表和一个循环索引,"变颜色"命令每触发一次就切换到表中下一个颜色;"睡眠模式"点亮橙黄色烛光色。处理流程如图 17 所示。
c
static void on_voice_command(voice_cmd_t cmd)
{
static const uint32_t color_table[] = {
0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0x00FFFF, 0xFF00FF,
0xFF8000, 0x8000FF, 0xFF007F, 0xFFFFFF, 0xFFCC66, 0xFF9933,
};
static int color_index = 0;
const int color_count = sizeof(color_table) / sizeof(color_table[0]);
switch (cmd) {
case VOICE_CMD_LIGHT_ON_1:
case VOICE_CMD_LIGHT_ON_2: // 开灯 / 照明模式:白色
ws2812_set_all(COLOR_WHITE);
ws2812_refresh();
break;
case VOICE_CMD_LIGHT_OFF_1:
case VOICE_CMD_LIGHT_OFF_2: // 关灯
ws2812_clear();
break;
case VOICE_CMD_COLOR_RED:
ws2812_set_all(0xFF0000);
ws2812_refresh();
break;
case VOICE_CMD_COLOR_WHITE:
ws2812_set_all(COLOR_WHITE);
ws2812_refresh();
break;
case VOICE_CMD_COLOR_BLUE:
ws2812_set_all(0x0000FF);
ws2812_refresh();
break;
case VOICE_CMD_COLOR_GREEN: // 变颜色:循环切换
color_index = (color_index + 1) % color_count;
ws2812_set_all(color_table[color_index]);
ws2812_refresh();
break;
case VOICE_CMD_SLEEP_MODE: // 睡眠模式:橙色暖灯
ws2812_set_all(0xFF9933);
ws2812_refresh();
break;
case VOICE_CMD_AUTO_MODE: // 可扩展:联动 LED_Auto
case VOICE_CMD_MANUAL_MODE:
default:
break;
}
}

词条需要在 V20 模块出厂工具中预先训练并与上述命令码一一对应。由于识别在模块本地完成,即使家中路由器断网,语音开灯等功能仍可正常使用。麦克风安装时应朝向使用者、远离喇叭与蜂鸣器,以降低误触发。
十、OLED 显示界面
OLED 使用 I2C0(SCL=IO17、SDA=IO18),分辨率 128×64。驱动层提供画点、字符串刷新与显存更新接口,中文字符使用取模软件生成的 16×16 字库,存放在 oled_fontdata.c 中,通过索引调用:
c
/* 在 (x,y) 显示一个 16×16 中文,index 为字库索引 */
OLED_ShowChineseFont(x, y, 16, index);
/* 显示 ASCII 字符串 */
OLED_ShowString(x, y, 16, "Connect WIFI....");
/* 将显存刷新到屏幕 */
OLED_RefreshGRAM();
需求文档中定义了 3 个显示页面,实际工程进一步扩展为 5 页,由 K1 循环切换,各页内容如表 10-1,显示效果如图 18 所示。
表 10-1 OLED 页面内容
| 页码 | 页面标题 | 显示内容 |
|---|---|---|
| 1 | 环境数据 | CO2(ppm)、TVOC(ppb)、心率(bpm)、呼吸(bpm) |
| 2 | 人体状态 | 人体感应(有人/无人)、活动状态(静止/活跃)、体动幅度、质量评分 |
| 3 | 睡眠时长 | 清醒时长、浅睡时长、深睡时长、睡眠总时长(分钟) |
| 4 | WiFi 信息 | SSID、密码、连接状态 |
| 5 | 日期时间 | NTP 同步后的年月日、时分秒 |

第一页(环境数据)的绘制代码示意如下:
c
void OLED_Page1_Environment(void)
{
char data_buff[20];
OLED_Clear();
OLED_ShowChineseFont(0, 0, 16, 0); // 二
OLED_ShowChineseFont(16, 0, 16, 1); // 氧
OLED_ShowChineseFont(32, 0, 16, 2); // 化
OLED_ShowChineseFont(48, 0, 16, 3); // 碳
sprintf(data_buff, "%dppm", CO2Data);
OLED_ShowString(80, 0, 16, data_buff);
OLED_ShowString(0, 16, 16, "TVOC");
sprintf(data_buff, "%dppb", TVOCData);
OLED_ShowString(80, 16, 16, data_buff);
sprintf(data_buff, "%dbpm", DeviceData.A5); // 心率
OLED_ShowString(0, 32, 16, data_buff);
sprintf(data_buff, "%dbpm", DeviceData.A6); // 呼吸
OLED_ShowString(80, 48, 16, data_buff);
OLED_RefreshGRAM();
}
第五页显示 NTP 同步后的本地时间;若时间尚未同步,则显示"NTP NOT SYNCED",提醒用户检查网络:
c
void OLED_Page5_DateTime(void)
{
char buf[32];
OLED_Clear();
if (!s_time_synced) {
OLED_ShowString(0, 32, 16, "NTP NOT SYNCED");
OLED_RefreshGRAM();
return;
}
time_t now = time(NULL);
struct tm ti;
localtime_r(&now, &ti);
snprintf(buf, sizeof(buf), "%04d-%02d-%02d",
ti.tm_year + 1900, ti.tm_mon + 1, ti.tm_mday);
OLED_ShowString(0, 16, 16, buf);
snprintf(buf, sizeof(buf), "%02d:%02d:%02d",
ti.tm_hour, ti.tm_min, ti.tm_sec);
OLED_ShowString(0, 32, 16, buf);
OLED_RefreshGRAM();
}
十一、按键、指示灯与网页配网
11.1 按键扫描
两个按键接 IO15(K1 翻页)与 IO16(K2 配网),扫描函数采用简单消抖,返回 0(无键)、1(K1)或 2(K2)。主循环每 100ms 扫描一次:
c
int Key_Scan(void)
{
if (gpio_get_level(GPIO_NUM_15) == 0) { // K1 按下(低有效)
vTaskDelay(pdMS_TO_TICKS(20)); // 消抖
if (gpio_get_level(GPIO_NUM_15) == 0) {
while (gpio_get_level(GPIO_NUM_15) == 0) vTaskDelay(10);
return 1;
}
}
if (gpio_get_level(GPIO_NUM_16) == 0) { // K2 按下
vTaskDelay(pdMS_TO_TICKS(20));
if (gpio_get_level(GPIO_NUM_16) == 0) {
while (gpio_get_level(GPIO_NUM_16) == 0) vTaskDelay(10);
return 2;
}
}
return 0;
}
主循环中,K1 每按一次页面计数加 1(超过 5 回到 1),蜂鸣器短响 100ms 作为反馈:
c
key = Key_Scan();
if (key == 1) {
page_cnt++;
if (page_cnt > 5) page_cnt = 1;
BEEP_On(); delay_ms(100); BEEP_Off();
}
11.2 指示灯
LED1(绿,IO13)为运行指示灯,每次成功上报数据后短闪一次(低电平点亮);LED2(黄,IO14)在未联网/配网期间常亮,连接成功后熄灭:
c
/* 上报成功后绿色运行灯闪 100ms */
mqtt_app_publish(POST_TOPIC, json_buf, 0, 0);
LedOn(1); delay_ms(100); LedOff(1);
11.3 AP + 网页配网流程
配网是物联网设备必须解决的问题。本项目采用"AP 热点 + 内置网页"方案,流程如图 19 所示:
- 设备上电后检查 NVS 是否保存过 WiFi;已保存则直接 STA 连接;
- 未保存时黄色配网灯常亮、OLED 提示等待按键,按下 K2 进入配网模式;
- ESP32 切换为 AP 模式,散发固定热点,同时启动 HTTP 服务器;
- 手机连接该热点,浏览器访问配网页面,填写家用 WiFi 的 SSID 与密码并提交;
- 设备将凭据写入 NVS,随后重启,重启后自动以 STA 模式连接家用路由器。

AP 参数固定如下,进入配网模式时 OLED 同步显示,方便用户操作:
c
#define AP_SSID "ESP32_S3_dev1"
#define AP_PASS "12345678"
#define AP_URL "192.168.4.1"
配网页面由 esp_http_server 提供,根 URI 返回一段 HTML 表单,表单 POST 提交后由处理函数解析参数并写入 NVS:
c
static esp_err_t root_handler(httpd_req_t *req)
{
const char *page =
"<html><body style='font-family:sans-serif;text-align:center'>"
"<h2>ESP32-S3 WiFi 配置</h2>"
"<form method='POST' action='/save'>"
"WiFi名称:<input name='ssid'><br><br>"
"WiFi密码:<input name='pass'><br><br>"
"<button type='submit'>保存并重启</button>"
"</form></body></html>";
httpd_resp_send(req, page, HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}
static esp_err_t save_handler(httpd_req_t *req)
{
char buf[128] = {0};
httpd_req_recv(req, buf, sizeof(buf) - 1); // 接收表单
char ssid[32] = {0}, pass[32] = {0};
httpd_query_key_value(buf, "ssid", ssid, sizeof(ssid));
httpd_query_key_value(buf, "pass", pass, sizeof(pass));
wifi_provision_save(ssid, pass); // 写入 NVS
httpd_resp_sendstr(req, "保存成功,设备重启中...");
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart(); // 重启后自动连接
return ESP_OK;
}

NVS 读写封装在 wifi_provision.c 中,提供保存、读取、判断、清除四个接口。设备正常运行时长按 K2,会先清除 NVS 凭据再重启,从而重新进入上述配网流程:
c
if (key == 2) {
OLED_Clear();
OLED_ShowString(0, 0, 16, "Reset WiFi...");
BEEP_On(); delay_ms(200); BEEP_Off();
wifi_provision_clear();
delay_ms(1000);
esp_restart();
}
十二、MQTT 接入华为云物联网平台
12.1 云端产品与产品模型
首先在华为云物联网设备接入服务(IoTDA,区域选择"华北-北京四"即 cn-north-4)中创建产品与设备:
- 创建产品,协议类型选 MQTT;
- 在产品的"模型定义(Profile)"中新建服务
stm32,并在服务下添加 A1~A17、A22、LED_Auto 全部属性,名称、类型与表 2-1 保持一致; - 注册设备,获取设备 ID 与密钥。本项目设备信息为:
- device_id:
6a548e3b7f2e6c302f84b258_dev1 - 设备密钥:
12345678
- device_id:

12.2 MQTT 连接三元组
华为云要求设备使用经 HMAC-SHA256 计算的密钥连接。MQTT 接入信息如下:
text
MQTT 服务器:5a491307eb.st1.iotda-device.cn-north-4.myhuaweicloud.com
端口:1883
ClientID:6a548e3b7f2e6c302f84b258_dev1_0_0_2026071307
UserName:6a548e3b7f2e6c302f84b258_dev1
PassWord:fe79416134c6fdc308f4476502477ec93f8eeb40fcc47f3f53d2c37a856d4047
主题分为发布(属性上报)与订阅(命令下发)两个:
c
#define SET_TOPIC "$oc/devices/6a548e3b7f2e6c302f84b258_dev1/sys/messages/down" // 订阅
#define POST_TOPIC "$oc/devices/6a548e3b7f2e6c302f84b258_dev1/sys/properties/report" // 发布
12.3 MQTT 客户端实现
固件使用 ESP-IDF 官方 MQTT 组件启动客户端,注册连接、断开、订阅、数据等事件:
c
static esp_mqtt_client_handle_t s_mqtt;
int mqtt_app_start(void)
{
esp_mqtt_client_config_t cfg = {
.broker.address.uri =
"mqtt://5a491307eb.st1.iotda-device.cn-north-4.myhuaweicloud.com:1883",
.credentials.client_id =
"6a548e3b7f2e6c302f84b258_dev1_0_0_2026071307",
.credentials.username = "6a548e3b7f2e6c302f84b258_dev1",
.credentials.authentication.password =
"fe79416134c6fdc308f4476502477ec93f8eeb40fcc47f3f53d2c37a856d4047",
};
s_mqtt = esp_mqtt_client_init(&cfg);
esp_mqtt_client_register_event(s_mqtt, MQTT_EVENT_ANY, mqtt_event_handler, NULL);
esp_mqtt_client_start(s_mqtt);
return 0;
}
static void mqtt_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
switch (id) {
case MQTT_EVENT_CONNECTED: // 连接成功后订阅下行主题
esp_mqtt_client_subscribe(s_mqtt, SET_TOPIC, 1);
break;
case MQTT_EVENT_DATA: // 收到云端下行消息
/* 可在此解析命令,如远程设置 LED_Auto */
break;
default:
break;
}
}
int mqtt_app_publish(const char *topic, const char *data, int qos, int retain)
{
return esp_mqtt_client_publish(s_mqtt, topic, data, 0, qos, retain);
}
12.4 属性上报 JSON
主循环按固定周期(约每秒)把全部属性打包成华为云规定的 JSON 并发布到 POST_TOPIC,报文结构为 services 数组、服务标识 stm32:
c
char json_buf[512];
sprintf(json_buf,
"{\"services\":[{\"service_id\":\"stm32\",\"properties\":{"
"\"A1\":%d,\"A2\":%d,\"A3\":%d,\"A4\":%d,\"A5\":%d,"
"\"A6\":%d,\"A7\":%d,\"A8\":%d,\"A9\":%d,\"A10\":%d,"
"\"A11\":%d,\"A12\":%d,\"A13\":%d,\"A14\":%d,\"A15\":%d,"
"\"A16\":%d,\"A17\":%d,\"A22\":%d,\"LED_Auto\":%d"
"}}]}",
CO2Data, TVOCData,
DeviceData.A3, DeviceData.A4, DeviceData.A5,
DeviceData.A6, DeviceData.A7, DeviceData.A8,
DeviceData.A9, DeviceData.A10, DeviceData.A11,
DeviceData.A12, DeviceData.A13, DeviceData.A14,
DeviceData.A15, DeviceData.A16, DeviceData.A17,
DeviceData.A22, LED_Auto);
mqtt_app_publish(POST_TOPIC, json_buf, 0, 0);
一条实际上报文的示例如下(数据为演示值):
json
{
"services": [
{
"service_id": "stm32",
"properties": {
"A1": 600, "A2": 150, "A3": 1, "A4": 0, "A5": 72,
"A6": 16, "A7": 8, "A8": 35, "A9": 130, "A10": 165,
"A11": 330, "A12": 86, "A13": 6, "A14": 1, "A15": 14,
"A16": 16, "A17": 70, "A22": 120, "LED_Auto": 1
}
}
]
}
12.5 NTP 时间同步与睡眠数据持久化
为了让睡眠统计按"天"组织,设备联网后先进行 NTP 对时。时区设置为东八区,NTP 服务器使用阿里云地址:
c
setenv("TZ", "CST-8", 1);
tzset();
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "203.107.6.88"); // 阿里云 NTP,免 DNS
esp_sntp_setservername(1, "ntp.aliyun.com");
esp_sntp_init();
睡眠统计通过 sleep_storage 模块保存在 NVS 中:开机时先把历史值读回 DeviceData,避免雷达重新从 0 开始;运行时每 60 秒保存一次,避免频繁写 NVS;每次对时后检测日期,跨天则清零并重新统计。
c
sleep_storage_init(g_today); // 按今天日期初始化(内部判断是否跨天)
/* 主循环中:跨天检查 */
if (s_time_synced && g_last_day != today_day) {
if (sleep_storage_check_new_day(today)) {
DeviceData.A8 = DeviceData.A9 = DeviceData.A10 =
DeviceData.A11 = DeviceData.A12 = DeviceData.A13 =
DeviceData.A14 = DeviceData.A15 = DeviceData.A16 =
DeviceData.A17 = 0;
}
g_last_day = today_day;
}
/* 每 60 秒保存一次 */
if (time(NULL) - g_last_save >= 60) {
g_last_save = time(NULL);
sleep_storage_save();
}
这样即使设备夜间意外断电重启,当天已统计的睡眠数据也不会丢失。

十三、Qt 上位机应用
13.1 应用侧工作原理
设备侧使用 MQTT 直连,应用侧(PC/手机)则通过华为云的 HTTPS 应用侧 API 间接访问设备,链路为:
- 调用 IAM 鉴权接口,用华为云账号/子用户换取 X-Subject-Token;
- 携带 Token 调用 IoTDA 的"查询设备影子"接口,获取设备最新上报的全部属性;
- 定时轮询并刷新界面;需要远程控制时,调用设备属性下发接口。
Qt 端使用 QNetworkAccessManager 完成全部网络请求,使用 C++ / Qt5 开发。
13.2 获取 Token
cpp
void Widget::GetToken()
{
QNetworkRequest request;
request.setUrl(QUrl("https://iam.cn-north-4.myhuaweicloud.com/v3/auth/tokens"));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QJsonObject user, domain, password, identity, methods, scope, project, auth;
domain.insert("name", "你的华为云账号名");
user.insert("domain", domain);
user.insert("name", "IAM用户名");
user.insert("password", "IAM用户密码");
identity.insert("methods", QJsonArray{"password"});
identity.insert("password", user);
project.insert("name", "cn-north-4");
scope.insert("project", project);
auth.insert("identity", identity);
auth.insert("scope", scope);
QJsonObject body;
body.insert("auth", auth);
QNetworkReply *reply = manager->post(request, QJsonDocument(body).toJson());
connect(reply, &QNetworkReply::finished, this, [=](){
// Token 在响应头 X-Subject-Token 中
token = reply->rawHeader("X-Subject-Token");
// 保存到本地文件,下次直接复用
});
}
13.3 查询设备影子并解析
cpp
void Widget::GetDeviceShadow()
{
QNetworkRequest request;
request.setUrl(QUrl("https://127f73c01f.st1.iotda-app.cn-north-4.myhuaweicloud.com"
"/v5/iot/ff981a50957a403cb68d906e0d424eed"
"/devices/6a548e3b7f2e6c302f84b258_dev1/shadow"));
request.setRawHeader("X-Auth-Token", token);
QNetworkReply *reply = manager->get(request);
connect(reply, &QNetworkReply::finished, this, &Widget::replyFinished);
}
在 replyFinished 中解析影子 JSON,取出全部属性,变量命名与设备字段一一对应:
cpp
QJsonObject properties = shadow["reported"].toObject()["properties"].toObject();
int A3_human = properties.take("A3").toInt();
int A4_activity = properties.take("A4").toInt();
int A5_heartRate = properties.take("A5").toInt();
int A6_respiration = properties.take("A6").toInt();
int A7_motion = properties.take("A7").toInt();
/* ...A8~A17、A22、LED_Auto 同理... */
int LED_Auto = properties.take("LED_Auto").toInt();
ui->checkBox_LED_Auto->setChecked(LED_Auto);
13.4 异常阈值与界面报警
Qt 端目前实现了两项健康预警:心率超过 120 次/分判为心率异常,呼吸低于 5 次/分判为呼吸异常,对应数值标红并以 10 秒最小间隔弹窗,避免连续打扰:
cpp
int Alarm = 0;
if (A5_heartRate > 120) {
Alarm = 2;
ui->label_A5->setStyleSheet(warningStyle); // 标红高亮
}
if (A6_respiration < 5) {
Alarm = 1;
ui->label_A6->setStyleSheet(warningStyle);
}
if (Alarm == 1)
QMessageBox::information(this, "提示", "【健康预警】呼吸异常");
else if (Alarm == 2)
QMessageBox::information(this, "提示", "【健康预警】心率异常");

界面使用定时器(如每 2 秒)调用一次查询接口,实现数据自动刷新;checkBox_LED_Auto 复选框与云端属性双向绑定,勾选后下发 LED_Auto=1,设备端即允许自动起夜灯工作。Token 默认有效期较长,工程中将其写入本地文件,程序启动时优先读取,失效(401)后再重新获取。
十四、微信小程序应用
14.1 小程序结构
微信小程序使用微信官方开发者工具开发,工程结构如下:
text
miniprogram-4/
├── app.js # 全局逻辑与 globalData(华为云配置)
├── app.json # 页面注册与窗口样式
├── app.wxss # 全局样式
├── pages/
│ ├── index/ # 实时监测主页
│ ├── sleep/ # 睡眠质量页
│ ├── setting/ # 设置页(绑定/解绑/配网)
│ ├── scan/ # 扫码绑定页
│ └── logs/ # 日志页
└── utils/
├── huawei.js # 华为云 API 封装(Token / 影子 / 命令下发)
└── util.js
小程序主色调为青蓝色(#4FB3D9),底部以自定义 TabBar 在"实时监测、睡眠质量、设置"三个页面之间切换。
14.2 全局配置
华为云账号信息集中保存在 app.js 的 globalData 中,并在启动时从本地缓存读回 Token 与设备 ID:
javascript
App({
globalData: {
MAIN_USER: "你的华为云账号名",
IAM_USER: "IAM用户名",
IAM_PASSWORD: "IAM用户密码",
PROJECT_ID: "ff981a50957a403cb68d906e0d424eed",
SERVER_ID: "cn-north-4",
IP_ADDR: "127f73c01f.st1.iotda-app.cn-north-4.myhuaweicloud.com",
service_id: "stm32",
token: "",
deviceId: "",
shadowData: null
},
onLaunch() {
const token = wx.getStorageSync('token');
const deviceId = wx.getStorageSync('deviceId');
if (token) this.globalData.token = token;
if (deviceId) this.globalData.deviceId = deviceId;
}
});
14.3 华为云接口封装
utils/huawei.js 封装了与 Qt 端完全对应的三个能力。获取 Token 时注意:微信会把响应头键名统一转为小写,需要不区分大小写地查找 x-subject-token:
javascript
async function getToken() {
const g = getApp().globalData;
const res = await request({
url: `https://iam.${g.SERVER_ID}.myhuaweicloud.com/v3/auth/tokens`,
method: 'POST',
data: {
auth: {
identity: {
methods: ["password"],
password: {
user: {
domain: { name: g.MAIN_USER },
name: g.IAM_USER,
password: g.IAM_PASSWORD
}
}
},
scope: { project: { name: g.SERVER_ID } }
}
}
});
let token = '';
Object.keys(res.header).forEach(k => {
if (k.toLowerCase() === 'x-subject-token') token = res.header[k];
});
getApp().globalData.token = token;
wx.setStorageSync('token', token);
return token;
}
查询设备影子时携带 X-Auth-Token;如果返回 401(Token 过期),自动重新获取并重试一次:
javascript
async function getDeviceShadow() {
const g = getApp().globalData;
if (!g.token) await getToken();
const url = `https://${g.IP_ADDR}/v5/iot/${g.PROJECT_ID}/devices/${g.deviceId}/shadow`;
let res = await request({
url, method: 'GET',
header: { 'X-Auth-Token': g.token }
});
if (res.statusCode === 401) { // Token 失效,刷新后重试
await getToken();
res = await request({
url, method: 'GET',
header: { 'X-Auth-Token': getApp().globalData.token }
});
}
return res.data;
}
14.4 实时监测主页
主页加载时若未绑定设备,弹窗提示并跳转绑定页;已绑定则查询影子并解析。页面支持手动刷新与 2 秒间隔自动刷新开关:
javascript
async refreshData(silent = false) {
try {
const data = await hw.getDeviceShadow();
this.parseShadow(data);
} catch (err) {
if (!silent) wx.showToast({ title: '刷新失败', icon: 'none' });
}
},
startTimer() {
this._timer = setInterval(() => this.refreshData(true), 2000);
}
parseShadow 从影子的 reported.properties 中提取全部字段,并根据上报时间 event_time 判断设备是否在线(超过 60 秒视为离线)。健康预警阈值与 Qt 端保持一致:
javascript
const p = shadow.reported.properties;
const heartWarn = (p.A5 || 0) > 120;
const respWarn = (p.A6 || 0) < 5;
if (heartWarn) this.showAlarm('【健康预警】心率异常');
else if (respWarn) this.showAlarm('【健康预警】呼吸异常');
let scoreLevel = 'normal';
if ((p.A12 || 0) >= 80) scoreLevel = 'good';
else if ((p.A12 || 0) >= 60) scoreLevel = 'medium';
else scoreLevel = 'bad';
this.setData({
heartRate: p.A5, respiration: p.A6,
humanText: p.A3 === 1 ? '👤 有人' : '😴 无人',
activityText: p.A4 === 1 ? '🏃 活跃' : '🧘 静止',
distance: (p.A22 || 0) + ' cm',
co2: (p.A1 || 0) + 'ppm', tvoc: (p.A2 || 0) + 'ppb',
totalSleep: p.A11, sleepScore: (p.A12 || 0) + '分',
ledAuto: !!p.LED_Auto,
deviceOnline: online
});

14.5 睡眠质量页
睡眠页以卡片形式分四组展示睡眠时长(清醒/浅睡/深睡)、睡眠总时长与评分、离床时长/次数与翻身次数、平均呼吸与平均心跳,评分按 80/60 分档显示不同颜色:
javascript
this.setData({
awakeTime: p.A8, lightSleep: p.A9, deepSleep: p.A10,
totalSleep: p.A11, sleepScore: (p.A12 || 0) + '分',
scoreLevel,
outOfBedTime: p.A13, outOfBedCount: (p.A14 || 0) + '次',
turnOverCount: (p.A15 || 0) + '次',
avgResp: p.A16, avgHeart: p.A17
});

14.6 扫码绑定与设置页
绑定页支持两种方式:调用 wx.scanCode 扫描设备二维码(内容支持纯文本、带 deviceId 参数的 URL、JSON 三种格式),或手动输入设备 ID。绑定成功后将设备 ID 写入本地缓存并清掉旧 Token,下次查询自动重新鉴权:
javascript
onBind() {
const id = (this.data.inputDevId || '').trim();
if (id.length < 5) {
wx.showToast({ title: '请输入有效的设备ID', icon: 'none' });
return;
}
getApp().globalData.deviceId = id;
wx.setStorageSync('deviceId', id);
getApp().globalData.token = '';
wx.removeStorageSync('token');
wx.reLaunch({ url: '/pages/index/index' });
}
设置页提供"扫码绑定新设备、设备配网提示、取消设备绑定"三项操作;解绑时弹窗二次确认后清除缓存的设备 ID。配网操作在页面中给出文字指引(长按设备配网键 → 配网灯亮 → 手机连接热点),与第十一章的硬件配网流程衔接。

14.7 小程序域名配置(重要)
微信正式版小程序要求网络请求地址必须在管理后台配置为合法域名(HTTPS),开发期间可在开发者工具右上角"详情 → 本地设置"中勾选"不校验合法域名"先行调试;发布前需把华为云应用侧域名与 IAM 域名添加到 request 合法域名列表,并完成备案,否则真机上请求会被拦截。

十五、总结
本项目以 ESP32-S3 为主控、以 60GHz 毫米波雷达为核心,完整实现了一台非接触式睡眠监护仪。设备能够在不接触人体、不采集影像的前提下,监测呼吸、心率、清醒/浅睡/深睡时长、睡眠评分、翻身与离床等 17 项参数;同时集成了 SGP30 空气质量检测、"环境光+人体存在"双条件自动起夜灯、离线语音控制、OLED 本地显示与 AP 网页配网等功能,并通过 MQTT 接入华为云,配合 Qt 上位机与微信小程序完成了"端---管---云---应用"全链路的设计。
通过本项目的实践,可以系统地学习到以下工程能力:多外设(双 UART、双 I2C、ADC、RMT、GPIO)的协同驱动、FreeRTOS 多任务划分、串口协议帧的解析方法、NVS 数据持久化、MQTT 云平台接入、应用侧 HTTPS API 调用以及 Qt 与微信小程序两种客户端的开发。这些内容构成了一个完整嵌入式物联网项目的典型知识闭环,适合作为相关专业毕业设计与入门实践的参考。