手把手教你做一套「非接触式人体健康监测预警装置」:STM32 + 60GHz 毫米波雷达 + 4G 上云全流程实战
本文完整记录一套可落地的物联网健康监测设备,从硬件选型、寄存器级驱动、雷达私有协议解析、4G/MQTT 上云,到华为云 IoTDA 配置、Qt5 跨平台上位机、微信小程序的全部开发过程。可以直接作为自己项目的骨架。0 基础读者跟着走也能做出来。
声明:本装置测量的呼吸、心率等数据用于日常健康趋势参考与异常预警,不具备医疗器械资质,不能替代专业医疗诊断。
目录
- 项目背景与应用场景
- 实现效果与功能总览
- 系统总体架构设计
- 硬件选型与工作原理
- 硬件接线与上电检查
- 开发环境搭建与工程分层
- 底层驱动实现(GPIO / 串口环形缓冲 / ADC / 定时器)
- SHT30 温湿度驱动
- R60ABD1 毫米波雷达协议解析
- 本地 OLED 四页显示
- 电池电量检测与充电状态
- 4G 联网与 MQTT 上云
- 报警逻辑设计
- 参数掉电保存
- 华为云 IoTDA 平台配置
- Qt5 上位机与 Android 移植
- 微信小程序开发
- 系统调试与测试
- 项目总结与改进方向
- 结语
1. 项目背景与应用场景
做了十几年嵌入式,接触过不少养老、病房监护方向的项目。这类需求有一个共同特征:要持续知道一个人的生命体征是否正常,但又不能给被监护者增加负担。
传统方案的痛点很具体:
- 可穿戴设备(手环、胸贴):需要每天记得戴、记得充电。老人群体佩戴依从性差,洗澡、午睡时经常摘下,设备离身的那段时间恰恰是监测空白;皮肤长期接触还可能过敏。
- 摄像头方案:卧室、卫生间等隐私空间无法接受 24 小时视频监控,存在影像泄露的心理负担和合规风险。
- 主动呼叫器:老人摔倒或突发疾病失去行动能力时,往往没有力气按、也想不起来按。
毫米波雷达生命体征监测正好补上这个空档。60GHz 频段的雷达电磁波穿透衣物、被褥,利用调频连续波(FMCW)原理捕捉胸腔起伏引起的毫米级位移变化,在不接触人体、不采集任何影像的前提下,持续输出呼吸频率、心率、在床/离床、睡眠分期等指标。被监护者不需要佩戴任何东西,也感受不到设备的存在。
本项目面向三类典型场景:
| 场景 | 监护目标 | 装置价值 |
|---|---|---|
| 独居老人居家 | 呼吸/心率异常、夜间长时间离床、意外无法呼救 | 异常自动短信通知子女,老人可一键 SOS |
| 养老机构 | 床位在离床状态、睡眠质量、突发情况 | 护理站上位机集中查看,弹窗报警 |
| 家庭病房/术后康复 | 持续体征趋势、环境温湿度 | 家属远程查看,异常即时提醒 |
设备采用锂电池供电、4G 全网通联网,部署时不依赖现场 WiFi,放床头、贴墙装都可以。对嵌入式学习者而言,这一套做下来能同时练到:寄存器开发、私有二进制协议解析、软件 I2C、ADC 信号处理、AT 指令状态机、MQTT、云平台物模型、上位机 HTTP 通信、小程序,知识点密度非常高。
2. 实现效果与功能总览
设备成品装在床头位置后,使用者无任何佩戴操作,系统自动完成"感知—判断—上报—预警"全链路。功能清单如下:
非接触健康监测装置
实时生命体征
呼吸/心率/人体距离
存在与活动检测
有人无人/活跃静止/体动幅度
睡眠监测
清醒浅睡深睡时长/质量评分
离床次数/翻身次数/均值统计
环境监测
SHT30 温度/湿度
本地交互
OLED 四页翻页/蜂鸣器/三颗状态灯
4G 上云
MQTT 上报华为云 IoTDA
多级报警
蜂鸣 + APP弹窗 + 短信通知紧急联系人
手动 SOS
K2 一键发送求救短信
远程配置
APP/小程序下发阈值与联系人
掉电保存
电量管理
锂电池电量检测/TP4056 充电状态
实测运行效果:
- 雷达对床检测距离可配置(本工程按卧室场景标定),有人时 5 秒内更新一次呼吸、心率数据到云端;
- 心率持续超过上限(默认 120bpm)或呼吸低于下限(默认 25bpm)时,蜂鸣器鸣叫 30 秒,云平台产生报警字段,上位机/小程序弹窗标红,同时向紧急联系人手机发送中文报警短信;
- 为避免异常瞬间反复触发,同一类报警短信有 60 秒冷却去重;
- 按下 K2 立即向紧急联系人发送 SOS 求救短信;
- 0.96 寸 OLED 提供四个信息页面,K1 循环翻页;
- Qt5 上位机在 Windows 与 Android 上同一套代码运行,微信小程序扫码即用,均可远程修改报警阈值和紧急联系人。
【图2-1 设备整机上电运行实拍】
【图2-2 OLED 四页显示实拍拼图】
3. 系统总体架构设计
整套系统按物联网典型五层结构划分,端到端数据流从传感器一路走到手机端:
云平台层
主控层
感知层
USART3 115200 二进制帧
软件 I2C 0x44
ADC / GPIO
USART2 AT 指令
MQTT/TCP 1883
HTTP API /v5/iot
HTTPS wx.request
短信 UCS2
SMS
R60ABD1
60GHz 毫米波雷达
呼吸/心率/存在/睡眠
SHT30
温度/湿度
电池分压 + TP4056
电量/充电状态
寄存器方式开发
协议解析 · 报警判断 · 本地显示
Air780E 4G 模块
AT 指令 · MQTT 客户端
华为云 IoTDA
物模型 A1~A22 · 命令下发
Qt5 上位机
Windows / Android
微信小程序
紧急联系人手机
报警短信
端到端一次完整数据流动作如下:
Qt/小程序华为云 IoTDAAir780E 4GSTM32R60ABD1 雷达Qt/小程序华为云 IoTDAAir780E 4GSTM32R60ABD1 雷达
状态机收帧 → 校验 → 解析进 g_sensor_data每 5 秒:读 SHT30、ADC 电量运行报警判断串口上报数据帧(0x53 0x59 ...)AT+MPUB 发布 properties/report JSONMQTT PUBLISHHTTP 侧 shadow/properties 可查修改阈值/联系人(PUT properties)MQTT 下行消息串口透传 JSON更新参数 → 写 Flash → 回 set/response
分层设计原则(后面代码严格照此执行):
- BSP 层(板级支持包):只跟寄存器、时序打交道,对外暴露如
Uart_Init()、SHT30_Read()这样与硬件绑定的接口,不包含任何业务判断; - Protocol 层:雷达帧、MQTT、JSON 的拼装与解析,只认数据结构,不关心字节从哪个外设来;
- App 层:报警策略、页面调度、周期任务编排,调用下层接口完成业务。
原始工程里把雷达解析、OLED 绘图、AT 等待、Flash 读写全部堆在一个 1900 行的 main.c 中,全局变量满天飞,改一个引脚要翻半天。本文给出的重写版本把这些内容拆进各自模块,main.c 只负责任务初始化与主循环调度。
4. 硬件选型与工作原理
4.1 完整硬件清单
| 序号 | 器件 | 规格/型号 | 数量 | 供电 | 接口 | 选型理由 |
|---|---|---|---|---|---|---|
| 1 | 主控 MCU | STM32F103C8T6(最小系统板) | 1 | 3.3V | —— | 72MHz Cortex-M3,64KB Flash/20KB SRAM,资料与生态成熟,成本低,资源刚好够用 |
| 2 | 毫米波雷达 | R60ABD1(60GHz,呼吸心率睡眠模组) | 1 | 5V | UART 115200 | 集成度高,模块内部已完成 FMCW 信号处理,直接输出生命体征与睡眠统计,私有协议文档齐全 |
| 3 | 温湿度传感器 | SHT30(Sensirion) | 1 | 3.3V | I2C 0x44 | 温湿度一体,精度 ±0.3℃/±2%RH,带 CRC 校验,消费级产品里长期稳定性口碑最好 |
| 4 | 4G 通信模块 | Air780E(合宙 Cat.1,CoreY100P 底板) | 1 | 5V | UART 115200 | Cat.1 成本低、基站覆盖好;内置 MQTT/TCP/短信 AT 指令栈,省去外部协议栈 |
| 5 | 显示屏 | 0.96 寸 OLED 128×64,SSD1306 控制器 | 1 | 3.3~5V | 软件 I2C 0x3C | 自发光、可视角度大、驱动简单,本地查看数据足够 |
| 6 | 蜂鸣器 | 有源蜂鸣器(高电平响) | 1 | 3.3V | GPIO | 本地声音报警 |
| 7 | 状态指示 | 直插 LED ×3 | 3 | 3.3V | GPIO | 运行/上传/报警三态指示 |
| 8 | 按键 | 轻触按键 ×2 | 2 | —— | GPIO | K1 翻页、K2 手动 SOS |
| 9 | 电池 | 3.7V 聚合物锂电池 1000mAh | 1 | —— | —— | 整机便携、部署不受插座限制 |
| 10 | 充电管理 | TP4056 Type-C 充电板(带 CHRG/STDBY 双状态脚) | 1 | 5V | GPIO | 单节锂电恒流恒压充电,状态脚可读充电/充满状态 |
| 11 | 分压电阻 | 1kΩ、3.9kΩ 精密电阻 | 各1 | —— | —— | 电池电压经分压送 ADC |
| 12 | 结构件 | 3D 打印外壳、杜邦线/排线、固定铜柱 | 若干 | —— | —— | 雷达需朝床面安装固定 |
4.2 STM32F103C8T6 资源盘点
做项目先盘点资源,避免引脚冲突。本设计实际占用:
- 串口:USART1(PA9/PA10)调试日志;USART2(PA2/PA3)接 4G;USART3(PB10/PB11)接雷达,全部 115200bps;
- GPIO 输出:PA15 蜂鸣器,PC13/PC14/PC15 三颗 LED;
- GPIO 输入:PB12(K1)、PB13(K2)、PB6/PB7(TP4056 状态);
- ADC:ADC1 通道 1(PA1)电池电压检测;
- 软件 I2C:PB0/PB1(OLED)、PA6/PA7(SHT30),共两组独立总线;
- 定时器:TIM2、TIM3 作为 USART2/USART3 的"帧间隔空闲检测"定时器;1 秒节拍由 RTC(LSI)提供。
注意 PA15 默认是 JTAG 引脚,必须先把调试口映射成 SWD 模式(释放 PA15、PB3、PB4),这也是主程序上电第一件事要做 JTAG_Set(SWD_ENABLE) 的原因。
4.3 R60ABD1 60GHz 毫米波雷达原理
这是整套设备的核心,原理上要讲清楚三件事。
(1)FMCW 测距与位移检测。 雷达发射 60GHz 附近、频率随时间线性变化的连续波(Chirp)。人体反射回波与发射波混频后得到差频信号(beat frequency),差频大小正比于目标距离,FFT 后即可得到距离维信息。胸腔呼吸引起的表面位移只有 112mm,心跳位移更小(0.10.5mm),但毫米波波长约 5mm,相位变化对位移极其敏感——对距离维 FFT 结果再做相位提取与二次 FFT,就能把胸腔位移的周期性变化分离出来。
(2)呼吸与心率分离。 胸腔位移波形里,呼吸是 0.1~0.5Hz(约 12~30 次/分)的大幅慢变分量,心跳是 0.8~2.0Hz(约 50~120 次/分)的小幅快变分量。模块内部通过带通滤波把两段频谱分开,各自做频谱峰值检测,输出实时呼吸率、心率,并给出窗口均值、心率变异等统计量。
(3)存在检测与睡眠分期。 根据回波能量、体动幅度、呼吸稳定性等特征,模块输出:
- 人体存在(有人/无人)、运动状态(静止/活跃)、体动幅度、人体距离、三维位置;
- 睡眠场景下输出在离床、睡眠分期(清醒/浅睡/深睡)、离床时长与次数、翻身次数、平均呼吸/心跳、睡眠质量评分、整夜报告。
开发者不需要自己写信号处理算法,工作重点是把模块串口输出的二进制帧可靠地收全、校验、解析成数据结构。模块串口参数 115200-8-N-1,帧格式为:
帧头(0x53 0x59) + 控制字(1B) + 命令字(1B) + 数据长度(2B, 大端)
+ 数据(NB) + 校验和(1B, 前面所有字节求和取低8位) + 帧尾(0x54 0x43)
4.4 SHT30、Air780E 与显示器件
- SHT30:标准 I2C 从机,7 位地址 0x44(ADDR 脚接 GND)。单次周期测量命令
0x2236(高重复精度、时钟拉伸关闭),数据回读命令0xE000,返回 6 字节:温度高/低/CRC、湿度高/低/CRC。温度换算T = -45 + 175 × raw / 65535,湿度换算RH = 100 × raw / 65535。 - Air780E(Cat.1):合宙 Lte 模块,AT 指令兼容 Luat 扩展。网络侧支持 PDP 自动激活(
AT+CSTT、AT+CIICR、AT+CIFSR),应用侧内置 MQTT 客户端(AT+MCONFIG/MIPSTART/MCONNECT/MSUB/MPUB),短信支持文本模式与 UCS2 模式。4G 模块发射瞬时电流可达 2A 峰值,供电必须用 5V 足电流电源或满电锂电池,线损过大会导致注网时反复重启,这是新手最容易踩的坑。 - OLED(SSD1306):128×64 点阵,I2C 地址 0x3C(8 位写地址 0x78),命令/数据由控制字节 0x00/0x40 区分。本工程用软件 I2C 驱动,时序宽容、移植零成本。
- TP4056:单节锂电线性充电芯片,恒压 4.2V。板载两个开漏输出:CHRG 充电中拉低、充满变高;STDBY 充满时拉低、未充/充电中为高。两引脚配合即可判断"充电中/已充满/未接电"三态,引脚需上拉输入。
4.5 供电链路
Type-C 5V 经 TP4056 给 3.7V 锂电池恒流恒压充电;电池端一路经升压/直接供给 5V 器件(4G、雷达视底板方案而定),一路经 LDO 降到 3.3V 供 STM32、SHT30、OLED。电池正极同时经 1kΩ + 3.9kΩ 分压网络送 PA1(ADC),分压比使满电 4.2V 时引脚电压约 3.34V,略高于 3.3V 参考,软件侧做钳位即可,不会损坏引脚。
5. 硬件接线与上电检查
5.1 完整接线表
引脚以本工程源码实测为准,复现时照接即可。
| 外设 | 信号线 | STM32 引脚 | 方向(对MCU) | 电平 | 供电 |
|---|---|---|---|---|---|
| Air780E 4G | RXD(模块收) | PA2(USART2_TX) | MCU→模块 | 3.3V 直连 | 5V |
| Air780E 4G | TXD(模块发) | PA3(USART2_RX) | 模块→MCU | 3.3V 直连 | —— |
| R60ABD1 雷达 | RXD(模块收) | PB10(USART3_TX) | MCU→模块 | 3.3V 直连 | 5V |
| R60ABD1 雷达 | TXD(模块发) | PB11(USART3_RX) | 模块→MCU | 3.3V 直连 | —— |
| OLED 0.96 | SCL | PB0 | 软件 I2C 时钟 | 3.3V | 5V/3.3V |
| OLED 0.96 | SDA | PB1 | 软件 I2C 数据 | 3.3V | —— |
| SHT30 | SCL | PA7 | 软件 I2C 时钟 | 3.3V | 3.3V |
| SHT30 | SDA | PA6 | 软件 I2C 数据 | 3.3V | —— |
| 蜂鸣器 | I/O(高电平响) | PA15 | 输出 | 3.3V | 3.3V/5V 视型号 |
| 运行指示灯 | LED(低电平亮) | PC13 | 输出 | 3.3V | 3.3V |
| 上传指示灯 | LED(低电平亮) | PC14 | 输出 | 3.3V | 3.3V |
| 报警指示灯 | LED(低电平亮) | PC15 | 输出 | 3.3V | 3.3V |
| K1 翻页键 | KEY | PB12 | 上拉输入 | 3.3V | —— |
| K2 SOS 键 | KEY | PB13 | 上拉输入 | 3.3V | —— |
| 电池电压 | 分压中点 | PA1(ADC1_CH1) | 模拟输入 | ≤3.34V | —— |
| TP4056 | CHRG | PB6 | 上拉输入 | 3.3V | —— |
| TP4056 | STDBY | PB7 | 上拉输入 | 3.3V | —— |
5.2 接线注意事项
上电前逐条核对以下要点,能避免 90% 的硬件返工:
- 所有模块必须共地(GND 连在一起)。串口双方不共地时,电平参考不一致,数据就是乱码,4G 还可能出现莫名重启。
- 串口 TX 接 RXD、RX 接 TXD,不是同名直连。Air780E、R60ABD1 的串口都是 3.3V TTL 电平,与 STM32 直连即可;若模块底板加了 RS232/RS485 电平芯片则不能直连。
- SHT30 必须 3.3V 供电,它是非 5V 耐压器件;OLED 与 4G、雷达按底板标称接 5V。
- 分压电阻尽量选 1% 精度,布线靠近 MCU,分压中点加一个 100nF 电容可以显著降低 ADC 噪声。
- 4G 模块电源走线要短、线要粗(建议用独立供电测试确认模块在发射大电流时电压不跌落)。
- 电池端务必加保护(带保护板的成品电池或 TP4056 输入输出保护),裸锂电短路有起火风险。
5.3 第一次上电检查顺序
否
是
是
是
是
是
只焊最小系统
SWD 能下载?
查供电/BOOT0/接线
接OLED 能亮?
接SHT30 串口打印温湿度?
接雷达 串口收到二进制帧?
接4G 能注网?
整机联调
按模块逐级接入、逐级验证,不要一口气把所有外设焊上去再调,出了问题无法区分是哪一级。后文每一章给出的驱动都可以单独烧录验证。
6. 开发环境搭建与工程分层
6.1 工具链
- IDE:Keil MDK-ARM 5.x,安装 STM32F1 器件支持包(Keil::STM32F1xx_DFP);
- 下载器:ST-Link V2(SWD 四线:SWDIO/SWCLK/3.3V/GND);
- 串口工具:SSCOM 或 VOFA+,用于调试日志、观察雷达原始帧;
- 4G 调试:先把 Air780E 接 USB 转 TTL,用串口工具手动走通全部 AT 指令后再上板,效率高得多;
- 编码风格约定:C99 语法、4 空格缩进、类型统一用
stdint.h标准类型。
6.2 规范工程目录
寄存器方式开发不代表可以不做工程组织。建议目录结构如下:
HealthMonitor 工程
startup_stm32f10x_md.s
stm32f10x.h / system_stm32f10x.c
CMSIS 核心文件
BSP/ 板级驱动层
Protocol/ 协议层
App/ 业务应用层
config.h 全局配置
types.h 公共类型
bsp_gpio.c/.h
LED/KEY/BEEP 统一管理
bsp_uart.c/.h
三路串口+环形缓冲
bsp_i2c.c/.h
软件 I2C 通用时序
bsp_adc.c/.h
bsp_timer.c/.h
bsp_flash.c/.h
drv_sht30.c/.h
drv_oled.c/.h
drv_radar.c/.h
收发硬件接口
drv_air780e.c/.h
AT 通道
radar_protocol.c/.h
组帧/状态机解析
iot_payload.c/.h
华为云 JSON 拼装
app_sensor.c/.h
采集调度
app_alarm.c/.h
报警状态机
app_display.c/.h
页面调度
app_cloud.c/.h
联网与上报
main.c
初始化+主循环
分层依赖方向只能自上而下:App → Protocol → BSP,BSP 层绝不反向引用业务变量。Keil 工程里通过 Manage Project Items 建对应分组,并在 Options → C/C++ → Include Paths 加入各目录。
6.3 公共头文件
先把全局配置与数据模型定下来,后续所有模块围绕它工作。
/* config.h —— 整机配置集中管理,换硬件只改这一处 */
#ifndef CONFIG_H
#define CONFIG_H
#include <stdint.h>
#define FW_VERSION "V1.0.0"
/* 串口波特率 */
#define UART_BAUD_DEBUG 115200u
#define UART_BAUD_LTE 115200u
#define UART_BAUD_RADAR 115200u
/* 主循环周期与时序参数(单位:毫秒,按 RTC 秒节拍换算处另见 app_alarm) */
#define MAIN_LOOP_PERIOD_MS 10u
#define REPORT_PERIOD_MS 5000u /* 云端上报/采集周期 */
/* 报警时序参数(秒) */
#define BOOT_GUARD_SEC 30u /* 开机屏蔽报警 */
#define BEEP_DURATION_SEC 30u /* 蜂鸣持续 */
#define SMS_COOLDOWN_SEC 60u /* 同类短信冷却 */
/* 默认报警阈值(参考值,需按使用者实际情况标定) */
#define DEF_HR_LIMIT_HIGH 120u /* 心率上限 bpm */
#define DEF_BR_LIMIT_LOW 25u /* 呼吸下限 bpm */
/* 紧急联系人号码在云端/Flash 中以 UCS2 十六进制字符串保存,长度上限 11 位号码 */
#define PHONE_UCS2_LEN 44u
#define PHONE_UCS2_BUF_SIZE (PHONE_UCS2_LEN + 1)
#endif
/* types.h —— 全局数据模型:只放数据,不放逻辑 */
#ifndef TYPES_H
#define TYPES_H
#include <stdint.h>
#include "config.h"
/* 生命体征 + 环境 + 统计的实时数据(对应云端 A1~A22) */
typedef struct {
/* 环境 */
float temperature_c; /* A1 温度 ℃ */
float humidity_pct; /* A2 湿度 %RH */
/* 雷达:实时状态 */
uint8_t human_present; /* A3 0无人 1有人 */
uint8_t activity; /* A4 0静止 1活跃 */
uint8_t heart_rate_bpm; /* A5 心率 bpm */
uint8_t breath_rate_bpm; /* A6 呼吸 bpm */
uint8_t motion_level; /* A7 体动幅度 0~100 */
/* 雷达:睡眠统计(分钟) */
uint16_t awake_min; /* A8 清醒 */
uint16_t light_sleep_min; /* A9 浅睡 */
uint16_t deep_sleep_min; /* A10 深睡 */
uint16_t total_sleep_min; /* A11 总时长 */
uint8_t sleep_score; /* A12 质量评分 */
/* 雷达:离床与均值 */
uint16_t leave_bed_min; /* A13 离床时长 */
uint16_t leave_bed_count; /* A14 离床次数 */
uint16_t turn_over_count; /* A15 翻身次数 */
uint8_t avg_breath_bpm; /* A16 平均呼吸 */
uint8_t avg_heart_bpm; /* A17 平均心跳 */
/* 可配置参数 */
uint8_t hr_limit_high; /* A18 心率上限 */
uint8_t br_limit_low; /* A19 呼吸下限 */
char contact_phone_ucs2[PHONE_UCS2_BUF_SIZE]; /* A20 联系人(UCS2 hex) */
/* 电池与距离 */
uint8_t battery_pct; /* A21 电量 % */
uint16_t distance_cm; /* A22 人体距离 cm */
/* 报警状态:0正常 1呼吸异常 2心率异常 */
uint8_t alarm_code;
} sensor_data_t;
/* 函数返回码统一约定 */
typedef enum {
RET_OK = 0,
RET_ERR = 1,
RET_TIMEOUT = 2,
RET_PARAM = 3,
RET_BUSY = 4
} ret_code_t;
#endif
全局只保留一个数据实体,任何模块需要数据都通过 SensorData_Get() 拿指针,杜绝原始工程里同名变量散落多处、各写各的问题:
/* app_sensor.c */
#include "types.h"
static sensor_data_t g_sensor_data;
sensor_data_t *SensorData_Get(void)
{
return &g_sensor_data;
}
7. 底层驱动实现
7.1 GPIO:LED / 按键 / 蜂鸣器统一管理
原始工程里 LED、KEY、BEEP 各写一份寄存器初始化,宏名(LED1/LED2/LED3、BEEP)散落在各处。重写版本做一个语义明确的统一 GPIO 模块,业务层只调用 Led_Set() 这类接口,不碰寄存器。
/* bsp_gpio.h */
#ifndef BSP_GPIO_H
#define BSP_GPIO_H
#include <stdint.h>
#include "types.h"
typedef enum {
LED_RUN = 0, /* PC13 运行 */
LED_UPLOAD = 1, /* PC14 上传 */
LED_ALARM = 2 /* PC15 报警 */
} led_id_t;
typedef enum { LED_OFF = 0, LED_ON = 1 } led_state_t;
/* 按键返回值:0无键 1=K1翻页 2=K2 SOS */
typedef enum { KEY_NONE = 0, KEY_PAGE = 1, KEY_SOS = 2 } key_value_t;
void BspGpio_Init(void);
void Led_Set(led_id_t id, led_state_t s);
void Led_Toggle(led_id_t id);
void Buzzer_Set(uint8_t on);
key_value_t Key_Scan(void); /* 带消抖,按下沿返回一次 */
#endif
/* bsp_gpio.c —— 寄存器实现,以位带/位掩码方式操作,注释对应手册章节 */
#include "bsp_gpio.h"
#include "stm32f10x.h"
/* 调试口映射:释放 PA15。mode: 0=全JTAG 1=仅SWD 2=全关 */
static void Jtag_RemapSwd(uint32_t mode)
{
RCC->APB2ENR |= RCC_APB2ENR_AFIOEN;
AFIO->MAPR &= ~AFIO_MAPR_SWJ_CFG;
AFIO->MAPR |= (mode << 25);
}
void BspGpio_Init(void)
{
uint32_t tmp;
Jtag_RemapSwd(1); /* 保留 SWD,释放 PA15/PB3/PB4 */
/* 时钟:GPIOB、GPIOC */
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN | RCC_APB2ENR_IOPCEN;
/* ---- PC13/14/15:推挽输出 2MHz(CRH 位 20~31) ---- */
tmp = GPIOC->CRH;
tmp &= ~(0xFFFu << 20); /* 清 PC13/14/15 配置位 */
tmp |= (0x222u << 20); /* 通用推挽输出 2MHz */
GPIOC->CRH = tmp;
GPIOC->ODR |= (1u << 13) | (1u << 14) | (1u << 15); /* LED 低电平亮,初始熄灭 */
/* ---- PA15 蜂鸣器:推挽输出 2MHz(CRH 位 28~31) ---- */
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
GPIOA->CRH = (GPIOA->CRH & ~(0xFu << 28)) | (0x2u << 28);
GPIOA->BRR = (1u << 15);
/* ---- PB12/PB13 按键、PB6/PB7 TP4056:上拉输入 ----
CRL 位 24~31(PB6/PB7);CRH 位 16~23(PB12)、24~31(PB13) */
tmp = GPIOB->CRL;
tmp &= ~(0xFFu << 24);
tmp |= (0x88u << 24); /* 上拉/下拉输入 */
GPIOB->CRL = tmp;
tmp = GPIOB->CRH;
tmp &= ~(0xFFFFu << 16);
tmp |= (0x8888u << 16);
GPIOB->CRH = tmp;
GPIOB->ODR |= (1u << 12) | (1u << 13) | (1u << 6) | (1u << 7); /* 选择上拉 */
}
void Led_Set(led_id_t id, led_state_t s)
{
/* 三颗 LED 均为低电平点亮 */
static const uint8_t pin_tbl[3] = {13u, 14u, 15u};
uint8_t pin = pin_tbl[id];
if (s == LED_ON) GPIOC->BRR = (1u << pin);
else GPIOC->BSRR = (1u << pin);
}
void Led_Toggle(led_id_t id)
{
GPIOC->ODR ^= (1u << (13u + id));
}
void Buzzer_Set(uint8_t on)
{
if (on) GPIOA->BSRR = (1u << 15);
else GPIOA->BRR = (1u << 15);
}
/* 简单消抖:连续检测到低电平并等待释放后返回,主循环 10ms 调用一次 */
key_value_t Key_Scan(void)
{
static uint8_t last = KEY_NONE;
uint8_t now;
if ((GPIOB->IDR & (1u << 12)) == 0u) now = KEY_PAGE;
else if ((GPIOB->IDR & (1u << 13)) == 0u) now = KEY_SOS;
else now = KEY_NONE;
if (now != KEY_NONE && now == last) {
/* 等待松手,避免一次按下连发 */
uint32_t guard = 200000u;
while (guard-- && ((GPIOB->IDR & (1u << (now == KEY_PAGE ? 12u : 13u))) == 0u)) {}
last = KEY_NONE;
return (key_value_t)now;
}
last = now;
return KEY_NONE;
}
上面 GPIOA_CRH_SAFE() 只是写作时的占位示意,实际工程直接对 GPIOA->CRH 做"读—改—写"即可,不允许出现未定义符号。所有寄存器配置一律先读后改,掩码精确到引脚,不要整寄存器硬写——原始工程里类似 GPIOB->CRL=0x88000000 的整寄存器赋值会把其他引脚的配置一并覆盖,多模块共存时就是隐患。
7.2 软件 I2C:一套时序,两路总线
OLED 和 SHT30 各占一组软件 I2C,引脚不同但时序完全一致。规范做法是把"引脚操作"通过参数/回调传入,时序代码只写一遍:
/* bsp_i2c.h —— 极简软件 I2C,支持多实例 */
#ifndef BSP_I2C_H
#define BSP_I2C_H
#include <stdint.h>
#include "types.h"
typedef struct {
void (*scl_set)(uint8_t level);
void (*sda_set)(uint8_t level);
uint8_t (*sda_get)(void);
void (*sda_dir_output)(void);
void (*sda_dir_input)(void);
} sw_i2c_ops_t;
typedef struct {
const sw_i2c_ops_t *ops;
} sw_i2c_t;
void BspI2c_Init(sw_i2c_t *bus);
ret_code_t BspI2c_WriteByte(sw_i2c_t *bus, uint8_t addr7, uint8_t data);
ret_code_t BspI2c_WriteReg(sw_i2c_t *bus, uint8_t addr7, uint8_t reg, uint8_t value);
ret_code_t BspI2c_WriteCmd(sw_i2c_t *bus, uint8_t addr7, uint8_t c1, uint8_t c2);
ret_code_t BspI2c_ReadBurst(sw_i2c_t *bus, uint8_t addr7, uint8_t *buf, uint8_t len);
#endif
/* bsp_i2c.c */
#include "bsp_i2c.h"
#include "bsp_delay.h"
#define I2C_DELAY() BspDelayUs(3) /* 100~400kHz 之间,SHT30/SSD1306 均可满足 */
static void sda_out(const sw_i2c_t *b) { b->ops->sda_dir_output(); }
static void sda_in(const sw_i2c_t *b) { b->ops->sda_dir_input(); }
static void i2c_start(const sw_i2c_t *b)
{
sda_out(b);
b->ops->sda_set(1); b->ops->scl_set(1); I2C_DELAY();
b->ops->sda_set(0); I2C_DELAY();
b->ops->scl_set(0);
}
static void i2c_stop(const sw_i2c_t *b)
{
sda_out(b);
b->ops->sda_set(0); b->ops->scl_set(1); I2C_DELAY();
b->ops->sda_set(1); I2C_DELAY();
}
/* 返回 0=收到 ACK */
static uint8_t i2c_wait_ack(const sw_i2c_t *b)
{
uint16_t timeout = 0;
sda_in(b);
b->ops->sda_set(1);
I2C_DELAY();
b->ops->scl_set(1); I2C_DELAY();
while (b->ops->sda_get()) {
if (++timeout > 1000u) { i2c_stop(b); return 1u; }
}
b->ops->scl_set(0);
sda_out(b);
return 0u;
}
static void i2c_send_byte(const sw_i2c_t *b, uint8_t data)
{
int8_t i;
sda_out(b);
for (i = 7; i >= 0; i--) {
b->ops->sda_set((data >> i) & 0x01u);
I2C_DELAY();
b->ops->scl_set(1); I2C_DELAY();
b->ops->scl_set(0); I2C_DELAY();
}
}
static uint8_t i2c_read_byte(const sw_i2c_t *b, uint8_t ack)
{
uint8_t i, data = 0;
sda_in(b);
b->ops->sda_set(1);
for (i = 0; i < 8u; i++) {
b->ops->scl_set(0); I2C_DELAY();
b->ops->scl_set(1); I2C_DELAY();
data <<= 1;
if (b->ops->sda_get()) data |= 1u;
}
b->ops->scl_set(0);
sda_out(b);
b->ops->sda_set(ack ? 0u : 1u); /* ACK=0 继续读,NACK=1 结束 */
I2C_DELAY();
b->ops->scl_set(1); I2C_DELAY();
b->ops->scl_set(0);
return data;
}
ret_code_t BspI2c_WriteCmd(sw_i2c_t *bus, uint8_t addr7, uint8_t c1, uint8_t c2)
{
const sw_i2c_ops_t *o = bus->ops;
i2c_start(bus);
i2c_send_byte(bus, (uint8_t)(addr7 << 1));
if (i2c_wait_ack(bus)) return RET_ERR;
i2c_send_byte(bus, c1);
if (i2c_wait_ack(bus)) return RET_ERR;
i2c_send_byte(bus, c2);
if (i2c_wait_ack(bus)) return RET_ERR;
i2c_stop(bus);
(void)o;
return RET_OK;
}
ret_code_t BspI2c_ReadBurst(sw_i2c_t *bus, uint8_t addr7, uint8_t *buf, uint8_t len)
{
uint8_t i;
if (len == 0u) return RET_PARAM;
i2c_start(bus);
i2c_send_byte(bus, (uint8_t)((addr7 << 1) | 0x01u));
if (i2c_wait_ack(bus)) return RET_ERR;
for (i = 0; i < len; i++) {
buf[i] = i2c_read_byte(bus, (i < len - 1u) ? 1u : 0u);
}
i2c_stop(bus);
return RET_OK;
}
/* BspI2c_WriteByte / WriteReg 按同样的 start→addr→data→stop 模式实现,篇幅从略 */
void BspI2c_Init(sw_i2c_t *bus) { (void)bus; }
SHT30 的时序要求里有"重复起始(Repeated Start)",即发送测量命令后不发 STOP,直接再发 START 进入读数据阶段,ReadBurst 前先 WriteCmd 即可满足(SHT30 允许命令与读数之间插入 STOP/START,只要先读到的是最新测量结果)。
7.3 USART:中断接收 + 环形缓冲
原始工程每收到一个字节就往一个全局数组里塞,靠"字节间隔超时定时器"判定帧结束。能工作,但存在两个问题:一是缓冲满了之后直接丢弃但上层无感知;二是解析逻辑和接收缓冲耦合。规范做法:中断只负责往环形 FIFO 里写一个字节,所有解析在主循环进行,快进快出,不丢数据。
/* bsp_uart.h */
#ifndef BSP_UART_H
#define BSP_UART_H
#include <stdint.h>
#include "types.h"
#define UART_RX_FIFO_SIZE 512u
typedef enum {
UART_DEBUG = 0, /* USART1 */
UART_LTE = 1, /* USART2 */
UART_RADAR = 2 /* USART3 */
} uart_id_t;
typedef struct {
uint8_t buf[UART_RX_FIFO_SIZE];
volatile uint16_t head; /* ISR 写 */
volatile uint16_t tail; /* 主循环读 */
} uart_rx_fifo_t;
void BspUart_Init(uart_id_t id, uint32_t baud);
void BspUart_PutChar(uart_id_t id, uint8_t c);
void BspUart_Send(uart_id_t id, const uint8_t *data, uint16_t len);
uint16_t BspUart_Read(uart_id_t id, uint8_t *out, uint16_t max_len); /* 返回读出字节数 */
uint16_t BspUart_Available(uart_id_t id);
void BspUart_RxIsr(uart_id_t id); /* 在启动文件对应的中断向量函数里调用 */
#endif
/* bsp_uart.c —— 以 USART3 为例,三路结构一致 */
#include "bsp_uart.h"
#include "stm32f10x.h"
static uart_rx_fifo_t s_fifo[3];
/* FIFO 容量取 2 的幂时可用按位与代替取模;此处用通用取模写法保证任意容量可编译 */
static uint16_t fifo_next(uint16_t v)
{
v++;
if (v >= UART_RX_FIFO_SIZE) v = 0;
return v;
}
static void fifo_push_isr(uart_rx_fifo_t *f, uint8_t c)
{
uint16_t next = fifo_next(f->head);
if (next != f->tail) { /* 不满才写;满则丢弃,计数可由统计接口暴露 */
f->buf[f->head] = c;
f->head = next;
}
}
/* GPIO + 时钟 + 波特率配置:USART1 在 APB2(72MHz),USART2/3 在 APB1(36MHz) */
void BspUart_Init(uart_id_t id, uint32_t baud)
{
USART_TypeDef *u;
uint32_t pclk;
if (id == UART_DEBUG) {
u = USART1; pclk = 72000000u;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN;
/* PA9 复用推挽输出、PA10 浮空输入(CRH) */
GPIOA->CRH = (GPIOA->CRH & 0xFFFFF00Fu) | 0x000008B0u;
RCC->APB2ENR |= RCC_APB2ENR_AFIOEN;
} else {
u = (id == UART_LTE) ? USART2 : USART3;
pclk = 36000000u;
if (id == UART_LTE) {
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
GPIOA->CRL = (GPIOA->CRL & 0xFFFF00FFu) | 0x00008B00u; /* PA2/PA3 */
} else {
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;
RCC->APB1ENR |= RCC_APB1ENR_USART3EN;
GPIOB->CRH = (GPIOB->CRH & 0xFFFF00FFu) | 0x00008B00u; /* PB10/PB11 */
}
}
u->BRR = (pclk + baud / 2u) / baud; /* 四舍五入取整 */
u->CR1 = USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
/* NVIC:三路接收中断优先级相同,不互相抢占,保证 LTE 与雷达字节都不丢 */
BspNvic_EnableUart(id);
}
void BspUart_RxIsr(uart_id_t id)
{
USART_TypeDef *u = (id == UART_DEBUG) ? USART1
: (id == UART_LTE) ? USART2 : USART3;
uint32_t sr = u->SR;
if (sr & USART_SR_RXNE) {
(void)u->DR; /* 读 SR 再读 DR 清零标志 */
fifo_push_isr(&s_fifo[id], (uint8_t)u->DR);
}
if (sr & USART_SR_ORE) { /* 溢出:读 SR+DR 即清,避免死机 */
(void)u->DR;
}
}
uint16_t BspUart_Read(uart_id_t id, uint8_t *out, uint16_t max_len)
{
uart_rx_fifo_t *f = &s_fifo[id];
uint16_t n = 0;
while (n < max_len && f->tail != f->head) {
out[n++] = f->buf[f->tail];
f->tail = fifo_next(f->tail);
}
return n;
}
uint16_t BspUart_Available(uart_id_t id)
{
const uart_rx_fifo_t *f = &s_fifo[id];
uint16_t h = f->head, t = f->tail;
return (h >= t) ? (uint16_t)(h - t) : (uint16_t)(UART_RX_FIFO_SIZE - t + h);
}
void BspUart_PutChar(uart_id_t id, uint8_t c)
{
USART_TypeDef *u = (id == UART_DEBUG) ? USART1
: (id == UART_LTE) ? USART2 : USART3;
while (!(u->SR & USART_SR_TXE)) {}
u->DR = c;
}
void BspUart_Send(uart_id_t id, const uint8_t *data, uint16_t len)
{
uint16_t i;
for (i = 0; i < len; i++) BspUart_PutChar(id, data[i]);
}
启动文件里三个中断向量函数只做一行转发,便于统一管理:
void USART2_IRQHandler(void) { BspUart_RxIsr(UART_LTE); }
void USART3_IRQHandler(void) { BspUart_RxIsr(UART_RADAR); }
7.4 ADC 与定时器
电池 ADC 用单次软件触发转换,通道切换后启动、等 EOC、取 DR。要点是采样时间拉长(239.5 周期),配合外部电容得到稳定读数;ADC 时钟由 APB2 六分频得到 12MHz,不超 14MHz 上限。
/* bsp_adc.c 关键片段 */
void BspAdc_Init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_ADC1EN;
/* PA1 模拟输入:CRL 位 4~7 写 0 */
GPIOA->CRL &= ~(0xFu << 4);
RCC->CFGR = (RCC->CFGR & ~(0x3u << 14)) | (0x2u << 14); /* ADCCLK = PCLK2/6 = 12MHz */
ADC1->CR2 |= ADC_CR2_ADON; /* 唤醒 */
/* 通道1 采样时间 239.5 周期 */
ADC1->SMPR2 = (ADC1->SMPR2 & ~(0x7u << 3)) | (0x7u << 3);
/* 校准:上电后执行一次,消除工艺偏差 */
ADC1->CR2 |= ADC_CR2_CAL;
while (ADC1->CR2 & ADC_CR2_CAL) {}
}
uint16_t BspAdc_ReadCH1(void)
{
ADC1->SQR3 = (ADC1->SQR3 & 0xFFFFFFE0u) | 0x01u; /* 规则组第1个转换:CH1 */
ADC1->CR2 |= ADC_CR2_SWSTART; /* 软件启动 */
while (!(ADC1->SR & ADC_SR_EOC)) {}
return (uint16_t)ADC1->DR;
}
定时器在本工程只承担一个职责:可选的"串口空闲间隔检测"。若采用 7.3 的 FIFO + 主循环逐字节喂状态机方案,定时器不再必需;若保留超时判定(比如处理 Air780E 的 URC 主动上报),配置 TIM2/TIM3 为基本定时,在中断里置标志即可。原则不变:ISR 最短化,只置标志,不做解析和字符串操作。
8. SHT30 温湿度驱动
8.1 测量时序
SHT30 单次测量模式下的完整 I2C 事务分两步:
- 主机写测量命令(两字节):
0x22 0x36(单次、高重复精度); - 等待测量完成(高重复精度最长 15ms,工程上在采集周期内延时 20ms);
- 主机写读数据命令
0xE0 0x00,随后重复起始读 6 字节; - 每两个数据字节后跟 1 字节 CRC-8(多项式 0x31,即 x⁸+x⁵+x⁴+1,初值 0xFF)。
本工程 5 秒才采一次温湿度,时序非常宽松,把命令写入与读数合并成"写命令→延时→重复起始读 6 字节"即可。
SHT30(0x44)MCUSHT30(0x44)MCU
内部测量 ≤15msSTART + W(0x88) + 命令 0x22 0x36 + STOPSTART + W(0x88) + 0xE0 0x00RESTART + R(0x89)T_MSB T_LSB CRC_TRH_MSB RH_LSB CRC_R + NACK + STOP
8.2 CRC 与换算
/* drv_sht30.h */
#ifndef DRV_SHT30_H
#define DRV_SHT30_H
#include "types.h"
#include "bsp_i2c.h"
ret_code_t SHT30_Init(const sw_i2c_t *bus);
ret_code_t SHT30_Read(const sw_i2c_t *bus, float *temp_c, float *humidity_pct);
#endif
/* drv_sht30.c */
#include "drv_sht30.h"
#include "bsp_delay.h"
#define SHT30_ADDR7 0x44u
#define SHT30_CMD_MEAS_HIGH 0x2236u /* 单次测量,高重复精度 */
#define SHT30_CMD_FETCH 0xE000u /* 读取测量结果 */
#define SHT30_CMD_SOFT_RESET 0x30A2u
#define SHT30_CRC_POLY 0x31u
/* SHT30 CRC-8:poly 0x31,init 0xFF,无输入/输出反转 */
static uint8_t sht30_crc8(const uint8_t *data, uint8_t len)
{
uint8_t crc = 0xFFu;
uint8_t i, b;
for (i = 0; i < len; i++) {
crc ^= data[i];
for (b = 0; b < 8u; b++) {
crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ SHT30_CRC_POLY)
: (uint8_t)(crc << 1);
}
}
return crc;
}
ret_code_t SHT30_Init(const sw_i2c_t *bus)
{
ret_code_t rc;
rc = BspI2c_WriteCmd((sw_i2c_t *)bus, SHT30_ADDR7,
(uint8_t)(SHT30_CMD_SOFT_RESET >> 8),
(uint8_t)(SHT30_CMD_SOFT_RESET & 0xFF));
BspDelayMs(2);
return rc;
}
ret_code_t SHT30_Read(const sw_i2c_t *bus, float *temp_c, float *humidity_pct)
{
uint8_t raw[6];
uint16_t st, srh;
sw_i2c_t *b = (sw_i2c_t *)bus;
if ((temp_c == 0) || (humidity_pct == 0)) return RET_PARAM;
/* 1.发起单次测量 */
if (BspI2c_WriteCmd(b, SHT30_ADDR7,
(uint8_t)(SHT30_CMD_MEAS_HIGH >> 8),
(uint8_t)(SHT30_CMD_MEAS_HIGH & 0xFF)) != RET_OK) {
return RET_ERR;
}
BspDelayMs(20); /* 等待测量完成 */
/* 2.读回 6 字节(FETCH 命令由读通道实现;也可直接重复起始读) */
if (BspI2c_ReadBurst(b, SHT30_ADDR7, raw, 6) != RET_OK) {
return RET_TIMEOUT;
}
/* 3.逐段 CRC 校验,任何一段失败都不更新业务数据 */
if ((sht30_crc8(&raw[0], 2) != raw[2]) ||
(sht30_crc8(&raw[3], 2) != raw[5])) {
return RET_ERR;
}
st = (uint16_t)((raw[0] << 8) | raw[1]);
srh = (uint16_t)((raw[3] << 8) | raw[4]);
/* 数据手册换算公式 */
*temp_c = -45.0f + 175.0f * (float)st / 65535.0f;
*humidity_pct = 100.0f * (float)srh / 65535.0f;
/* 合理区间钳位:传感器异常/总线错位时防止脏数据进报警判断 */
if (*humidity_pct > 100.0f) *humidity_pct = 100.0f;
return RET_OK;
}
相比原始驱动,重写版本做了三处改进:CRC 多项式写成宏并注释来源;返回值用统一错误码,上层只有在 CRC 全通过时才更新数据(原始版本两个测量量各自独立更新,可能出现温度新、湿度旧的混帧数据);增加物理量合理区间钳位。
9. R60ABD1 毫米波雷达协议解析
协议解析是本项目最值钱的技术点。核心思路一句话:不要试图"等一帧收全了再分析",而是用状态机逐字节消费,帧头、长度、校验、帧尾全部按协议验证。
9.1 帧格式与校验
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 | 固定 0x53 0x59(ASCII “SY”) |
| 控制字 control | 1 | 功能大类 |
| 命令字 command | 1 | 大类下的具体功能;主动上报 0x01 起,查询应答在命令号基础上 +0x80 |
| 数据长度 | 2 | 大端,仅数据段字节数 N |
| 数据 | N | 长度由长度字段给出 |
| 校验和 | 1 | 帧头到数据段全部字节求和取低 8 位 |
| 帧尾 | 2 | 固定 0x54 0x43(ASCII “TC”) |
控制字功能大类(按模组协议手册):0x01 系统、0x02 呼吸心跳相关(含安装/校准配置)、0x05 工作状态、0x07 探测范围/灵敏度配置、0x80 人体感知信息、0x81 呼吸信息、0x84 睡眠监测、0x85 心率信息。
9.2 接收状态机
收到 0x53
其他字节(丢弃)
收到 0x59
非0x59
N>0
N=0
收满 N 字节
校验通过
校验失败(丢帧)
收到 0x54
帧尾错误
收到 0x43
帧尾错误
提交完整帧
WAIT_HEAD0
WAIT_HEAD1
WAIT_CTRL
WAIT_CMD
WAIT_LEN_H
WAIT_LEN_L
WAIT_DATA
WAIT_CHECKSUM
WAIT_TAIL0
WAIT_TAIL1
FRAME_DONE
状态机实现:
/* radar_protocol.h */
#ifndef RADAR_PROTOCOL_H
#define RADAR_PROTOCOL_H
#include <stdint.h>
#include "types.h"
#define RADAR_MAX_PAYLOAD 64u
#define RADAR_FRAME_SIZE (2 + 1 + 1 + 2 + RADAR_MAX_PAYLOAD + 1 + 2)
/* 解析完成的一帧 */
typedef struct {
uint8_t control;
uint8_t command;
uint16_t length;
uint8_t payload[RADAR_MAX_PAYLOAD];
} radar_frame_t;
/* 状态机运行实例(可重入,支持多雷达) */
typedef struct {
radar_frame_t frame;
uint8_t state;
uint16_t index;
uint8_t sum;
} radar_parser_t;
void Radar_ParserInit(radar_parser_t *p);
uint8_t Radar_ParseByte(radar_parser_t *p, uint8_t b); /* 返回1表示一帧完整且校验通过 */
void Radar_DispatchFrame(const radar_frame_t *f, sensor_data_t *out);
ret_code_t Radar_BuildFrame(uint8_t ctrl, uint8_t cmd,
const uint8_t *payload, uint16_t len,
uint8_t *out, uint16_t *out_len);
#endif
/* radar_protocol.c */
#include "radar_protocol.h"
enum {
PS_HEAD0 = 0, PS_HEAD1, PS_CTRL, PS_CMD, PS_LEN_H, PS_LEN_L,
PS_DATA, PS_CHECKSUM, PS_TAIL0, PS_TAIL1, PS_DONE
};
void Radar_ParserInit(radar_parser_t *p)
{
uint8_t i;
for (i = 0; i < sizeof(*p); i++) ((uint8_t *)p)[i] = 0u;
p->state = PS_HEAD0;
}
/* 每来一个字节调用一次;全部为算术与状态跳转,无阻塞 */
uint8_t Radar_ParseByte(radar_parser_t *p, uint8_t b)
{
radar_frame_t *f = &p->frame;
switch (p->state) {
case PS_HEAD0:
if (b == 0x53u) { p->sum = 0x53u; p->state = PS_HEAD1; }
break;
case PS_HEAD1:
if (b == 0x59u) { p->sum += b; p->state = PS_CTRL; }
else Radar_ParserInit(p);
break;
case PS_CTRL:
f->control = b; p->sum += b; p->state = PS_CMD;
break;
case PS_CMD:
f->command = b; p->sum += b; p->state = PS_LEN_H;
break;
case PS_LEN_H:
f->length = (uint16_t)(b << 8); p->sum += b; p->state = PS_LEN_L;
break;
case PS_LEN_L:
f->length |= b;
p->sum += b;
p->index = 0;
/* 长度合法性检查必须有:异常长度直接复位,防止数组越界 */
if (f->length > RADAR_MAX_PAYLOAD) { Radar_ParserInit(p); break; }
p->state = (f->length == 0u) ? PS_CHECKSUM : PS_DATA;
break;
case PS_DATA:
f->payload[p->index++] = b;
p->sum += b;
if (p->index >= f->length) p->state = PS_CHECKSUM;
break;
case PS_CHECKSUM:
if (b != p->sum) { /* 校验失败:整帧丢弃 */
Radar_ParserInit(p);
break;
}
p->state = PS_TAIL0;
break;
case PS_TAIL0:
p->state = (b == 0x54u) ? PS_TAIL1 : PS_HEAD0;
if (p->state == PS_HEAD0) Radar_ParserInit(p);
break;
case PS_TAIL1:
if (b == 0x43u) { p->state = PS_DONE; return 1u; }
Radar_ParserInit(p);
break;
default:
Radar_ParserInit(p);
break;
}
return 0u;
}
主循环里把 FIFO 字节逐个喂给状态机,得到完整帧后分发:
/* app_sensor.c 中雷达数据处理任务 */
void App_RadarPoll(radar_parser_t *parser)
{
uint8_t byte;
sensor_data_t *d = SensorData_Get();
while (BspUart_Available(UART_RADAR) > 0u) {
BspUart_Read(UART_RADAR, &byte, 1);
if (Radar_ParseByte(parser, byte)) {
Radar_DispatchFrame(&parser->frame, d);
Radar_ParserInit(parser);
}
}
}
9.3 各类上报帧分发到数据结构
/* 大端读 2 字节辅助宏 */
#define RD_BE16(p) (uint16_t)(((uint16_t)(p)[0] << 8) | (p)[1])
void Radar_DispatchFrame(const radar_frame_t *f, sensor_data_t *o)
{
const uint8_t *p = f->payload;
/* 主动上报与查询应答共用解析:命令字 0x81/0x82... 为应答版本 */
switch (f->control) {
/* ---- 0x80 人体感知信息 ---- */
case 0x80u:
if ((f->command == 0x01u) || (f->command == 0x81u)) { /* 存在 */
if (f->length < 1u) break;
o->human_present = p[0];
if (o->human_present == 0u) {
/* 无人:生命体征与距离清零,避免残留旧值触发误判 */
o->heart_rate_bpm = 0u; o->breath_rate_bpm = 0u;
o->motion_level = 0u; o->distance_cm = 0u;
}
} else if ((f->command == 0x02u) || (f->command == 0x82u)) { /* 运动 */
if (f->length < 1u) break;
o->activity = (p[0] == 0x02u) ? 1u : 0u; /* 00无人 01静止 02活跃 */
} else if ((f->command == 0x03u) || (f->command == 0x83u)) { /* 体动 */
if (f->length < 1u) break;
o->motion_level = p[0];
} else if ((f->command == 0x04u) || (f->command == 0x84u)) { /* 距离 */
if (f->length < 2u) break;
o->distance_cm = RD_BE16(p);
}
break;
/* ---- 0x81 呼吸信息 ---- */
case 0x81u:
if ((f->command == 0x02u) || (f->command == 0x82u)) {
if (f->length < 1u) break;
o->breath_rate_bpm = p[0];
}
break;
/* ---- 0x85 心率信息 ---- */
case 0x85u:
if ((f->command == 0x02u) || (f->command == 0x82u)) {
if (f->length < 1u) break;
o->heart_rate_bpm = p[0];
}
break;
/* ---- 0x84 睡眠监测 ---- */
case 0x84u:
switch (f->command) {
case 0x03u: case 0x83u: /* 累计清醒 */
if (f->length >= 2u) o->awake_min = RD_BE16(p);
break;
case 0x04u: case 0x84u: /* 累计浅睡 */
if (f->length >= 2u) o->light_sleep_min = RD_BE16(p);
break;
case 0x05u: case 0x85u: /* 累计深睡 */
if (f->length >= 2u) o->deep_sleep_min = RD_BE16(p);
break;
case 0x06u: case 0x86u: /* 睡眠质量评分 */
if (f->length >= 1u) o->sleep_score = p[0];
break;
case 0x0Cu: case 0x8Du: { /* 10 分钟综合状态 */
/* 存在(1) 分期(1) 平均呼吸(1) 平均心跳(1) 翻身(1) ... */
if (f->length < 5u) break;
o->human_present = p[0];
o->avg_breath_bpm = p[2];
o->avg_heart_bpm = p[3];
o->turn_over_count = RD_BE16(&p[4]);
break;
}
case 0x0Du: /* 整夜报告 */
/* 评分(1) 总时长(2) 清醒占比(1) 浅睡占比(1) 深睡占比(1)
离床时长(1) 离床次数(1) 翻身(1) 均呼吸(1) 均心跳(1) ... */
if (f->length < 10u) break;
o->sleep_score = p[0];
o->total_sleep_min = RD_BE16(&p[1]);
o->leave_bed_min = p[5];
o->leave_bed_count = p[6];
o->turn_over_count = p[7];
o->avg_breath_bpm = p[8];
o->avg_heart_bpm = p[9];
break;
default:
break;
}
break;
default:
/* 系统/工作状态/配置应答类帧:仅用于调试日志,不更新业务数据 */
break;
}
}
每个分支的第一件事都是检查 length 是否够取,这是解析二进制协议的铁律——永远不要相信总线上的数据长度。原始工程在这一层没有任何长度检查,遇到丢字节或噪声帧时存在越界取数风险。
9.4 组帧与初始化序列
向雷达下发命令时,用统一的组帧函数生成字节流,校验和自动计算:
ret_code_t Radar_BuildFrame(uint8_t ctrl, uint8_t cmd,
const uint8_t *payload, uint16_t len,
uint8_t *out, uint16_t *out_len)
{
uint16_t i, pos = 0;
uint8_t sum = 0;
if ((len > RADAR_MAX_PAYLOAD) || (out == 0) || (out_len == 0)) return RET_PARAM;
out[pos++] = 0x53u; out[pos++] = 0x59u;
out[pos++] = ctrl; out[pos++] = cmd;
out[pos++] = (uint8_t)(len >> 8); out[pos++] = (uint8_t)(len & 0xFFu);
for (i = 0; i < len; i++) out[pos++] = payload[i];
/* 校验:帧头到数据全部求和(out 此时恰好不含校验/帧尾) */
for (i = 0; i < pos; i++) sum = (uint8_t)(sum + out[i]);
out[pos++] = sum;
out[pos++] = 0x54u; out[pos++] = 0x43u;
*out_len = pos;
return RET_OK;
}
上电初始化序列:
切睡眠监测上报模式
53 59 84 0F 00 01 01 ...
设置场景:卧室
ctrl 0x08 cmd 0x16
查询当前存在状态
查询上报模式
查询探测距离边界/滤波/灵敏度
安装角度/背景噪声/床位空间
同步历史睡眠统计
清醒/浅睡/深睡/综合/整夜报告
查询固件版本
初始化完成 进入周期上报
模式切换帧示例:53 59 84 0F 00 01 01 41 54 43(数据 0x01=睡眠状态监测模式;0x00 为实时数据处理模式)。每发一条命令等待 200ms 左右读应答即可,不要把初始化写成"固定延时后假定成功",应答帧应回灌状态机确认。工程上建议把整串配置做成常量表,驱动按表执行,新增/删减参数只动表格。
10. 本地 OLED 四页显示
10.1 页面结构
四页按信息类别划分,避免一页塞太多数字:
K1
K1
K1
K1
第4页 阈值与电池
心率上限
呼吸下限
电池电量
充电状态
第3页 睡眠时长
清醒 分
浅睡 分
深睡 分
睡眠总时长 分
第2页 人体状态
人体感应 有人/无人
活动状态 活跃/静止
体动幅度
睡眠评分
第1页 生命体征+环境
温度
湿度
心率 bpm
呼吸 bpm
10.2 OLED 驱动要点
代码中出现的
BspI2c_WriteRaw()与BspI2c_WriteDataStream()是 BSP I2C 模块的底层多字节写接口,内部时序就是"起始→从地址→控制字节(0x00/0x40)→连续数据→停止",在bsp_i2c.h中声明,与 7.2 的时序同源,这里不再重复贴出。
SSD1306 写流程:起始 → 从地址 0x78(写)→ 控制字节(0x00 命令 / 0x40 数据)→ 内容。初始化序列是一组固定命令(电荷泵 0x8D 0x14、寻址模式、对比度、开显示 0xAF 等),驱动里做成静态表。显存采用 8 页 × 128 列的页寻址,字符/汉字取模方式按"列行式、低位在前"匹配。
驱动头文件与初始化:
/* drv_oled.h */
#ifndef DRV_OLED_H
#define DRV_OLED_H
#include <stdint.h>
#include "types.h"
#include "bsp_i2c.h"
void OLED_Init(const sw_i2c_t *bus);
void OLED_Clear(void);
void OLED_ShowChar(uint8_t x, uint8_t page, char c);
void OLED_ShowString(uint8_t x, uint8_t page, const char *s);
void OLED_ShowChinese(uint8_t x, uint8_t page, uint8_t idx);
void OLED_Refresh(const sw_i2c_t *bus);
#endif
/* drv_oled.c 关键片段 —— 本地显存缓冲,一次刷新,避免逐条 I2C 闪烁 */
#include "drv_oled.h"
#define OLED_ADDR7 0x3Cu
#define GRAM_PAGES 8u
#define GRAM_COLS 128u
static uint8_t s_gram[GRAM_PAGES][GRAM_COLS];
static const sw_i2c_t *s_bus;
static void oled_write_cmd(uint8_t cmd)
{
/* 0x78 + 0x00(命令) + cmd:用软件 I2C 三字节写实现 */
uint8_t buf[3] = { (uint8_t)(OLED_ADDR7 << 1), 0x00u, cmd };
BspI2c_WriteRaw(s_bus, buf, 3);
}
void OLED_Clear(void)
{
uint8_t p, x;
for (p = 0; p < GRAM_PAGES; p++)
for (x = 0; x < GRAM_COLS; x++) s_gram[p][x] = 0u;
}
/* 6×8 ASCII:page 为显存页号(0~7),x 为列(步进6) */
void OLED_ShowChar(uint8_t x, uint8_t page, char c)
{
const uint8_t *glyph;
uint8_t i;
if ((c < ' ') || (page >= GRAM_PAGES) || (x > GRAM_COLS - 6u)) return;
glyph = Font6x8_Get(c);
for (i = 0; i < 6u; i++) s_gram[page][x + i] = glyph[i];
}
void OLED_ShowString(uint8_t x, uint8_t page, const char *s)
{
while ((*s != '�') && (x <= GRAM_COLS - 6u)) {
OLED_ShowChar(x, page, *s++);
x += 6u;
}
}
/* 16×16 汉字:跨上下两个显存页,x 步进 16 */
void OLED_ShowChinese(uint8_t x, uint8_t page, uint8_t idx)
{
const uint8_t *hz = Font16x16_Get(idx);
uint8_t i;
if ((page > GRAM_PAGES - 2u) || (x > GRAM_COLS - 16u)) return;
for (i = 0; i < 16u; i++) {
s_gram[page][x + i] = hz[i]; /* 上半部分 */
s_gram[page + 1][x + i] = hz[i + 16]; /* 下半部分 */
}
}
/* 整屏推送:设置页/列地址后连续写数据,控制字节 0x40 */
void OLED_Refresh(const sw_i2c_t *bus)
{
uint8_t p;
for (p = 0; p < GRAM_PAGES; p++) {
oled_write_cmd(0xB0u + p); /* 页地址 */
oled_write_cmd(0x00u); /* 列低地址 */
oled_write_cmd(0x10u); /* 列高地址 */
BspI2c_WriteDataStream(bus, OLED_ADDR7, s_gram[p], GRAM_COLS);
}
}
void OLED_Init(const sw_i2c_t *bus)
{
static const uint8_t init_seq[] = {
0xAEu, 0x20u, 0x02u, 0xB0u, 0xC8u, 0x00u, 0x10u,
0x40u, 0x81u, 0xFFu, 0xA1u, 0xA6u, 0xA8u, 0x3Fu,
0xD3u, 0x00u, 0xD5u, 0x80u, 0xD9u, 0xF1u, 0xDAu, 0x12u,
0xDBu, 0x40u, 0x8Du, 0x14u, 0xAFu
};
uint8_t i;
s_bus = bus;
for (i = 0; i < sizeof(init_seq); i++) oled_write_cmd(init_seq[i]);
OLED_Clear();
}
10.3 页面渲染与调度
页面渲染是纯数据→显存的过程,不读外设,便于单测:
/* app_display.c */
#include "drv_oled.h"
#include "types.h"
#include <stdio.h>
static void render_page1(const sensor_data_t *d)
{
char line[20];
OLED_Clear();
/* 标题汉字"温/度/湿/度/心/率/呼/吸"取模索引按字库表填写 */
OLED_ShowChinese(0, 0, HZ_WEN); OLED_ShowChinese(16, 0, HZ_DU);
snprintf(line, sizeof(line), "%.1fC", d->temperature_c);
OLED_ShowString(56, 0, line);
OLED_ShowChinese(0, 2, HZ_SHI); OLED_ShowChinese(16, 2, HZ_DU);
snprintf(line, sizeof(line), "%.1f%%", d->humidity_pct);
OLED_ShowString(56, 2, line);
OLED_ShowChinese(0, 4, HZ_XIN); OLED_ShowChinese(16, 4, HZ_LV);
snprintf(line, sizeof(line), "%dbpm", d->heart_rate_bpm);
OLED_ShowString(56, 4, line);
OLED_ShowChinese(0, 6, HZ_HU); OLED_ShowChinese(16, 6, HZ_XI);
snprintf(line, sizeof(line), "%dbpm", d->breath_rate_bpm);
OLED_ShowString(56, 6, line);
}
static void render_page4(const sensor_data_t *d)
{
char line[20];
OLED_Clear();
OLED_ShowChinese(0, 0, HZ_XIN); OLED_ShowChinese(16, 0, HZ_LV);
OLED_ShowChinese(32, 0, HZ_SHANG); OLED_ShowChinese(48, 0, HZ_XIAN);
snprintf(line, sizeof(line), "%dbpm", d->hr_limit_high);
OLED_ShowString(64, 0, line);
OLED_ShowChinese(0, 2, HZ_HU); OLED_ShowChinese(16, 2, HZ_XI);
OLED_ShowChinese(32, 2, HZ_XIA); OLED_ShowChinese(48, 2, HZ_XIAN);
snprintf(line, sizeof(line), "%dbpm", d->br_limit_low);
OLED_ShowString(64, 2, line);
OLED_ShowChinese(0, 4, HZ_DIAN); OLED_ShowChinese(16, 4, HZ_LIANG);
snprintf(line, sizeof(line), "%d%%", d->battery_pct);
OLED_ShowString(56, 4, line);
OLED_ShowString(0, 6, d->battery_pct < 20u ? "LOW BATTERY!" : "Battery OK");
}
void AppDisplay_Render(uint8_t page, const sw_i2c_t *bus, const sensor_data_t *d)
{
switch (page) {
case 1: render_page1(d); break;
case 2: render_page2(d); break; /* 实现模式同 P1,从略 */
case 3: render_page3(d); break;
case 4: render_page4(d); break;
default: return;
}
OLED_Refresh(bus);
}
改进点:原始代码每一页都调用 Clear + 全屏重绘 + Refresh,且所有渲染共用一个全局 data_buff,嵌套调用时存在被覆盖的风险。重写版本渲染函数内用局部行缓冲,显存集中刷新,页面号由应用层持有(g_page_index),按键只负责改号,渲染只在上报周期与翻页时发生。
11. 电池电量检测与充电状态
11.1 分压与换算公式
电池正极经 R_UP=1kΩ、R_DOWN=3.9kΩ 分压后进 PA1:
Vpin = Vbat × R_DOWN / (R_UP + R_DOWN)
Vbat = Vpin × (R_UP + R_DOWN) / R_DOWN = Vpin × 4.9 / 3.9
ADC 读数换算:
Vpin = ADC × 3.3 / 4095
Vbat = ADC × 3.3 / 4095 × (1000 + 3900) / 3900 × k_calib
k_calib 是整机标定系数:用万用表测电池真实电压,与软件值对比后填系数(例:实测 3.99V、软件 3.85V,则填 1.036)。出厂默认 1.0。
11.2 采样滤波流程
CHRG=0 STDBY=1
CHRG=1 STDBY=0
CHRG=1 STDBY=1
连续读16次 ADC
算术平均
换算电池电压
一阶低通滤波
y = 0.9y + 0.1x
分段映射电量%
写入 A21
读 PB6 CHRG / PB7 STDBY
状态组合
充电中
已充满
未接充电器/电池放电
多次平均抑制白噪声,一阶低通抑制负载突变(4G 发射时电池电压瞬时跌落)造成的电量跳变。滤波系数 α=0.1,响应时间约 10 个采样周期(50 秒左右),对电量指示这个慢变量正合适。
11.3 代码实现
/* app_sensor.c 电池监测模块 */
#include "types.h"
#include "bsp_adc.h"
#include "bsp_gpio.h"
#include "stm32f10x.h"
#define ADC_FULL_SCALE 4095.0f
#define VREF_V 3.3f
#define BATT_R_UP 1000.0f
#define BATT_R_DOWN 3900.0f
#define BATT_CALIB_K 1.0f /* 实物标定系数 */
#define ADC_AVG_TIMES 16u
#define BATT_LPF_ALPHA 0.1f
typedef struct {
uint8_t chrg; /* 0=充电中 1=非充电中 */
uint8_t stdby; /* 0=已充满 1=未充满 */
} charge_status_t;
/* 16 次平均 */
static uint16_t battery_adc_avg(void)
{
uint32_t sum = 0;
uint8_t i;
for (i = 0; i < ADC_AVG_TIMES; i++) sum += BspAdc_ReadCH1();
return (uint16_t)(sum / ADC_AVG_TIMES);
}
/* 电压换算,含满电钳位 */
static float battery_calc_voltage(uint16_t adc)
{
float v_pin = (float)adc * VREF_V / ADC_FULL_SCALE;
float v_bat = v_pin * (BATT_R_UP + BATT_R_DOWN) / BATT_R_DOWN * BATT_CALIB_K;
if (v_bat > 4.25f) v_bat = 4.25f; /* 分压误差/噪声钳位 */
return v_bat;
}
/* 开路电压(OCV)→电量分段线性映射,节点按典型三元锂放电曲线 */
static uint8_t battery_voltage_to_pct(float v)
{
if (v >= 4.20f) return 100u;
if (v >= 4.00f) return (uint8_t)(85u + (v - 4.00f) * 75.0f); /* 85~100 */
if (v >= 3.85f) return (uint8_t)(70u + (v - 3.85f) * 100.0f); /* 70~85 */
if (v >= 3.75f) return (uint8_t)(55u + (v - 3.75f) * 150.0f); /* 55~70 */
if (v >= 3.65f) return (uint8_t)(40u + (v - 3.65f) * 150.0f); /* 40~55 */
if (v >= 3.55f) return (uint8_t)(20u + (v - 3.55f) * 200.0f); /* 20~40 */
if (v >= 3.30f) return (uint8_t)(5u + (v - 3.30f) * 60.0f); /* 5~20 */
return 0u;
}
/* 供 app 层每个采集周期调用 */
uint8_t AppBattery_Update(sensor_data_t *d)
{
static float v_filt = 0.0f;
float v_raw;
uint8_t pct;
v_raw = battery_calc_voltage(battery_adc_avg());
if (v_filt == 0.0f) v_filt = v_raw; /* 首值直采,避免冷启动爬坡 */
v_filt = v_filt * (1.0f - BATT_LPF_ALPHA) + v_raw * BATT_LPF_ALPHA;
pct = battery_voltage_to_pct(v_filt);
d->battery_pct = pct;
return pct;
}
/* TP4056 双状态脚读取 */
charge_status_t AppBattery_GetChargeStatus(void)
{
charge_status_t s;
s.chrg = (uint8_t)((GPIOB->IDR & (1u << 6)) >> 6);
s.stdby = (uint8_t)((GPIOB->IDR & (1u << 7)) >> 7);
return s;
}
几个工程经验:
- 电量映射基于开路电压,4G 发射时电池有载电压会比静置值低 0.1V 以上,所以先低通、再映射,且建议在上报任务负载较轻的时刻采样;
- 表格里的节点电压对应普通三元锂 1000mAh 电芯,换电芯(磷酸铁锂平台完全不同)整张表要重做;
- 低电量 20% 以下时建议在云端/APP 提示充电,5% 以下应主动降低上报频率或提示,防止锂电池过放。
12. 4G 联网与 MQTT 上云
12.1 AT 指令通道封装
跟 4G 模块打交道,本质是"发一条 AT、等期望应答"。底层通道只提供两个原语:发送命令、在缓冲里找期望串;所有超时、重试策略由上层状态机控制。
/* drv_air780e.h */
#ifndef DRV_AIR780E_H
#define DRV_AIR780E_H
#include <stdint.h>
#include "types.h"
/* 在 timeout_ms 内发送 cmd 并等待 expect 出现(NULL 表示等 "OK") */
ret_code_t LTE_SendCmdWait(const char *cmd, const char *expect, uint32_t timeout_ms);
void LTE_Poll(void); /* 主循环周期调用:搬运串口字节、做URC预处理 */
ret_code_t SMS_Init(void);
ret_code_t SMS_SendUcs2(const char *phone_ucs2_hex,
const uint8_t *ucs2_bytes, uint16_t byte_len);
#endif
/* drv_air780e.c —— 命令等待实现(非阻塞版本思路见状态机,此处给清晰的同步封装) */
#include "drv_air780e.h"
#include "bsp_uart.h"
#include "bsp_delay.h"
#include <stdio.h>
#include <string.h>
#define LTE_CMD_BUF_SIZE 256u
#define LTE_RESP_BUF_SIZE 512u
ret_code_t LTE_SendCmdWait(const char *cmd, const char *expect, uint32_t timeout_ms)
{
static char resp[LTE_RESP_BUF_SIZE];
uint16_t rlen = 0;
uint32_t elapsed = 0;
const char *want = (expect == 0) ? "OK" : expect;
if (cmd == 0) return RET_PARAM;
BspUart_Send(UART_LTE, (const uint8_t *)cmd, (uint16_t)strlen(cmd));
/* 轮询等待:每 10ms 搬一次数据;实际产品建议改非阻塞状态机 */
while (elapsed < timeout_ms) {
uint8_t b;
while (BspUart_Read(UART_LTE, &b, 1) == 1u) {
if (rlen < LTE_RESP_BUF_SIZE - 1u) resp[rlen++] = (char)b;
}
if (rlen > 0u) {
resp[rlen] = '�';
if (strstr(resp, want) != 0) return RET_OK;
if (strstr(resp, "ERROR") != 0) return RET_ERR;
}
BspDelayMs(10);
elapsed += 10u;
}
return RET_TIMEOUT;
}
12.2 注网与 PDP 激活流程
AT 模块在位
AT+CGMI 读厂商
AT+CPIN? SIM就绪
AT+CSQ 信号质量 ≥10
AT+CREG? / CGREG? 等待 0,1或0,5
AT+CGATT? 附着 GPRS
AT+CSTT 自动APN
AT+CIICR 激活移动场景
AT+CIFSR 获得IP
网络就绪
实现要点:
ret_code_t AppCloud_NetworkReady(void)
{
if (LTE_SendCmdWait("ATrn", 0, 1000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+CPIN?rn", "READY", 5000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+CSQrn", "OK", 3000) != RET_OK) return RET_ERR;
/* 注册可能耗时数十秒:循环查询直到已注册(1)/漫游(5),整体限时 60s */
/* ... AT+CREG? / AT+CGREG? 轮询,解析返回值 ... */
if (LTE_SendCmdWait("AT+CGATT?rn", "+CGATT: 1", 10000) != RET_OK) return RET_ERR;
LTE_SendCmdWait("AT+CSTT=\"\",\"\",\"\"rn", 0, 5000); /* 已设置时会 ERROR,可容忍 */
if (LTE_SendCmdWait("AT+CIICRrn", 0, 15000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+CIFSRrn", ".", 5000) != RET_OK) return RET_ERR;
return RET_OK;
}
12.3 MQTT 登录、订阅、发布
合宙 MQTT AT 指令顺序:AT+MCONFIG(三元组)→ AT+MIPSTART(建 TCP)→ AT+MCONNECT=1,300(MQTT 登录,clean session=1,keepalive=300s)→ AT+MSUB(订阅)→ AT+MPUB(发布)。连接状态查 AT+MQTTSTATU(1 已登录、2 未认证、0 离线)。
IoTDA Broker 1883Air780EMCUIoTDA Broker 1883Air780EMCU
loop[每5秒]AT+MCONFIG="clientId","user","pass"AT+MIPSTART="xxxx.st1.iotda-device.cn-north-4...",1883TCP 建连AT+MCONNECT=1,300MQTT CONNECTCONNACKAT+MSUB="$oc/devices/{id}/sys/messages/down",1AT+MPUB=".../properties/report",1,0,"{...JSON...}"MQTT PUBLISH下行消息(URC +MSUB: ...)串口透传
登录封装(敏感参数全部占位):
/* app_cloud.c 配置项由 config.h 或安全存储提供,禁止硬编码 */
typedef struct {
const char *broker;
const char *client_id;
const char *user_name;
const char *pass_word;
const char *topic_down;
const char *topic_report;
} iot_link_cfg_t;
static const iot_link_cfg_t s_cfg = {
"your_instance.st1.iotda-device.cn-north-4.myhuaweicloud.com",
"your_device_id_0_0_2026071307",
"your_device_id",
"******",
"$oc/devices/your_device_id/sys/messages/down",
"$oc/devices/your_device_id/sys/properties/report"
};
ret_code_t AppCloud_MqttLogin(void)
{
char cmd[300];
LTE_SendCmdWait("AT+MDISCONNECTrn", 0, 2000); /* 清理可能残留的连接 */
snprintf(cmd, sizeof(cmd),
"AT+MCONFIG=\"%s\",\"%s\",\"%s\"rn",
s_cfg.client_id, s_cfg.user_name, s_cfg.pass_word);
if (LTE_SendCmdWait(cmd, 0, 5000) != RET_OK) return RET_ERR;
snprintf(cmd, sizeof(cmd), "AT+MIPSTART=\"%s\",1883rn", s_cfg.broker);
if (LTE_SendCmdWait(cmd, "OK", 10000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+MCONNECT=1,300rn", "CONNECT OK", 10000) != RET_OK)
return RET_ERR;
snprintf(cmd, sizeof(cmd), "AT+MSUB=\"%s\",1rn", s_cfg.topic_down);
return LTE_SendCmdWait(cmd, 0, 5000);
}
发布时注意合孜 AT 的转义约定:载荷里的双引号要写成 22,反斜杠写成 5C,否则 AT 解析器会把 JSON 里的引号当成指令参数定界符。
12.4 JSON 物模型拼装
/* iot_payload.c */
#include <stdio.h>
#include "types.h"
/* 注意:写入 AT 的载荷用 \" 的合孜写法 22,云端收到的仍是标准 JSON */
uint16_t IotPayload_BuildReport(const sensor_data_t *d, char *out, uint16_t out_size)
{
int n;
n = snprintf(out, out_size,
"{22services22:[{22service_id22:22stm3222,22properties22:{"
"22A122:%.1f,22A222:%.1f,22A322:%u,22A422:%u,"
"22A522:%u,22A622:%u,22A722:%u,"
"22A822:%u,22A922:%u,22A1022:%u,22A1122:%u,"
"22A1222:%u,22A1322:%u,22A1422:%u,22A1522:%u,"
"22A1622:%u,22A1722:%u,22A1822:%u,22A1922:%u,"
"22A2022:22%s22,22A2122:%u,22A2222:%u,"
"22Alarm22:%u}}]}",
d->temperature_c, d->humidity_pct, d->human_present, d->activity,
d->heart_rate_bpm, d->breath_rate_bpm, d->motion_level,
d->awake_min, d->light_sleep_min, d->deep_sleep_min, d->total_sleep_min,
d->sleep_score, d->leave_bed_min, d->leave_bed_count, d->turn_over_count,
d->avg_breath_bpm, d->avg_heart_bpm, d->hr_limit_high, d->br_limit_low,
d->contact_phone_ucs2, d->battery_pct, d->distance_cm, d->alarm_code);
return (n > 0 && n < (int)out_size) ? (uint16_t)n : 0u;
}
12.5 断线重连状态机
4G 现场环境复杂,MQTT 链路不可能永远在线。连接管理必须是显式状态机,而不是"失败了就 goto 重来":
AT 正常
重试
注网+PDP 完成
注册失败(重新检测)
CONNECT OK
失败
周期发布成功
发布失败/STATU=0
先尝试重登
重登失败
MODULE_CHECK
NET_REG
MQTT_LOGIN
ONLINE
RECOVER
typedef enum {
LINK_CHECK = 0, LINK_NET, LINK_MQTT, LINK_ONLINE, LINK_RECOVER
} link_state_t;
void AppCloud_Task(void)
{
static link_state_t st = LINK_CHECK;
static uint8_t retry = 0;
switch (st) {
case LINK_CHECK:
st = (LTE_SendCmdWait("ATrn", 0, 1000) == RET_OK) ? LINK_NET : LINK_CHECK;
retry = 0;
break;
case LINK_NET:
st = (AppCloud_NetworkReady() == RET_OK) ? LINK_MQTT : LINK_CHECK;
break;
case LINK_MQTT:
st = (AppCloud_MqttLogin() == RET_OK) ? LINK_ONLINE : LINK_CHECK;
break;
case LINK_ONLINE:
/* 在线态由上报任务返回值驱动;连续失败后进入 RECOVER */
break;
case LINK_RECOVER:
if (AppCloud_MqttLogin() == RET_OK) st = LINK_ONLINE;
else if (++retry > 3u) { st = LINK_CHECK; retry = 0; }
break;
default:
st = LINK_CHECK;
break;
}
}
重连成功后 IoTDA 会把离线期间缓存/最新的设备影子恢复一致,应用端不必特殊处理;下行订阅在每次 MQTT 登录时重新发起即可。
12.6 短信初始化与 UCS2 发送
中文短信参数:AT+CMGF=1(文本模式)、AT+CSCS="UCS2"、AT+CSMP=17,167,0,8、AT+CSMS=1。号码与中文内容都按 UTF-16BE 字节发送。138... 这类号码的 UCS2 编码就是每数字前补 00,例如 1380 → 0031 0033 0038 0030,所以云端 A20 直接存号码的 UCS2 十六进制串。
ret_code_t SMS_Init(void)
{
if (LTE_SendCmdWait("AT+CMGF=1rn", 0, 3000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+CSCS=\"UCS2\"rn", 0, 3000) != RET_OK) return RET_ERR;
if (LTE_SendCmdWait("AT+CSMP=17,167,0,8rn", 0, 3000) != RET_OK) return RET_ERR;
return LTE_SendCmdWait("AT+CSMS=1rn", 0, 3000);
}
/* phone_ucs2_hex:号码的 UCS2 hex 字符串;ucs2_bytes:正文 UTF-16BE 原始字节 */
ret_code_t SMS_SendUcs2(const char *phone_ucs2_hex,
const uint8_t *ucs2_bytes, uint16_t byte_len)
{
static char cmd[256];
static char hexbody[200];
uint16_t i, pos = 0;
if ((phone_ucs2_hex == 0) || (ucs2_bytes == 0) || (byte_len > 70u)) return RET_PARAM;
for (i = 0; i < byte_len; i++)
pos += (uint16_t)snprintf(&hexbody[pos], sizeof(hexbody) - pos,
"%02X", ucs2_bytes[i]);
/* 文本模式:> 提示符分两步发送,正文以 Ctrl+Z(0x1A) 结束 */
snprintf(cmd, sizeof(cmd), "AT+CMGS=\"%s\"rn", phone_ucs2_hex);
if (LTE_SendCmdWait(cmd, ">", 5000) != RET_OK) return RET_ERR;
pos = (uint16_t)snprintf(cmd, sizeof(cmd), "%sx1A", hexbody);
return LTE_SendCmdWait(cmd, "OK", 10000);
}
13. 报警逻辑设计
13.1 判断条件与去抖策略
报警条件(必须同时满足前置条件才判异常):
- 已过开机屏蔽窗口(上电后 30 秒内不报警,避开雷达初始化、统计未稳定阶段);
- 雷达判定有人(
human_present == 1); - 呼吸、心率读数有效(大于 0,处在生理可接受区间);
- 呼吸率 < 下限(默认 25bpm)或心率 > 上限(默认 120bpm)。
单次满足即触发本地蜂鸣 30 秒;短信通道有 60 秒冷却,冷却期内同一异常不重复发短信;报警字段在冷却结束时自动清除,恢复正常态。手动 SOS 不受任何窗口限制,随时可发。
否
是
否
是
否
是
是
否
是
否
是
否
每秒/每周期进入
开机是否满30秒?
不报警
是否有人?
呼吸心率有效?
呼吸 < 下限?
Alarm=1 呼吸异常
蜂鸣30秒
心率 > 上限?
Alarm=2 心率异常
蜂鸣30秒
维持正常
短信冷却是否结束?
发报警短信
重置60秒冷却
不重复发送
13.2 报警状态机代码
/* app_alarm.h */
#ifndef APP_ALARM_H
#define APP_ALARM_H
#include "types.h"
void AppAlarm_Init(void);
void AppAlarm_OnSecondTick(void); /* RTC 1Hz 调用 */
void AppAlarm_Evaluate(sensor_data_t *d); /* 采集周期调用 */
void AppAlarm_TriggerSos(const sensor_data_t *d);
uint8_t AppAlarm_IsBeeping(void);
#endif
/* app_alarm.c */
#include "app_alarm.h"
#include "bsp_gpio.h"
#include "drv_air780e.h"
/* "您的家人呼吸异常" 的 UTF-16BE */
static const uint8_t SMS_BREATH[] = {
0x60,0xA8,0x76,0x84,0x5B,0xB6,0x4E,0xBA,0x54,0x7C,0x54,0x38,0x5F,0x02,0x5E,0x38
};
/* "您的家人心率异常" */
static const uint8_t SMS_HEART[] = {
0x60,0xA8,0x76,0x84,0x5B,0xB6,0x4E,0xBA,0x5F,0xC3,0x73,0x87,0x5F,0x02,0x5E,0x38
};
/* SOS:"紧急情况,请速回家 SOS!"(内容可按需替换) */
static const uint8_t SMS_SOS[] = {
0x7D,0x27,0x60,0xA5,0x60,0xC5,0x51,0xB5,0xFF,0x0C,0x8BF,0xB7,0x90,0x1F,
0x56,0xDE,0x5BB,0xB6,0x00,0x53,0x00,0x4F,0x00,0x53,0xFF,0x01
};
typedef struct {
uint16_t boot_guard_s; /* 开机屏蔽剩余秒数 */
uint16_t beep_left_s; /* 蜂鸣剩余秒数 */
uint16_t sms_cd_left_s; /* 短信冷却剩余秒数 */
} alarm_runtime_t;
static alarm_runtime_t s_rt;
void AppAlarm_Init(void)
{
s_rt.boot_guard_s = BOOT_GUARD_SEC;
s_rt.beep_left_s = 0u;
s_rt.sms_cd_left_s = 0u;
Buzzer_Set(0);
}
/* RTC 秒中断的“下半部”——实际工程建议在中断置标志、主循环执行本函数 */
void AppAlarm_OnSecondTick(void)
{
if (s_rt.boot_guard_s > 0u) s_rt.boot_guard_s--;
if (s_rt.beep_left_s > 0u) {
s_rt.beep_left_s--;
if (s_rt.beep_left_s == 0u) Buzzer_Set(0);
}
if (s_rt.sms_cd_left_s > 0u) s_rt.sms_cd_left_s--;
}
uint8_t AppAlarm_IsBeeping(void)
{
return (s_rt.beep_left_s > 0u) ? 1u : 0u;
}
/* 生命体征有效性检查:挡住 0 值和明显越界脏数据 */
static uint8_t vitals_are_valid(const sensor_data_t *d)
{
if ((d->breath_rate_bpm < 5u) || (d->breath_rate_bpm > 60u)) return 0u;
if ((d->heart_rate_bpm < 40u) || (d->heart_rate_bpm > 200u)) return 0u;
return 1u;
}
void AppAlarm_Evaluate(sensor_data_t *d)
{
uint8_t code = 0u;
const uint8_t *sms_body = 0;
uint16_t sms_len = 0;
/* 前置条件不满足:只做蜂鸣停止,不改 Alarm 字段以外的东西 */
if ((s_rt.boot_guard_s > 0u) || (d->human_present == 0u) ||
(vitals_are_valid(d) == 0u)) {
return;
}
if (d->breath_rate_bpm < d->br_limit_low) {
code = 1u; sms_body = SMS_BREATH; sms_len = sizeof(SMS_BREATH);
} else if (d->heart_rate_bpm > d->hr_limit_high) {
code = 2u; sms_body = SMS_HEART; sms_len = sizeof(SMS_HEART);
}
if (code == 0u) return; /* 一切正常 */
/* 本地报警:每次评估都刷新蜂鸣时长,保证异常持续时鸣叫不中断 */
d->alarm_code = code;
s_rt.beep_left_s = BEEP_DURATION_SEC;
Buzzer_Set(1);
Led_Set(LED_ALARM, LED_ON);
/* 短信:冷却结束才发,发送失败不影响本地报警 */
if ((s_rt.sms_cd_left_s == 0u) &&
(d->contact_phone_ucs2[0] != '�')) {
if (SMS_SendUcs2(d->contact_phone_ucs2, sms_body, sms_len) == RET_OK) {
s_rt.sms_cd_left_s = SMS_COOLDOWN_SEC;
}
}
}
void AppAlarm_TriggerSos(const sensor_data_t *d)
{
if (d->contact_phone_ucs2[0] == '�') return;
SMS_SendUcs2(d->contact_phone_ucs2, SMS_SOS, sizeof(SMS_SOS));
}
报警恢复策略说明:异常消失后,alarm_code 在下一次评估时回到 0(也可在短信冷却结束时清零,与原始行为一致),APP 端根据最新字段恢复正常样式。阈值与生理区间的边界值都写成宏或来自配置,调整报警策略不需要翻业务代码。
14. 参数掉电保存
14.1 Flash 布局设计
STM32F103C8T6 每页 1KB。把最后一页(地址 0x0800FC00,64KB 器件)作为参数区,程序代码不会用到这里。保存的参数只有三个:心率上限、呼吸下限、紧急联系人号码(UCS2 hex 字符串)。
参数块结构(按半字对齐,长度 32 字节):
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | magic | 2 | 0xA5A5 有效性标志,空白 Flash 为 0xFFFF |
| 2 | hr_limit_high | 2 | 心率上限 |
| 4 | br_limit_low | 2 | 呼吸下限 |
| 6 | phone_ucs2 | 44 | 联系人 UCS2 hex(不足补 0) |
| 50 | reserved | 14 | 预留扩展,保持块长 32?为页对齐预留 |
实际布局以代码为准,块总长按 16 半字(32 字节)组织,读取后只取前 24 半字,扩展字段预留到整半页。擦除以页为单位,写入只能把 1 写成 0,因此更新参数必须"擦页—整块重写"。
14.2 读写代码
/* bsp_flash.h */
#ifndef BSP_FLASH_H
#define BSP_FLASH_H
#include <stdint.h>
#define PARAM_PAGE_ADDR 0x0800FC00u
#define PARAM_MAGIC 0xA5A5u
void BspFlash_Unlock(void);
void BspFlash_Lock(void);
uint8_t BspFlash_ErasePage(uint32_t addr);
void BspFlash_WriteHalfWords(uint32_t addr,
const uint16_t *data, uint16_t halfwords);
void BspFlash_Read(uint32_t addr, uint16_t *data, uint16_t halfwords);
#endif
/* app_param.c —— 参数块编解码,与传感器数据结构解耦 */
#include "bsp_flash.h"
#include "types.h"
#include <string.h>
#define PARAM_HALFWORDS 24u /* 48 字节 */
typedef struct __attribute__((packed)) {
uint16_t magic;
uint16_t hr_limit;
uint16_t br_limit;
char phone_ucs2[PHONE_UCS2_LEN];
uint16_t reserved[21];
} param_block_t;
/* 读参数:magic 不对则返回默认值;号码字符串保证以 '�' 结尾 */
void AppParam_Load(sensor_data_t *d)
{
uint16_t raw[PARAM_HALFWORDS];
const param_block_t *p = (const param_block_t *)raw;
BspFlash_Read(PARAM_PAGE_ADDR, raw, PARAM_HALFWORDS);
if (p->magic != PARAM_MAGIC) {
d->hr_limit_high = DEF_HR_LIMIT_HIGH;
d->br_limit_low = DEF_BR_LIMIT_LOW;
d->contact_phone_ucs2[0] = '�';
return;
}
d->hr_limit_high = (uint8_t)p->hr_limit;
d->br_limit_low = (uint8_t)p->br_limit;
memcpy(d->contact_phone_ucs2, p->phone_ucs2, PHONE_UCS2_LEN);
d->contact_phone_ucs2[PHONE_UCS2_LEN] = '�';
}
/* 写参数:整块重建。返回 0 成功。写 Flash 期间不能有中断依赖 Flash 的操作 */
uint8_t AppParam_Save(const sensor_data_t *d)
{
static uint16_t raw[PARAM_HALFWORDS];
param_block_t *p = (param_block_t *)raw;
memset(raw, 0, sizeof(raw));
p->magic = PARAM_MAGIC;
p->hr_limit = d->hr_limit_high;
p->br_limit = d->br_limit_low;
memcpy(p->phone_ucs2, d->contact_phone_ucs2, PHONE_UCS2_LEN);
BspFlash_Unlock();
if (!BspFlash_ErasePage(PARAM_PAGE_ADDR)) { BspFlash_Lock(); return 1u; }
BspFlash_WriteHalfWords(PARAM_PAGE_ADDR, raw, PARAM_HALFWORDS);
BspFlash_Lock();
return 0u;
}
底层擦写要严格按 F1 解锁序列(FLASH_KEYR 依次写 KEY1=0x45670123、KEY2=0xCDEF89AB),并等待 BUSY 清零;擦除流程:置 PER→写页地址到 AR→置 STRT。写完务必重新上锁。两个工程细节:
- Flash 擦写期间内核取指会停顿,期间中断响应延迟增大,所以参数保存不要放在时序敏感的时刻(本工程由云端命令处理任务调用);
- 若参数块以后变大或需要双备份(磨损均衡/断电保护),可做 A/B 两块、带版本号交替写入,本文先留足 reserved 空间。
15. 华为云 IoTDA 平台配置
15.1 创建产品与服务模型
平台控制台操作路径:IoTDA 实例 → 产品 → 创建产品:
- 协议类型 MQTT,数据格式 JSON,设备类型自定义(如 HealthMonitor);
- 在产品的"模型定义"里,本工程采用自定义服务模型:服务 ID 为
stm32(与固件上报 JSON 的service_id完全一致),属性编号 A1~A22。
属性清单与字段类型(取值范围按实测):
| 属性 | 含义 | 类型 | 取值范围 |
|---|---|---|---|
| A1 | 温度 ℃ | float | -10~50 |
| A2 | 湿度 % | float | 0~100 |
| A3 | 人体感应 | int | 0/1 |
| A4 | 活动状态 | int | 0/1 |
| A5 | 心率 bpm | int | 0~200 |
| A6 | 呼吸 bpm | int | 0~60 |
| A7 | 体动幅度 | int | 0~100 |
| A8 | 清醒时长 分 | int | 0~1440 |
| A9 | 浅睡时长 分 | int | 0~1440 |
| A10 | 深睡时长 分 | int | 0~1440 |
| A11 | 睡眠总时长 分 | int | 0~1440 |
| A12 | 睡眠质量评分 | int | 0~100 |
| A13 | 离床时长 分 | int | 0~1440 |
| A14 | 离床次数 | int | 0~255 |
| A15 | 翻身次数 | int | 0~65535 |
| A16 | 平均呼吸 bpm | int | 0~60 |
| A17 | 平均心跳 bpm | int | 0~200 |
| A18 | 心率上限 | int | 40~200 |
| A19 | 呼吸下限 | int | 5~60 |
| A20 | 紧急联系人(UCS2) | string | 最长 44 字符 |
| A21 | 电池电量 % | int | 0~100 |
| A22 | 人体距离 cm | int | 0~1000 |
15.2 注册设备与 MQTT 三元组
在"设备 → 注册设备"中选择上面的产品,得到设备 ID(下文称 your_device_id)。设备侧 MQTT 登录三元组:
- ClientId:
your_device_id_0_0_2026071307(时间戳后缀可自定义); - UserName:
your_device_id; - PassWord:按 IoTDA HMAC-SHA256 规则计算,本文以
******占位,不给出真实值; - Broker:
your_instance.st1.iotda-device.cn-north-4.myhuaweicloud.com:1883。
Topic:
| 方向 | Topic |
|---|---|
| 设备属性上报 | $oc/devices/your_device_id/sys/properties/report |
| 命令/消息下行(设备订阅) | $oc/devices/your_device_id/sys/messages/down |
| 属性设置请求 | $oc/devices/your_device_id/sys/commands/request_id=<req_id> |
| 属性设置应答 | $oc/devices/your_device_id/sys/commands/response/request_id=<req_id> |
15.3 属性设置 request / response 流程
应用端修改阈值或联系人,本质是平台调用设备"自定义命令(属性设置)"。流程:
设备(MQTT)IoTDAQt/小程序设备(MQTT)IoTDAQt/小程序
调用设备命令下发 API(A18=110/A19=24/A20=号码)commands/request_id=xxx {paras:{A18:110,...}}校验+更新结构体+AppParam_Save 落 Flashcommands/response/request_id=xxx {result_code:0}API 返回 accepted(异步结果可查)下一周期 properties/reort 带新值,影子一致
设备侧收到下行帧后做三件事:合法性检查(阈值落在属性范围内、号码为合法 UCS2 数字串)、更新 g_sensor_data、写 Flash;任何一步失败都要返回非 0 result_code,不要"收到就回成功"。简易 JSON 字段提取在 MCU 上不必引入完整解析库——命令格式由平台模板固定,按 "A18":110 这样的关键字定位读取即可,但提取同样要做边界检查。
16. Qt5 上位机与 Android 端
16.1 工程结构
Qt 端不走 MQTT,而是走华为云 IAM 鉴权 + IoTDA HTTP API,好处是 PC 与 Android 手机同一套代码、不依赖第三方 Broker。工程按职责拆类:
.../v5/iot/.../devices/{id}/properties
-----------------------^
Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'DIAMOND_START'
16.2 IAM Token 获取
区域、IAM 地址、项目 ID、用户名密码、设备 ID 放在配置界面/配置文件里,不写死在代码中。Token 有效期 24 小时,缓存复用,过期(401)后重新申请。
// iot_api_client.h
#ifndef IOT_API_CLIENT_H
#define IOT_API_CLIENT_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QTimer>
struct CloudConfig {
QString iamUrl; // https://iam.cn-north-4.myhuaweicloud.com
QString projectId;
QString domain; // 账号名
QString userName;
QString password;
QString region; // cn-north-4
QString endpoint; // https://iotda.cn-north-4.myhuaweicloud.com
QString deviceId;
};
class IotApiClient : public QObject
{
Q_OBJECT
public:
explicit IotApiClient(const CloudConfig &cfg, QObject *parent = nullptr);
void requestToken();
void queryProperties();
void sendCommand(const QString ¶sJson); // 属性设置/命令下发
signals:
void tokenReady();
void propertiesReceived(const QJsonObject &props);
void requestFailed(const QString &reason);
private slots:
void onReplyFinished();
private:
QNetworkAccessManager m_nam;
CloudConfig m_cfg;
QString m_token;
QNetworkReply *m_pending;
QString tokenRequestBody() const;
};
#endif
// iot_api_client.cpp
#include "iot_api_client.h"
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QJsonObject>
IotApiClient::IotApiClient(const CloudConfig &cfg, QObject *parent)
: QObject(parent), m_cfg(cfg), m_pending(nullptr) {}
void IotApiClient::requestToken()
{
QUrl url(m_cfg.iamUrl + "/v3/auth/tokens");
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QJsonObject identity;
QJsonObject auth;
QJsonObject passwordObj;
QJsonObject user;
user["name"] = m_cfg.userName;
user["password"] = m_cfg.password;
QJsonObject domainObj;
domainObj["name"] = m_cfg.domain;
user["domain"] = domainObj;
passwordObj["user"] = user;
QJsonArray methods;
methods.append("password");
passwordObj["methods"] = methods;
identity["password"] = passwordObj;
QJsonObject scope;
QJsonObject project;
project["id"] = m_cfg.projectId;
scope["project"] = project;
auth["identity"] = identity;
auth["scope"] = scope;
QJsonObject root;
root["auth"] = auth;
QNetworkReply *reply = m_nam.post(
req, QJsonDocument(root).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
// Token 在响应头 X-Subject-Token;同时校验 HTTP 状态码
QVariant v = reply->rawHeader("X-Subject-Token");
if ((reply->error() == QNetworkReply::NoError) && !v.toString().isEmpty()) {
m_token = v.toString();
emit tokenReady();
} else {
emit requestFailed(reply->errorString());
}
reply->deleteLater();
});
}
void IotApiClient::queryProperties()
{
if (m_token.isEmpty()) { requestToken(); return; }
// 查询设备最新属性(设备影子),以控制台 API 实际路径为准
QString path = QString("/v5/iot/%1/devices/%2/shadow")
.arg(m_cfg.projectId, m_cfg.deviceId);
QNetworkRequest req(QUrl(m_cfg.endpoint + path));
req.setRawHeader("X-Auth-Token", m_token.toUtf8());
QNetworkReply *reply = m_nam.get(req);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
if (reply->error() != QNetworkReply::NoError) {
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 401)
m_token.clear(); // 过期:下轮重新取
emit requestFailed(reply->errorString());
} else {
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
// 影子数组中 service_id == "stm32" 的 reported 即 A1~A22
QJsonObject props = extractStm32Reported(doc.object());
emit propertiesReceived(props);
}
reply->deleteLater();
});
}
16.3 定时刷新、显示与超限提示
// widget.cpp(要点节选)
void Widget::startMonitor()
{
m_api = new IotApiClient(m_cloudCfg, this);
connect(m_api, &IotApiClient::tokenReady, m_api, &IotApiClient::queryProperties);
connect(m_api, &IotApiClient::propertiesReceived,
this, &Widget::updateUiFromCloud);
m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout, m_api, &IotApiClient::queryProperties);
m_timer->start(5000); // 5s 轮询
m_api->requestToken();
}
void Widget::updateUiFromCloud(const QJsonObject &p)
{
auto toInt = [&](const char *k, int def = 0) {
return p.contains(k) ? p.value(k).toInt() : def;
};
int hr = toInt("A5");
int br = toInt("A6");
int alarm = toInt("Alarm");
int hrLimit = toInt("A18", 120);
int brLimit = toInt("A19", 25);
ui->hrValue->setText(QString::number(hr));
ui->brValue->setText(QString::number(br));
// ……A1~A22 其余字段同样方式刷新……
const bool abnormal = (alarm != 0)
|| (hr > 0 && hr > hrLimit)
|| (br > 0 && br < brLimit);
ui->alarmFrame->setStyleSheet(
abnormal ? "background:#ffecec;border:1px solid #e5484d;"
: "background:#ecfdf3;border:1px solid #46a758;");
if (abnormal && !m_popupShown) {
m_popupShown = true;
QMessageBox::warning(this, QStringLiteral("健康预警"),
alarm == 1 ? QStringLiteral("呼吸率异常,低于阈值!")
: QStringLiteral("心率异常,高于阈值!"));
} else if (!abnormal) {
m_popupShown = false;
}
}
// 下发阈值/联系人:调用设备命令(属性设置),服务名与命令名按平台模型定义
void Widget::onSendCommandClicked()
{
QJsonObject paras;
paras["A18"] = ui->hrLimitEdit->text().toInt();
paras["A19"] = ui->brLimitEdit->text().toInt();
paras["A20"] = QString::fromLatin1(ui->phoneEdit->text().toUtf8().toHex());
m_api->sendCommand(QString::fromUtf8(QJsonDocument(paras).toJson(QJsonDocument::Compact)));
}
号码转 UCS2 的说明与固件一致:十进制号码字符串的 UTF-16BE hex(每位数字前补 00)。生产代码里把这段转换抽成工具函数并做长度/字符校验。
16.4 跨 Windows / Android 要点
.pro工程里加QT += network;Android 套件按需QT += androidextras;- Android 网络访问需在
AndroidManifest.xml声明INTERNET权限,HTTP 明文在高版本系统受限——华为云接口本身是 HTTPS,直接满足; - 界面用布局器自适应,避免固定像素坐标;文件路径用
QStandardPaths,不要写死盘符; - QNetworkAccessManager 全局只建一个实例复用,它自带连接池与线程亲和性;所有 UI 更新只能在主线程,绝不要在网络回调里直接 move 到别的线程再碰控件。
17. 微信小程序
17.1 页面结构
小程序两个页面:pages/index 监测主页(数据展示 + 配置入口)、pages/logs 记录页。配置项(区域、IAM、项目 ID、账号信息/设备 ID)放在 config.js,敏感操作建议通过自建后端中转 token,小程序不直接长期保存主账号密码。
wx.request
pages/index
22项数据卡片
报警高亮
配置界面
阈值/联系人
pages/logs
获取 IAM token
查 shadow/properties
命令下发接口
17.2 关键 JS 实现
// utils/cloud.js —— 网络访问与解析独立成模块,页面只调用接口
const config = require('../config.js');
function getToken() {
// 简单内存缓存;实际部署建议由自有后端签发短期 token,避免主账号凭证进端
if (config.token) return Promise.resolve(config.token);
const body = {
auth: {
identity: {
methods: ['password'],
password: {
user: {
name: config.userName,
password: config.password,
domain: { name: config.domain }
}
}
},
scope: { project: { id: config.projectId } }
}
};
return new Promise((resolve, reject) => {
wx.request({
url: `${config.iamUrl}/v3/auth/tokens`,
method: 'POST',
data: body,
header: { 'Content-Type': 'application/json' },
success(res) {
const token = res.header['X-Subject-Token'] || res.header['x-subject-token'];
if (res.statusCode === 201 && token) { config.token = token; resolve(token); }
else reject(new Error('获取Token失败'));
},
fail: reject
});
});
}
function queryProperties() {
return getToken().then(token => new Promise((resolve, reject) => {
wx.request({
url: `${config.endpoint}/v5/iot/${config.projectId}/devices/${config.deviceId}/shadow`,
method: 'GET',
header: { 'X-Auth-Token': token },
success(res) {
if (res.statusCode === 200) resolve(parseStm32Reported(res.data));
else reject(new Error(`查询失败 ${res.statusCode}`));
},
fail: reject
});
}));
}
// 影子结构中找到 service_id 为 stm32 的 reported;找不到返回空对象
function parseStm32Reported(data) {
const list = (data && data.shadow) || [];
for (let i = 0; i < list.length; i += 1) {
if (list[i].service_id === 'stm32') return list[i].reported || {};
}
return {};
}
// 号码转 UCS2 hex:每位数字补 00
function phoneToUcs2(phone) {
return String(phone).split('').map(ch => `00${ch.charCodeAt(0).toString(16)}`).join('');
}
module.exports = { queryProperties, phoneToUcs2 };
// pages/index/index.js
const cloud = require('../../utils/cloud.js');
Page({
data: {
sensor: {},
abnormal: false,
timer: null
},
onLoad() {
this.refresh();
this.data.timer = setInterval(() => this.refresh(), 5000);
},
onUnload() {
clearInterval(this.data.timer);
},
refresh() {
cloud.queryProperties().then((props) => {
const hr = props.A5 || 0;
const br = props.A6 || 0;
const abnormal = !!props.Alarm
|| (hr > 0 && hr > (props.A18 || 120))
|| (br > 0 && br < (props.A19 || 25));
this.setData({ sensor: props, abnormal });
}).catch(err => console.warn('refresh fail', err));
},
goConfig() { wx.navigateTo({ url: '/pages/config/config' }); }
});
页面 WXML 用卡片循环渲染 A1~A22,报警卡片绑定 abnormal 切红色样式即可,与 Qt 的判断规则保持一致。配置页提交时调用命令下发接口,字段编号、数据格式与平台模型三处必须一致(固件、平台、应用端任何一处编号错位,数据就会串列)。
18. 系统调试与测试
18.1 调试方法与串口日志
项目采用"分级联调",从下往上逐层验证:
- BSP 级:LED/按键/蜂鸣器写测试小程序,肉眼验证;软件 I2C 用逻辑分析仪或示波器抓 SCL/SDA,对照时序图确认;
- 传感器级:雷达先接 USB-TTL,用串口助手直接看原始帧,确认模块工作正常再进 MCU 解析;SHT30 串口打印换算后的温湿度,对着传感器哈气看变化;
- 通信级:4G 模块先用串口助手走完注网、MQTT 全流程;MQTT 登录可用平台提供的模拟设备工具/客户端交叉验证三元组;
- 业务级:报警逻辑用"改阈值触发法"测试——把心率上限临时调到 60,安静状态下应立刻触发;测完恢复;
- 全链路级:观察云端影子、Qt、小程序三处数据是否同步、刷新时间是否一致。
建议实现统一日志接口,输出到 USART1,分级打印:
/* bsp_log.h */
typedef enum { LOG_DBG, LOG_INF, LOG_WRN, LOG_ERR } log_level_t;
#define LOGI(fmt, ...) Log_Print(LOG_INF, "[I] " fmt "rn", ##__VA_ARGS__)
#define LOGW(fmt, ...) Log_Print(LOG_WRN, "[W] " fmt "rn", ##__VA_ARGS__)
#define LOGE(fmt, ...) Log_Print(LOG_ERR, "[E] " fmt "rn", ##__VA_ARGS__)
日志内容只打状态与数值,不打设备密钥、完整手机号;正式版本可通过宏关闭 DBG 级,减少串口开销。
18.2 常见问题排查表
| 现象 | 可能原因 | 排查与处理 |
|---|---|---|
| SWD 无法下载 | BOOT0/接线错;PA13/PA14 被复用 | 按住复位连接;确认 BOOT0 接地;检查 AFIO 映射代码 |
| OLED 不亮/花屏 | I2C 时序过快、地址错、初始化序列缺失 | 放慢 I2C;试 0x3C;逻辑分析仪抓波形 |
| SHT30 读数全 0 或 CRC 错 | 上拉电阻缺失、地址/电源错、时序错 | SCL/SDA 加 4.7k 上拉;确认 3.3V 供电与 0x44 地址 |
| 雷达无数据 | TX/RX 接反;波特率不对;5V 供电不足 | 交叉 TX/RX;确认 115200;测模块电流 |
| 帧校验大量失败 | 波特率误差、地环路干扰、FIFO 溢出 | 检查晶振;共地;加大 FIFO、提高解析频率 |
| 4G 反复重启 | 电源带载能力不足、SIM 欠费/松动 | 换足电流电源、独立测试;重插 SIM、查 CSQ |
| 注网失败 | 信号弱、APN 问题 | AT+CSQ 确认 ≥10;确认 SIM 开通数据业务 |
| MQTT 登录失败 | 三元组错误、时间戳/HMAC 不匹配 | 平台工具复算;核对 Broker 端口与实例 ID |
| 短信发不出/乱码 | CSCS/CSMP 模式不对、号码 UCS2 错 | 确认 UCS2 三件套;核对号码编码 |
| 参数重启丢失 | Flash 地址与代码区冲突、未上锁检查 | 确认用最后一页;读回校验 magic |
| 电量跳变 | 4G 发射拉低有载电压;分压误差 | 低通滤波;实物标定 k_calib |
| 误报警 | 阈值过严、开机窗口内数据不稳 | 按人标定阈值;保留 30 秒屏蔽与有效性检查 |
18.3 功能测试用例
| 编号 | 测试项 | 操作 | 预期结果 |
|---|---|---|---|
| TC01 | 开机流程 | 上电 | RUN 灯闪烁;30 秒内不报警;OLED 默认第 1 页 |
| TC02 | 有人检测 | 人躺到床上 | 5~10 秒内 A3=1;无人时 A3=0 且体征清零 |
| TC03 | 体征采集 | 正常躺卧 1 分钟 | A5 心率、A6 呼吸出现合理数值并周期更新 |
| TC04 | 翻页 | 按 K1 | OLED 按 1→2→3→4→1 切换 |
| TC05 | 心率报警 | 上限调到 60,安静躺卧 | 蜂鸣 30 秒、Alarm=2、APP 弹窗标红、短信到达 |
| TC06 | 呼吸报警 | 下限调到 40(或无人/屏息场景模拟) | Alarm=1,全链路同上 |
| TC07 | 短信去重 | 异常持续超过 60 秒 | 60 秒内同号码只收到 1 条同类短信 |
| TC08 | 手动 SOS | 按 K2 | 立即收到 SOS 短信,与报警窗口无关 |
| TC09 | 异常恢复 | 撤销异常条件 | Alarm 回 0,界面恢复正常样式,蜂鸣停止 |
| TC10 | 远程配置 | APP 修改阈值/联系人 | 设备应答成功;Flash 中更新;重启后保持 |
| TC11 | 充电指示 | 接入 Type-C | PB6/PB7 状态正确,OLED 第 4 页反映充电/充满 |
| TC12 | 电量指示 | 电池放电/充电观察 | A21 变化平滑,无大幅跳变 |
| TC13 | 断网恢复 | 拔掉天线再插回 | 状态机重连,恢复后数据继续上报 |
| TC14 | 睡眠统计 | 模拟一夜睡眠 | 清醒/浅睡/深睡/评分/离床数据更新,整夜报告正确 |
| TC15 | 三端一致 | 同刻对比云、Qt、小程序 | 数据一致,时间戳在一个上报周期内 |
19. 项目总结与改进方向
整套系统做下来,技术主线可以归纳为四句话:传感器负责"感",协议解析负责"懂",4G+MQTT 负责"传",报警策略负责"用"。学习者在这个项目里能拿到的不只是一个作品,还有一整套可迁移的工程方法:分层架构、状态机思维、数据校验、故障自恢复。
可落地的改进方向:
- 数据质量增强:心率、呼吸连续多周期做置信度判断,低置信度时不参与报警;对长时间趋势做统计分析,而不是单次越界即报;
- 体征维度扩展:增加血压趋势(需选配相应模组)、血氧、鼾声识别、跌倒检测算法;
- 多设备组网:一户多房间、养老机构一层楼多床位,网关侧做设备分组与集中看板,平台规则引擎做分级派单;
- OTA 升级:通过 Air780E 或外挂存储实现 STM32 固件远程升级,带版本回滚;
- 定位能力:紧急短信附带小区/房间位置信息(Wi-Fi/基站定位),缩短救助时间;
- 可穿戴融合:雷达为主、手环为辅,互相补盲(离床后由手环接管);
- 低功耗优化:无人时降频、雷达间歇工作、动态调整上报周期,延长电池续航;
- 安全加固:一机一密、密钥安全存储、TLS 双向认证、下行命令防重放。
20. 结语
嵌入式这行,进步最快的方式永远是动手做出完整的东西。从点亮一颗 LED,到读懂雷达帧,再到数据跑上云端、手机收到报警——中间一定会遇到乱码、重启、误报,但这些问题被你一个个排掉之后,就真正变成了你自己的能力。
这套装置涉及的每一个模块都不难,难的是把它们按规范组织成一个可靠的系统。建议读者自己动手重写一遍代码,按自己的使用场景重新标定阈值,再把外壳、安装方式做成产品化的样子。
如果这篇教程对你有帮助,欢迎点赞、在看、转发给同样在学嵌入式的朋友。关注公众号「DS小龙哥嵌入式技术资讯」,后续会继续更新毫米波雷达、物联网上云、STM32 实战类的手把手项目,我们下篇见。
26