简介:基于MQTT协议的物联网健康监测系统,是面向物联网、嵌入式方向课程设计与期末大作业的一套完整项目。项目已获导师指导并以97分通过,整体完成度高,包含可运行的STM32下位机源码与配套文档,适合需要快速落地毕业设计、期末答辩或课程设计的学生参考。压缩包共296个文件、约8.16MB,主体为C语言工程(含.c/.h源文件、Keil的.uvprojx工程与编译中间文件),同时集成微信小程序前端代码(.wxml/.wxss/.js/.json),以及PNG/JPG图片、说明文档(.md/.txt)、Hex烧录文件等,代码结构清晰、注释充分,便于二次开发。已有168人学习,学习者可直接对照源码与文档理解MQTT通信、传感器数据采集、云端交互等关键环节,快速搭建并演示健康监测场景,无论是期末答辩还是功能展示都能轻松应对,是高分课程设计的有力参考。
1. 从一条心率消息说起:MQTT 在健康监测系统里到底在传什么
如果你的期末大作业标题里有“MQTT 协议”和“健康监测系统”,那评审老师真正想看的是数据链路:从可穿戴设备采到脉搏波形,到云端界面显示成趋势曲线,这一段路上协议怎么选、数据丢没丢、设备掉线怎么发现。MQTT 在这里不是可有可无的通信组件,而是整套系统的骨架——它决定了设备端烧多少 Flash、服务器扛多少并发、断网重连之后数据还能不能对得上。
MQTT 是轻量级发布/订阅模型,建立在 TCP 之上,设计目标就是给窄带宽、高延迟、弱网环境用。健康监测恰好全是这种场景:手环不会长时间保持屏幕亮着,心率数据几十毫秒一条,传感器偶尔掉线还要自动补传。HTTP 在这种场景下轮询开销大、实时性差、上行推送做不到;MQTT 的 broker 中转模式让设备只需要维持一条长连接,就能同时实现设备上报和控制下发的双向通信。
这套方案适合谁?一个是做物联网/嵌入式方向期末项目的高校学生,照着搭能跑通拿高分;另一个是真正在做智慧养老、运动手环、远程病患监护的工程师,想快速搭一套采集、传输、存储、展示的完整底座。下面按“协议 → 搭建 → 代码 → 排错 → 部署”的顺序,把这个系统从零拆到能交付。
2. 为什么选 MQTT:QoS、遗嘱、保活、会话恢复的合理解释
2.1 健康数据场景对协议的四个硬性要求
健康监测系统有几个和普通物联网设备不同的特征:数据有时效性,但允许短时间缺失;设备有移动性,会频繁进出网关范围;数据一旦入库就要永久留存,中间不能错乱;终端是电池供电,不能为了通信频繁重连。
这四个要求对应到 MQTT 就是四个机制:QoS 保证消息不重不漏、Last Will 发现异常下线、keepalive 控制连接生命周期、cleanSession 决定离线消息要不要补发。把这四个机制在文档说明里写清楚,比贴任何代码都能体现对协议的理解。
MOTT 协议规定报文头固定两个字节,第一个字节存消息类型和标志位,第二个字节存剩余长度。剩余长度最多 4 个字节,能表达最大 256MB 的报文——健康监测里单条心率数据只要几个字节,所以头开销占比非常低。
2.2 QoS 0/1/2 的实际语义和坑
QoS 0 是“最多一次”,消息发出就完了,不确认、不重传。适合环境温度这种丢了就丢了的数据。QoS 1 是“至少一次”,PUBLISH 后等 PUBACK,超时重发,接收方可能收到重复消息。QoS 2 是“恰好一次”,走 PUBREC/PUBREL/PUBCOMP 四次握手,能去重,但延迟高、包多,在健康监测里一般不用于高频心率数据。
典型做法是:心率、血氧这种高频采样用 QoS 0,心跳定位状态切换用 QoS 1,医嘱、远程开关这种控制指令用 QoS 2。还有一个容易漏的点:broker 端对离线设备的持久会话补发只在 QoS>0 时才生效(具体是网络连接断开时 PUBLISH 的 QoS 值决定是否存储),如果全链路都设 QoS 0,会话的离线堆积等于白配。
2.3 遗嘱消息和 retained 消息:系统怎么知道手环“死了”
设备异常断电和正常关机在 TCP 层面很难区分,TCP 超时重传要等很久。MQTT 的做法是连接时携带遗嘱:设备在 CONNECT 报文里附带 Will Topic、Will Message、Will QoS,之后如果 broker 发现连接异常中断(比如心跳超时、TCP 被 RST),broker 主动向该 topic 发送遗嘱消息。客户端正常 DISCONNECT 时,broker 不发布遗嘱。
项目里我一般把遗嘱设计成 JSON 结构{"device_id":"sensor_001","status":"offline","ts":1699999999}。服务端订阅这个 topic,维护一张设备在线表。同样的逻辑也可以用来做自动化联动:网关检测到手环离线,自动通过另一条 topic 给它的分体血压计发命令,让它进入省电模式。
retained 消息和遗嘱要分清楚。retained 是 broker 给新订阅者推送“这条 topic 的最近一条消息”,设备重启后订阅就能立刻拿到其他设备的当前状态,不用等下一次上报。在健康监测里典型用法是网关把自己的注册信息、固件版本发到gateway/{id}/info并 retained,平台上新节点上线订阅这个 topic 时就能拿到全量网关清单。
2.4 保活心跳、cleanSession 和 MQTT 5.0 特性的取舍
CONNECT 报文里的 keepalive 参数是秒数,建议取上报周期的 2 到 3 倍。上报周期 1 秒,keepalive 就设 3 秒;心跳周期长了 broker 会等很久才判定死亡,短了又会在网络抖动时反复重连。客户端在无业务消息发送时,需要自己发 PINGREQ 来维持连接,这是 SDK 干的活,但参数要能配置。
cleanSession 决定断开后 broker 是否保留该客户端的订阅关系。健康监测网关是长期运行的设备,建议 cleanSession=0,这样客户端重连后不用重新订阅就能收到离线期间积压的 QoS>0 消息。但要注意,持久会话的堆积消息如果太多,重连瞬间会全部灌给客户端,老年机大概率卡死——所以要不定期通过diagnostics接口清理订阅。
MQTT 5.0 加了会话过期、用户属性、服务器主动断开原因等特性,用于跨区域分级消费更舒服。如果是从零写期末项目,建议用 MQTT 3.1.1,broker 兼容性好,SDK 也稳定;要在文档说明里体现前沿性,就用一段篇幅介绍 5.0 的改进点即可。
3. 硬核起底:用 ESP32 + EMQX + Python 搭建最小可用的健康监测链路
3.1 整体架构和三端选型理由
系统的核心是三个进程(也可以是三台机器):传感端(带心率传感器的 ESP32)、消息中转(MQTT broker)、业务端(订阅存储和展示)。毕业设计或期末大作业的展示环境通常是局域网,所以 broker 装在同一台开发机上即可,设备通过 Wi-Fi 接入。
传感端选 ESP32 而不是 Arduino Uno,是因为 ESP32 自带 Wi-Fi/BLE,跑 MQTT 的 TCP 栈足够,Flash 有 4MB,可以直接存固件和少量离线缓存。业务端用 Python 是因为生态最全:paho-mqtt 做订阅、MySQL 做存储、Flask 做展示,一行命令装完所有依赖。Broker 选 EMQX 的原因很简单:开源版不限制连接数,Dashboard 直接把在线设备列表和消息吞吐量可视化,答辩时展示效果很好;如果要给老师演示“源代码”,读它核心的联系人管理模块代码比从零写 broker 有说服力得多。
3.2 Broker 端初始化:EMQX 的安装、监听端口、认证和 ACL
linux/macOS 直接下载解压,官方推荐 Docker 方式:
docker run -d --name emqx \ -p 1883:1883 \ -p 8083:8083 \ -p 8084:8084 \ -p 8883:8883 \ -p 18083:18083 \ emqx/emqx:5.1.0启动后 Dashboard 在18083端口,默认账号 admin/public。
这三个端口要知道干什么的:1883 是 MQTT 明文端口,调试时用;8883 是 TLS 加密端口,公网部署必须用;8083/8084 是 WebSocket 端口,给前端页面实时推送用。真正项目里只开放 8883 和 8084,1883 只在内网用。
认证在 Dashboard 的 “Access Control” 里配置,也可以放配置文件:
## etc/emqx.conf authentication = [ {mechanism = password_based, backend = built_in_database, password_hash_algorithm = {name = sha256}, user_id_type = username} ]ACL 规则建议按资源前缀授权,比如允许sensor/{deviceId}/#读和写,允许gateway/{id}/cmd读与写,但设备和设备之间不能互相订阅彼此的privatetopic。健康数据是敏感数据,项目文档里加一节“基于 ACL 的数据隔离”,老师会觉得你考虑到了隐私合规层面。
3.3 设备端:ESP32 采集、发布、断线重连、加密的完整代码
设备端要处理四件事:Wi-Fi 连接、传感器读取、MQTT 发布、离线缓存。下面给一个能在 Arduino IDE 环境直接编译的简化版本(屏蔽了具体传感器的 I2C 寄存器操作,换成模拟心跳数据):
#include <WiFi.h> #include <PubSubClient.h> const char* ssid = "YOUR_WIFI"; const char* password = "YOUR_PASSWORD"; const char* mqtt_server = "192.168.1.100"; const int mqtt_port = 1883; const char* clientId = "sensor_001"; const char* pub_topic = "health/sensor_001/data"; const char* will_topic = "health/sensor_001/status"; const char* will_msg = "{\"status\":\"offline\"}"; WiFiClient espClient; PubSubClient client(espClient); unsigned long lastPublish = 0; void reconnect() { while (!client.connected()) { if (client.connect(clientId, "sensor01", "pwd123", will_topic, 1, true, will_msg)) { client.subscribe("health/sensor_001/cmd"); } else { delay(2000); } } } void setup() { WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); } client.setServer(mqtt_server, mqtt_port); client.setCallback(callback_func); reconnect(); } void loop() { if (!client.connected()) { reconnect(); } client.loop(); if (millis() - lastPublish > 2000) { int hr = 60 + random(20); // 模拟心率数据 int spo2 = 95 + random(4); // 模拟血氧 char payload[128]; snprintf(payload, sizeof(payload), "{\"hr\":%d,\"spo2\":%d,\"ts\":%ld}", hr, spo2, time(nullptr)); client.publish(pub_topic, payload, 1); // QoS 1 保证入库数据不丢 lastPublish = millis(); } }第 14 行的client.connect第 4 到 6 个参数分别是遗嘱 topic、遗嘱 QoS、遗嘱 retained 标志、遗嘱内容。这里的 QoS=1 表示遗嘱消息本身至少送达一次;retained=true 表示设备掉线状态要保留在 broker 上,后续新订阅者能立刻看到。第 37 行定时 2 秒一包数据,QoS 1 发布,兼顾实时性和可靠性。
实际传感器怎么接?MAX30102 心率和血氧传感器通过 I2C 接 ESP32,SDA 连 GPIO21、SCL 连 GPIO22。库用 SparkFun 官方的 MAX30105 库,初始化后调用readSensor()拿到 FIFO 数据,经过 50Hz 低通滤波后算心率。期末大作业如果时间紧,用模拟数据也完全可行,但必须在文档里明说是模拟。
3.4 服务器端:Python 订阅、解析、入库和 API 暴露
服务端用 paho-mqtt 订阅所有health/+/data,入库到 MySQL,再提供接口给前端展示。最小实现:
import json import pymysql import paho.mqtt.client as mqtt DB_CONFIG = { 'host': '127.0.0.1', 'user': 'health', 'password': 'health123', 'database': 'health_monitor' } def on_connect(client, userdata, flags, rc): if rc == 0: client.subscribe('health/+/data', qos=1) else: print(f"MQQT connection failed, rc={rc}") def on_message(client, userdata, msg): topic = msg.topic # health/sensor_001/data device_id = topic.split('/')[1] payload = json.loads(msg.payload.decode('utf-8')) with pymysql.connect(**DB_CONFIG) as conn: with conn.cursor() as cur: sql = "INSERT INTO vitals(device_id, hr, spo2, ts) VALUES(%s, %s, %s, %s)" cur.execute(sql, (device_id, payload['hr'], payload['spo2'], payload['ts'])) conn.commit() client = mqtt.Client(client_id="backend-server") client.username_pw_set("backend", "srv_passwd") client.on_connect = on_connect client.on_message = on_message client.connect("127.0.0.1", 1883, 60) client.loop_forever()这里两个容易踩的坑。第一个是client_id必须全局唯一,两个服务端进程用同一个 client_id 会导致 broker 不断把先前连接踢下线,日志里全是 “Protocol error”。第二个是消费端没有做幂等处理,QoS 1 可能让同一消息被 broker 重投,上面代码用ts字段做天然幂等键:入库前先按(device_id, ts)查重。如果传感器的时间戳不稳定,就给每条消息生成一个message_id(UUID),入库时加唯一索引。
MySQL 表结构就一句话的事:
CREATE TABLE vitals ( id BIGINT AUTO_INCREMENT PRIMARY KEY, device_id VARCHAR(50) NOT NULL, hr TINYINT UNSIGNED, spo2 TINYINT UNSIGNED, ts BIGINT NOT NULL, UNIQUE KEY uk_device_ts (device_id, ts) );展示层用 Flask 的/api/vitals?device_id=sensor_001&minutes=60按 10 秒窗口做 AVG 聚合返回 JSON,前端图表库渲染趋势线。
4. 写文档说明才是拿高分的关键:架构图、时序图、参数表和测试报告
4.1 项目目录怎么设计和模块划分
如果这个 zip 里要包含“源代码+文档说明”,那交付物的目录结构本身就是文档:
health-monitor/ ├── firmware/ # ESP32 端工程(PlatformIO / Arduino) │ └── src/main.cpp ├── mqtt-broker/ # EMQX 配置文件或 Docker Compose ├── server/ # Python 订阅端 │ ├── subscriber.py │ ├── api.py │ └── db.py ├── web/ # 前端展示 ├── docs/ │ ├── design.md # 架构设计 │ ├── protocol.md # MQTT topic 规范 │ ├── test_report.md # 测试记录 │ └── deploy.md # 部署手册 └── README.mdprotocol.md是老师最爱看的部分。里面要写清楚每个 topic 的作用、方向、QoS、保留策略:
| Topic 名称 | 方向 | QoS | Retain | 用途 |
|---|---|---|---|---|
health/{deviceId}/data | 设备 → 服务端 | 1 | 否 | 周期性上报心率血氧 |
health/{deviceId}/status | 设备 → 服务端 | 1 | 是 | 遗嘱/上线状态通知 |
health/{deviceId}/cmd | 服务端 → 设备 | 2 | 否 | 下发指令(同步参数、校时) |
health/{deviceId}/alert | 设备 → 服务端 | 2 | 否 | 异常心率报警 |
把 QoS 等级的设计理由写进去:业务数据 QoS 1、报警 QoS 2(因为必须恰好一次)、状态 QoS 1 + retained(因为新节点需要立即知道设备是否在线)。
4.2 文档里必须有的三张图和三张表
设计类文档不能只有文字,必须有图有表。时序图是必须的——用文本形式表现设备初始化到断开全流程,别用 mermaid 语法,直接用 ASCII 逐行列清楚也行,但建议在 Word 里画标准 UML。架构类图三张够用:
- 物理架构图:设备、Wi-Fi 路由器、开发机、broker、数据库、浏览器之间的箭头。
- 时序图:设备上电 → Wi-Fi 连接 → MQTT CONNECT → CONNACK → SUBSCRIBE → PUBLISH → broker 转发 → 服务端 ACK → 入库 → 前端刷新。
- 状态图:设备在线、离线、重连、遗嘱触发的状态迁移条件。
三张表:topic 对照表(见上文)、QoS 和消息类型说明表、测试结果记录表。测试记录表必须真有数据,不能空着:
| 测试场景 | 预期结果 | 实测结果 | 结论 |
|---|---|---|---|
| 设备正常运行,后台订阅 | 每条消息 2 秒内入库 | 入库延迟 30ms | 通过 |
| 拔掉设备 Wi-Fi,等待 4 秒 | broker 收到遗嘱消息 | 设备状态变 offline | 通过 |
| 杀掉服务端进程 10 秒后重启 | 离线期间 QoS1 消息补发 | 补发 5 条,不重不漏 | 通过 |
| 两个 client 用相同 clientId 连接 | 前者被踢下线 | 前者断开 | 说明 clientId 唯一性的要求 |
4.3 在文档里解释“为什么这样设计”的模板
写文档最容易犯的错是只记流水账。设计说明里每个模块至少要回答三个问题:为什么选这个组件、有哪几个备选、权衡后为什么放弃。比如:
- 为什么用 MQTT 而不用 CoAP/HTTP?答:HTTP 轮询电池撑不了 12 小时;CoAP 基于 UDP,可靠性要自己做重传,而 MQTT 的 QoS 机制是现成的。
- 为什么用 EMQX 不用 Mosquitto?答:Mosquitto 单机性能足够但管理界面弱,ACL 配置要手改文件;EMQX 有 Dashboard 和 REST API,答辩演示时可以直接展示在线设备和消息速率。
- 为什么服务端用 Python 不用 Java/Go?答:期末项目重在链路完整性和业务逻辑可读性;Java Spring Boot 一套下来配置比业务代码多,Go 的 paho 库文档不如 Python 全面。
5. 调不通怎么办:MQTT 排查链路和最常见的 7 个故障点
5.1 从 broker 数据流逆推问题层
MQTT 排错有一个固定顺序:链路 → 报文 → 鉴权 → 订阅路由 → 消息持久化。不要一上来就抓包,先看 EMQX Dashboard 的 “Connections” 列表,设备有连接记录说明 TCP 和 CONNECT 都通了。如果设备根本没出现在列表里,问题一定在 TCP 层:Wi-Fi 没连上、IP 不对、1883 端口被防火墙挡了。
mosquitto_sub/mosquitto_pub是做对照实验的最好工具。比如怀疑某个设备的数据发不到服务端,先在终端用mosquitto_sub -h 192.168.1.100 -t 'health/#'订阅全部健康 topic。往这个订阅里能看到设备消息,说明 broker 路由正常,问题在服务端消费逻辑;看不到,则问题在设备侧发送或 ACL 限制。
5.2 用 Wireshark 抓包看 MQTT CONNECT 报文关键字段
Windows 或 macOS 上打开 Wireshark,过滤tcp.port == 1883,找到显示MQ CONNECT的包,展开协议树。重点核对三个字段:
- Client ID:是否全局唯一。
- Keep Alive:是否设成了 0。MQTT 协议规定 0 表示关闭心跳检测,broker 不会主动踢设备,和预期行为不符时先查这里。
- Will Message是否带了完整 topic。如果遗嘱 topic 和普通订阅规则冲突,ACL 会直接拒绝 CONNECT 报文,导致看起来像是“密码错误”但实际是遗嘱 topic 没权限。
报文里的 Flags 字段也值得留意,Clean Session是 0 还是 1 决定重连是否需要重新订阅。有的 SDK 默认 cleanSession=1,每次重连都会丢订阅关系,这在场景里表现为设备重启后数据矩阵出现断档。
5.3 重连风暴、消息堆积和时钟乱跳的实践处理
设备端如果断网重连后立刻把本地缓存的离线数据全部发出来,broker 在同一时刻会收到几十个设备的突发流量,内存和带宽瞬间被打满。这叫重连风暴,也是物联网系统杀手上榜第一。常见做法是:
- 限制重连退避时间,递增 1s/2s/4s/8s,最大 60s。
- 离线缓存按优先级丢一半——只保留最近 10 分钟关键指标,老数据直接丢弃。
- 重连成功后的第一条消息发
status:online,让 broker 端把 burst 缓存清空。
牵扯的另一坑是消息堆积。EMQX Dashboard 的 “Queued Messages” 在持久会话场景里持续增长,通常因为消费端处理速度跟不上生产端,而消费端又用了 QoS 1/2 的消息太多没及时 ACK。根据消息类型调整 QoS 是一种方案,但真正的瓶颈在消费端的批量写入:把逐条 INSERT 换成 batch insert,一次写 100 条,入库吞吐能上一个数量级。
6. 从大作业到产物,这块测试固件还有几个值得较真的细节
6.1 边缘部署的断点续传:设备侧加一个小型缓存文件
期末项目展示的是理想链路,真正做产品级健康监测系统,最担心的是设备离线几个小时,网关到云端的链路长期不稳定。常规做法是在设备端加一个环形缓冲区:写到 SPIFFS 或 LittleFS 上的一个小文件,只保留最近 N 条未确认的数据。消息带自增序号,服务端按序号判断缺失区间,主动向设备要缺失段。
用 ESP32 举例,文件系统是 LittleFS,128KB 分区能存大约 6000 条心率 JSON。写满后覆盖最老的条目——健康监测里丢失 5 分钟前的数据比丢掉当前数据可接受。重连后按“先传当前数据,再补历史数据”的顺序发,避免时序错乱。
# 伪代码来说明补传逻辑 if client.connected(): publish(live_data) for message in cached_queue: if message.seq < last_acked_seq + batch_size: client.publish(topic, message, qos=1) cached_queue.remove(message) else: breaklast_acked_seq由服务端在 SUBACK 后通过另一条 topic 返回,或者直接利用 QoS 1 的 PUBACK 回调更新本地游标。成熟方案是服务端每收到一条消息,就向ack/{deviceId}topic 发一条 ACK,设备端只删已被确认的数据。
6.2 设备端 RAM 和 Flash 的优化技巧
ESP32 也分型号,ESP32-S3 和 C3 的内存和 Flash 配置差异很大,但健康监测的数据量小,主要压力在 Wi-Fi 协议栈裸应用。优化点有三个:
- PubSubClient 默认收包缓存是 256B,一发一个含嵌套 JSON 的包就会溢出。改
MQTT_MAX_PACKET_SIZE为 1024。 - 发布字符串建议用
snprintf拼好再发,不要用 Android 习惯的 String 类型,避免堆碎裂。 - 传感器采样频率不用固定在 100Hz,根据设备状态动态切换:静息时 25Hz,运动时 50Hz。这能显著降低 MCU 负荷和链路带宽占用。
6.3 把“告警”功能做成有四象限分类的模块
健康监测系统做出来不难,难的是告警的灵敏度设置。很多项目用固定阈值——心率超过 150 就告警,但这在运动场景里是假阳性。比较好的做法是按“静息/运动/睡眠”三段式计算基线:
- 静息基线:过去 24 小时同一时段的平均心率 ± 20%
- 运动基线:连续 30 秒异常高值才触发
- 睡眠补抓:血氧低于 90% 持续时间超过 15 秒才告警
草率的实现会在代码里散落一堆if (hr > 150),正规做法是把告警规则挪到服务端用规则引擎做,设备端只负责上报原始数据。mqtt
6.4 给仿真环境加一个系统巡检脚本
项目交付前最怕的是一断网就白屏。加一个smoke_test.sh,自动检查 broker 端口、订阅连通性、数据库写入和 API 响应:
#!/bin/bash # broker 端口检查 nc -z 127.0.0.1 1883 && echo "[OK] MQTT Port 1883" || echo "[FAIL] MQTT Broker Down" # 发布一条测试消息确认订阅回路 mosquitto_pub -h 127.0.0.1 -t health/test -m "{\"hr\":70}" sleep 2 mysql -u health -phealth health_monitor -e "SELECT * FROM vitals ORDER BY id DESC LIMIT 1" \ | grep "70" && echo "[OK] Data Pipeline" || echo "[FAIL] DB Write" # 检查 API 返回 curl -s http://127.0.0.1:5000/api/vitals?minutes=1 | python -m json.tool > /dev/null \ && echo "[OK] Flask API" || echo "[FAIL] API"每一条检查命令代表一个交付链路节点。zo 环境里最有画面的一次是:杀进程模拟 server 挂掉,显示 broker 还能收到消息但数据断档——排障发现 subscriber 进程被 kill 后 QoS 1 消息在会话里堆积等重投,重启后数据库猛增几千条补录数据。正是这些细节,让“源代码+文档说明”显得不像复制粘贴的模板,而像真做过的工程。
MQTT 项目的评分点和代码量不直接相关,和链路完整性、协议理解深度、边界场景处理正相关。把上面这些细节落到文档说明里,比多写 200 行业务代码的收益更大。
本文还有配套的精品资源,点击获取