• 方案介绍
  • 相关推荐
申请入驻 产业图谱

手把手教你做一套「非接触式人体健康监测预警装置」:STM32 + 60GHz 毫米波雷达 + 4G 上云全流程实战

1小时前
26
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

手把手教你做一套「非接触式人体健康监测预警装置」:STM32 + 60GHz 毫米波雷达 + 4G 上云全流程实战

本文完整记录一套可落地的物联网健康监测设备,从硬件选型、寄存器级驱动、雷达私有协议解析、4G/MQTT 上云,到华为云 IoTDA 配置、Qt5 跨平台上位机、微信小程序的全部开发过程。可以直接作为自己项目的骨架。0 基础读者跟着走也能做出来。

声明:本装置测量的呼吸、心率等数据用于日常健康趋势参考与异常预警,不具备医疗器械资质,不能替代专业医疗诊断。


目录

  1. 项目背景与应用场景
  2. 实现效果与功能总览
  3. 系统总体架构设计
  4. 硬件选型与工作原理
  5. 硬件接线与上电检查
  6. 开发环境搭建与工程分层
  7. 底层驱动实现(GPIO / 串口环形缓冲 / ADC / 定时器)
  8. SHT30 温湿度驱动
  9. R60ABD1 毫米波雷达协议解析
  10. 本地 OLED 四页显示
  11. 电池电量检测与充电状态
  12. 4G 联网与 MQTT 上云
  13. 报警逻辑设计
  14. 参数掉电保存
  15. 华为云 IoTDA 平台配置
  16. Qt5 上位机与 Android 移植
  17. 微信小程序开发
  18. 系统调试与测试
  19. 项目总结与改进方向
  20. 结语

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

电量/充电状态

STM32F103C8T6

寄存器方式开发
协议解析 · 报警判断 · 本地显示

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% 的硬件返工:

  1. 所有模块必须共地(GND 连在一起)。串口双方不共地时,电平参考不一致,数据就是乱码,4G 还可能出现莫名重启。
  2. 串口 TX 接 RXD、RX 接 TXD,不是同名直连。Air780E、R60ABD1 的串口都是 3.3V TTL 电平,与 STM32 直连即可;若模块底板加了 RS232/RS485 电平芯片则不能直连。
  3. SHT30 必须 3.3V 供电,它是非 5V 耐压器件;OLED 与 4G、雷达按底板标称接 5V。
  4. 分压电阻尽量选 1% 精度,布线靠近 MCU,分压中点加一个 100nF 电容可以显著降低 ADC 噪声。
  5. 4G 模块电源走线要短、线要粗(建议用独立供电测试确认模块在发射大电流时电压不跌落)。
  6. 电池端务必加保护(带保护板的成品电池或 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 事务分两步:

  1. 主机写测量命令(两字节):0x22 0x36(单次、高重复精度);
  2. 等待测量完成(高重复精度最长 15ms,工程上在采集周期内延时 20ms);
  3. 主机写读数据命令 0xE0 0x00,随后重复起始读 6 字节;
  4. 每两个数据字节后跟 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 判断条件与去抖策略

报警条件(必须同时满足前置条件才判异常):

  1. 已过开机屏蔽窗口(上电后 30 秒内不报警,避开雷达初始化、统计未稳定阶段);
  2. 雷达判定有人(human_present == 1);
  3. 呼吸、心率读数有效(大于 0,处在生理可接受区间);
  4. 呼吸率 < 下限(默认 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。工程按职责拆类:

渲染错误: Mermaid 渲染失败: Parse error on line 7:
.../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 &parasJson);   // 属性设置/命令下发

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 调试方法与串口日志

项目采用"分级联调",从下往上逐层验证:

  1. BSP 级:LED/按键/蜂鸣器写测试小程序,肉眼验证;软件 I2C 用逻辑分析仪或示波器抓 SCL/SDA,对照时序图确认;
  2. 传感器级:雷达先接 USB-TTL,用串口助手直接看原始帧,确认模块工作正常再进 MCU 解析;SHT30 串口打印换算后的温湿度,对着传感器哈气看变化;
  3. 通信级:4G 模块先用串口助手走完注网、MQTT 全流程;MQTT 登录可用平台提供的模拟设备工具/客户端交叉验证三元组;
  4. 业务级:报警逻辑用"改阈值触发法"测试——把心率上限临时调到 60,安静状态下应立刻触发;测完恢复;
  5. 全链路级:观察云端影子、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 负责"传",报警策略负责"用"。学习者在这个项目里能拿到的不只是一个作品,还有一整套可迁移的工程方法:分层架构、状态机思维、数据校验、故障自恢复。

可落地的改进方向:

  1. 数据质量增强:心率、呼吸连续多周期做置信度判断,低置信度时不参与报警;对长时间趋势做统计分析,而不是单次越界即报;
  2. 体征维度扩展:增加血压趋势(需选配相应模组)、血氧、鼾声识别、跌倒检测算法;
  3. 多设备组网:一户多房间、养老机构一层楼多床位,网关侧做设备分组与集中看板,平台规则引擎做分级派单;
  4. OTA 升级:通过 Air780E 或外挂存储实现 STM32 固件远程升级,带版本回滚;
  5. 定位能力:紧急短信附带小区/房间位置信息(Wi-Fi/基站定位),缩短救助时间;
  6. 可穿戴融合:雷达为主、手环为辅,互相补盲(离床后由手环接管);
  7. 低功耗优化:无人时降频、雷达间歇工作、动态调整上报周期,延长电池续航;
  8. 安全加固:一机一密、密钥安全存储、TLS 双向认证、下行命令防重放。

20. 结语

嵌入式这行,进步最快的方式永远是动手做出完整的东西。从点亮一颗 LED,到读懂雷达帧,再到数据跑上云端、手机收到报警——中间一定会遇到乱码、重启、误报,但这些问题被你一个个排掉之后,就真正变成了你自己的能力。

这套装置涉及的每一个模块都不难,难的是把它们按规范组织成一个可靠的系统。建议读者自己动手重写一遍代码,按自己的使用场景重新标定阈值,再把外壳、安装方式做成产品化的样子。

如果这篇教程对你有帮助,欢迎点赞、在看、转发给同样在学嵌入式的朋友。关注公众号「DS小龙哥嵌入式技术资讯」,后续会继续更新毫米波雷达、物联网上云、STM32 实战类的手把手项目,我们下篇见。

相关推荐