简介:本资源是一套面向物联网专业本科生的课程设计实践项目,聚焦智能家居数据上传与远程控制系统的完整实现,覆盖感知层(ZigBee+ESP32传感器节点)、网络层(MQTT协议+EMQX消息中间件)及应用层(Tomcat+Servlet后端接口+MySQL数据库+Android原生App),适合巩固物联网三层架构思想与全栈开发能力。压缩包共153个文件,含30个Java核心业务代码、55个XML配置与布局文件、46个PNG界面资源图,以及WAR部署包、Gradle构建脚本、JSP页面和Python辅助工具等,结构清晰,便于分层学习与调试;整体体积仅3.97MB,轻量易下载。已有94人学习下载,项目历时20天完成,成本可控(约180元),包含烟雾/温湿度传感采集、LED执行器控制等典型实验模块,并体现从底层通信到移动端交互的端到端闭环。读者可直接部署运行,获取完整可调试工程、分层接口文档、传感器驱动示例及Android App源码,是理解物联网系统集成与课设落地的优质参考。
1. 这不是又一个“物联网毕设模板”:它是一套能跑通 ZigBee→ESP32→MQTT→EMQX→Tomcat→MySQL→Android 全链路的课设实战组合包
你是不是也见过太多标着“物联网项目”的压缩包,解压后只有 Android Studio 工程、几个空接口和一句“数据从服务器来”?这个.zip不是。它来自某高校物联网专业大三学生的课程设计实战,20天内用不到180元硬件成本,硬生生把 ZigBee 组网采集、ESP32 边缘桥接、EMQX 消息路由、Tomcat Servlet 接口层、MySQL 数据持久化、Android App 端控——六段技术栈全部串成一条可验证、可调试、可断点的完整数据流。它不追求高并发或工业级可靠性,但每层都留了真实日志、可改参数、带注释的配置项。适合两类人:一是刚学完《嵌入式系统》《网络编程》《数据库原理》《移动应用开发》四门课,想把知识缝合成一件能穿出门的“技术外套”的学生;二是需要快速搭建教学演示环境、验证某一层(比如 MQTT 消息如何被 Tomcat Servlet 解析)的一线实训导师。它解决的不是“能不能做”,而是“怎么让每一层的数据在你眼皮底下动起来”。
2. 从 ZigBee 节点到 ESP32 桥接器:物理层数据如何被“翻译”成 MQTT 主题消息
这套课设最扎实的起点,不在 Android,而在 ZigBee 协调器与终端节点之间的真实通信。它没用 Z-Stack 官方 SDK 的黑盒封装,而是基于 CC2530 + TI Z-Stack 2.3.2a 的最小可运行固件,配合 ESP32 作为串口透传+协议转换桥接器。整个链路不是“ZigBee 直连 WiFi”,而是 ZigBee → UART → ESP32 → MQTT,中间所有字节流都暴露给你看。
2.1 ZigBee 终端节点的传感器数据帧结构与校验逻辑
ZigBee 终端(如烟雾/温湿度节点)发送的原始数据帧不是随意拼接的字符串,而是遵循课设自定义的轻量二进制协议:
[SOH:0x01][TYPE:1B][SEQ:1B][LEN:1B][PAYLOAD:NB][CHKSUM:1B][ETX:0x04]TYPE字段区分设备类型:0x01=烟雾,0x02=温湿度,0x03=LED 控制反馈SEQ是递增序列号,用于排查丢包(课设中 ESP32 会打印SEQ gap detected)CHKSUM是TYPE + SEQ + LEN + PAYLOAD所有字节的异或和,非 CRC32,简单但足够教学定位
提示:
SecondActivity.java中解析 MQTT 消息时,反向还原的就是这个帧结构。如果你看到 Android 端显示“温度: -127℃”,大概率是CHKSUM校验失败导致PAYLOAD解析错位——这是课设里第一个必须亲手修的 Bug。
2.2 ESP32 作为 ZigBee-MQTT 桥接器的核心代码逻辑
ESP32(使用 Arduino Core for ESP32)承担两个关键职责:串口监听 ZigBee 帧、构造并发布 MQTT 消息。其主循环逻辑高度聚焦,无多余抽象:
// zigbee_mqtt_bridge.ino 关键片段 #include <WiFi.h> #include <PubSubClient.h> #define ZIGBEE_SERIAL Serial2 // 使用 UART2 接 ZigBee 模块 WiFiClient espClient; PubSubClient client(espClient); void loop() { if (ZIGBEE_SERIAL.available()) { uint8_t buffer[64]; int len = ZIGBEE_SERIAL.readBytes(buffer, sizeof(buffer)-1); if (len > 0 && buffer[0] == 0x01 && buffer[len-2] == 0x04) { // SOH & ETX 匹配 uint8_t checksum = 0; for (int i = 1; i < len-2; i++) checksum ^= buffer[i]; if (checksum == buffer[len-2]) { // 校验通过 String topic = buildMqttTopic(buffer[1]); // 根据 TYPE 构建 topic String payload = extractPayload(buffer); // 提取有效载荷,转为 JSON 字符串 client.publish(topic.c_str(), payload.c_str()); } } } }buildMqttTopic()将TYPE=0x02映射为sensor/env/temperature_humidity,这是后续 EMQX 路由和 Tomcat 接口订阅的基础extractPayload()对温湿度数据执行((buffer[4]<<8)|buffer[5])/10.0计算,单位为 ℃ 和 %RH,注意:这里没有浮点运算优化,直接暴露精度损失过程,方便你理解为什么 Android 端显示“25.3℃”而实际传感器是 25.34℃client.publish()前未加重试逻辑,课设明确要求“首次失败即打印 error”,逼你手动补while(!client.connected()) reconnect();
2.3 ZigBee 协调器与 ESP32 的串口电平与波特率对齐实操
ZigBee 模块(CC2530)默认 UART 电平为 3.3V TTL,但部分国产模块出厂配置为 115200bps,而 ESP32Serial2默认初始化为 9600bps。课设文档里没写这行,但gradlew.bat同目录下有个zigbee_setup_notes.txt提到:
“协调器烧录 Z-Stack Home 1.2.2a 固件后,AT 指令
AT+BAUDRATE=1设为 9600;ESP32 的Serial2.begin(9600, SERIAL_8N1, RX_PIN, TX_PIN)必须严格匹配,否则ZIGBEE_SERIAL.available()永远返回 0。”
这不是玄学——用逻辑分析仪抓 UART 波形,你会发现波特率错 1% 就导致起始位识别漂移。课设用的是GPIO16/GPIO17作为RX/TX,对应 ESP32-WROOM-32 的 UART2,默认引脚映射,但如果你换用 ESP32-S3,必须查 datasheet 改RX_PIN/TX_PIN定义。
3. EMQX + Tomcat Servlet:消息如何从 MQTT 主题“落地”为 MySQL 可存的结构化记录
ZigBee 数据变成 MQTT 消息只是第一步。真正让物联网“有记忆”的,是这条消息如何被应用层捕获、解析、校验、落库。课设没用 Spring Boot 或 Node.js,而是用最朴素的 Tomcat 9 + Servlet 3.1 + JDBC,直面连接池、事务、SQL 注入防护等底层细节。
3.1 EMQX 的主题路由规则与权限控制配置
EMQX(课设使用 v4.4.3)不是开箱即用。emqx.conf中关键配置被显式写出:
# emqx.conf 片段 mqtt.max_packet_size = 64KB listener.tcp.external = 0.0.0.0:1883 plugins.emqx_auth_http.auth_req = http://127.0.0.1:8080/auth plugins.emqx_auth_http.acl_req = http://127.0.0.1:8080/aclauth_req指向 Tomcat 的/auth接口,接收username,password,clientid,返回200或401acl_req指向/acl,接收username,topic,action(sub/pub),返回allow或deny- 课设中所有 ZigBee 设备统一用
clientid=zigbee_bridge_001,username=iot_dev,password=123456,密码明文传输——这是教学简化,生产环境必须禁用
注意:
settings.gradle里include ':auth-servlet'模块,就是实现这两个接口的 Java 类。AuthServlet.java中doPost()方法第一行就是String username = request.getParameter("username");,没有任何 JWT 解析,纯粹 HTTP 表单提交。
3.2 Tomcat Servlet 接收 MQTT 消息的两种模式对比
课设提供了两种接收方式,均在webapp/WEB-INF/web.xml中声明:
| 接收模式 | 实现类 | 触发时机 | 适用场景 | 课设选择理由 |
|---|---|---|---|---|
| HTTP POST 回调 | MqttCallbackServlet | EMQX 配置plugins.emqx_bridge_http后,每条消息发 HTTP 请求 | 调试友好,可 curl 模拟 | ✅ 课设采用,curl -X POST http://localhost:8080/callback -d '{"topic":"sensor/env/temperature_humidity","payload":"{...}"}'即可触发 |
| MQTT Client 内嵌 | MqttSubscriberServlet | Servlet 初始化时创建MqttClient订阅主题 | 更贴近真实消费模型 | ❌ 课设注释掉,因需额外处理Connection lost重连,超出课设范围 |
MqttCallbackServlet的核心逻辑在doPost():
// MqttCallbackServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { StringBuilder json = new StringBuilder(); String line; try (BufferedReader reader = request.getReader()) { while ((line = reader.readLine()) != null) json.append(line); } JSONObject obj = new JSONObject(json.toString()); String topic = obj.getString("topic"); String payload = obj.getString("payload"); // 解析 topic 获取设备类型和位置 String[] parts = topic.split("/"); if (parts.length >= 3 && "sensor".equals(parts[0])) { String type = parts[2]; // "temperature_humidity" SensorData data = parsePayload(type, payload); // 调用具体解析方法 saveToDatabase(data); // 插入 MySQL } }parsePayload()对"temperature_humidity"类型,调用new JSONObject(payload).getDouble("temperature"),强制要求 payload 是标准 JSON,拒绝任何非 JSON 格式,这是课设设定的“数据契约”saveToDatabase()使用commons-dbutils-1.3.jar,而非 JPA/Hibernate,SQL 语句全写死在 Java 里:"INSERT INTO sensor_data (type, value, timestamp) VALUES (?, ?, ?)"—— 你一眼就能看出参数绑定顺序
3.3 MySQL 表结构设计与 JDBC 连接池配置
build.gradle中依赖mysql:mysql-connector-java:8.0.28,而数据库连接由context.xml配置:
<!-- META-INF/context.xml --> <Resource name="jdbc/IoTDB" auth="Container" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" minIdle="5" initialSize="5" maxWaitMillis="10000" username="iot_user" password="iot_pass" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/iot_db?useSSL=false&serverTimezone=UTC"/>对应的iot_db表结构极简但覆盖课设需求:
CREATE TABLE sensor_data ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type VARCHAR(50) NOT NULL COMMENT 'e.g., temperature_humidity, smoke', value DECIMAL(10,2) NOT NULL COMMENT 'parsed numeric value', unit VARCHAR(20) DEFAULT NULL COMMENT 'e.g., "℃", "%RH", "ppm"', device_id VARCHAR(50) DEFAULT 'unknown', timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 为高频查询添加复合索引 CREATE INDEX idx_type_time ON sensor_data(type, timestamp);unit字段非空,但课设中 ZigBee 温湿度节点的 payload JSON 里明确包含"unit":"℃",不是靠后端硬编码,而是从原始消息里提取,保证数据溯源device_id在课设中固定为"zigbee_node_001",但字段预留,方便你扩展多节点
4. Android App 的数据绑定与实时刷新:从 MQTT 订阅到 UI 更新的三层解耦
Android 端不是简单的TextView.setText(),而是用课设自研的轻量事件总线 + Service + BroadcastReceiver 实现“消息到达→数据解析→UI 刷新”的松耦合。SecondActivity.java是核心界面,但它不直接操作 MQTT 客户端。
4.1 MQTT 客户端封装与生命周期管理
课设没用 Eclipse Paho 的Service封装,而是将MqttAndroidClient实例放在MyApplication.java的全局static引用中,并在MainActivity启动时初始化:
// MyApplication.java public class MyApplication extends Application { public static MqttAndroidClient mqttClient; public static final String BROKER_URL = "tcp://192.168.1.100:1883"; // 课设要求手动填 IP public static final String CLIENT_ID = "android_app_" + Build.SERIAL; @Override public void onCreate() { super.onCreate(); mqttClient = new MqttAndroidClient(this, BROKER_URL, CLIENT_ID); mqttClient.setCallback(new MqttCallbackHandler()); // 自定义回调 } }MqttCallbackHandler继承MqttCallbackExtended,重写messageArrived(String topic, MqttMessage message)- 关键设计:
messageArrived()中不做 UI 操作,而是发送LocalBroadcast,避免 Activity 未启动时的空指针
4.2 LocalBroadcastReceiver 实现 UI 层解耦
SecondActivity.java注册一个LocalBroadcastReceiver,只关心特定 Action:
// SecondActivity.java private BroadcastReceiver dataReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if ("ACTION_SENSOR_UPDATE".equals(intent.getAction())) { String type = intent.getStringExtra("type"); double value = intent.getDoubleExtra("value", 0.0); String unit = intent.getStringExtra("unit"); updateUiForType(type, value, unit); // 具体更新逻辑 } } }; @Override protected void onResume() { super.onResume(); LocalBroadcastManager.getInstance(this) .registerReceiver(dataReceiver, new IntentFilter("ACTION_SENSOR_UPDATE")); } @Override protected void onPause() { super.onPause(); LocalBroadcastManager.getInstance(this) .unregisterReceiver(dataReceiver); }updateUiForType()根据type字符串(如"temperature_humidity")找到对应TextView并设置文本,不写 switch-case,而是用findViewById(R.id.tv_temp)硬编码 ID,教学目的明确:让你看清每个控件如何被数据驱动onResume/onPause的注册/注销是课设强制要求,防止内存泄漏——这是血泪经验,某次调试忘记注销,App 后台运行时SecondActivity被反复创建
4.3 实时性保障:QoS 1 与手动 ACK 的取舍
课设中 ESP32 发布 MQTT 消息时,client.publish(topic, payload, 1, false)使用 QoS 1。但 Android 端MqttAndroidClient的setCallback()中,deliveryComplete(IMqttDeliveryToken token)方法被重写为空实现:
@Override public void deliveryComplete(IMqttDeliveryToken token) { // 课设注释:QoS 1 的 PUBACK 已由 EMQX 处理,App 不需 ACK // 若需端到端确认,应在此处更新 UI 的“已送达”状态 }- 这是刻意为之:课设目标是“数据可见”,而非“数据必达”。QoS 1 保证 EMQX 收到,EMQX 到 Tomcat 是 HTTP,Tomcat 到 MySQL 是 JDBC,整条链路的信任锚点在 EMQX 的
publish日志里 - 如果你想验证,
emqx.log中搜索publish,能看到clientid=zigbee_bridge_001 topic=sensor/env/temperature_humidity qos=1的完整记录
5. 避坑 / 常见问题 / 排查:课设实操中踩过的五个真实深坑
这些不是理论假设,而是某开发者在 20 天课设周期里,凌晨三点对着日志文件逐行比对后记下的真实翻车现场。每一条都附带复现步骤、根因和绕过方案。
5.1 现象:Android App 启动后 MQTT 连接成功,但messageArrived()从不触发
原因:EMQX 的 ACL(访问控制列表)默认拒绝所有subscribe请求。课设中plugins.emqx_auth_http.acl_req指向 Tomcat 的/acl接口,但该接口返回{"result": "deny"}的静态 JSON,未根据topic动态判断。
解决:打开AclServlet.java,修改doPost()方法,在解析topic后添加白名单逻辑:
String topic = request.getParameter("topic"); if (topic != null && (topic.startsWith("sensor/") || topic.startsWith("control/"))) { response.getWriter().print("{\"result\": \"allow\"}"); } else { response.getWriter().print("{\"result\": \"deny\"}"); }提示:课设
web.xml中<servlet-mapping>的 URL-pattern 是/acl,确保请求路径匹配。
5.2 现象:ESP32 串口收到 ZigBee 数据,但client.publish()总失败,client.state()返回-1
原因:ESP32 的 WiFi 连接未稳定就尝试 MQTT 连接。WiFi.status()返回WL_CONNECTED后,还需等待 DNS 解析完成,而client.connect()内部connect()会因 DNS 超时失败。
解决:在reconnect()函数中加入 DNS 等待:
while (WiFi.status() != WL_CONNECTED || !WiFi.hostByName("192.168.1.100", ip)) { delay(500); Serial.print("."); } client.connect(CLIENT_ID, "iot_dev", "123456");5.3 现象:Tomcat 日志显示MqttCallbackServlet收到消息,但 MySQL 表中无新记录,saveToDatabase()无异常抛出
原因:commons-dbutils-1.3.jar的QueryRunner.update()方法在 SQL 错误时静默失败,不抛SQLException,只返回-1。课设中saveToDatabase()未检查返回值。
解决:在saveToDatabase()中添加返回值判断:
int rows = queryRunner.update(conn, sql, data.getType(), data.getValue(), data.getUnit()); if (rows != 1) { log.error("Failed to insert sensor data, rows affected: " + rows); throw new SQLException("Insert failed, expected 1 row, got " + rows); }5.4 现象:ZigBee 温湿度节点发送数据,Android 显示“温度: 25.3℃”,但用万用表测传感器输出电压,计算应为 25.34℃
原因:ESP32 的analogRead()采样精度为 12-bit(0-4095),但课设中 ZigBee 固件将 ADC 值直接左移 4 位再发送,导致低 4 位丢失。buffer[4]<<8|buffer[5]得到的是 16-bit 值,但高 4 位为 0。
解决:修改 ZigBee 固件的HAL_ADC_Read()调用,或在 ESP32 端extractPayload()中补偿:
uint16_t raw = (buffer[4]<<8) | buffer[5]; double temp = (raw * 3.3 / 4095.0 * 100.0); // 直接按 12-bit 满量程计算5.5 现象:gradlew build失败,报错Could not find method implementation() for arguments [file collection]
原因:build.gradle中implementation files('libs/commons-dbutils-1.3.jar')语法过时。Gradle 7.0+ 要求implementation fileTree(dir: 'libs', include: ['*.jar'])。
解决:替换build.gradle第 32 行:
// 旧写法(Gradle < 6.0) // implementation files('libs/commons-dbutils-1.3.jar') // 新写法(Gradle 7.0+) implementation fileTree(dir: 'libs', include: ['*.jar'])6. 从那以后我每次部署课设环境,都强制走一遍“五层连通性验证清单”
课设交付物不是一份 ZIP,而是一套可验证的“信任链”。我把它拆成五个原子动作,每个动作失败,都能精准定位到哪一层出了问题。这份清单不是为了炫技,而是为了在实验室里,当导师问“你的 ZigBee 数据到底卡在哪?”时,你能 30 秒内给出答案。
6.1 第一层:ZigBee 物理层连通性(5 秒验证)
用串口调试助手(如 XCOM)连接 ESP32 的Serial0(USB 转串口),波特率 115200。给 ZigBee 终端上电,观察是否持续收到类似01 02 05 04 00 F3 04的十六进制流。
- ✅ 成功:
01(SOH)02(TYPE=温湿度)05(SEQ)04(LEN)00 F3(PAYLOAD=243 → 24.3℃)04(ETX) - ❌ 失败:无输出 → 检查 ZigBee 协调器供电、ESP32
Serial2接线(TX/RX 是否反接)、ZigBee 固件是否烧录正确
6.2 第二层:MQTT 消息路由层(10 秒验证)
在 EMQX Dashboard 的WebSocket工具页(http://192.168.1.100:18083/#/tools/websocket),用clientid=test_sub订阅sensor/#,然后用另一窗口clientid=test_pub向sensor/env/temperature_humidity发送 JSON 消息。
- ✅ 成功:订阅窗口实时显示消息,且
Publish数字递增 - ❌ 失败:无消息 → 检查 EMQX
acl_req返回是否为allow,emqx.log中是否有ACL denied日志
6.3 第三层:Tomcat 接口层(15 秒验证)
用curl直接调用 Tomcat 的回调接口,绕过 MQTT:
curl -X POST http://192.168.1.100:8080/callback \ -H "Content-Type: application/json" \ -d '{"topic":"sensor/env/temperature_humidity","payload":"{\"temperature\":25.3,\"humidity\":60.5,\"unit\":\"℃\"}"}'- ✅ 成功:返回
HTTP 200,且iot_db.sensor_data表新增一行 - ❌ 失败:返回
500→ 查catalina.out,看是否ClassNotFoundException(缺mysql-connector-java.jar)或SQLException(数据库连接失败)
6.4 第四层:MySQL 数据持久层(5 秒验证)
登录 MySQL,执行:
SELECT * FROM sensor_data WHERE type = 'temperature_humidity' ORDER BY created_at DESC LIMIT 1;- ✅ 成功:返回一行,
value=25.30,unit='℃',created_at是当前时间 - ❌ 失败:无结果 → 检查 Tomcat 的
context.xml中url是否指向正确 IP,iot_user是否有INSERT权限
6.5 第五层:Android UI 层(10 秒验证)
在SecondActivity.java的updateUiForType()方法开头插入日志:
Log.d("SENSOR_UI", "Updating UI for " + type + " = " + value + unit);运行 App,观察 Logcat 是否输出该日志。
- ✅ 成功:Logcat 显示日志,且 UI 文本更新
- ❌ 失败:无日志 → 检查
LocalBroadcastManager是否在onResume()中注册,ACTION_SENSOR_UPDATE的 Intent 是否被正确发送
从那以后我每次帮同学搭环境,都强制走一遍这五步。不是为了显得多严谨,而是因为课设里任何一个环节的“黑匣子”,都会让后续所有调试变成玄学。比如你花两小时调 Android 的 MQTT,最后发现是 EMQX 的 ACL 拒绝了订阅——这种后悔药,不如一开始就不吃。希望帮到你。
本文还有配套的精品资源,点击获取