ZigBee+ESP32+MQTT+EMQX+Tomcat+MySQL+Android全链路课设实战 简介本资源是一套面向物联网专业本科生的课程设计实践项目聚焦智能家居数据上传与远程控制系统的完整实现覆盖感知层ZigBeeESP32传感器节点、网络层MQTT协议EMQX消息中间件及应用层TomcatServlet后端接口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温湿度0x03LED 控制反馈SEQ是递增序列号用于排查丢包课设中 ESP32 会打印SEQ gap detectedCHKSUM是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()将TYPE0x02映射为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 指令ATBAUDRATE1设为 9600ESP32 的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,actionsub/pub返回allow或deny课设中所有 ZigBee 设备统一用clientidzigbee_bridge_001usernameiot_devpassword123456密码明文传输——这是教学简化生产环境必须禁用注意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 回调MqttCallbackServletEMQX 配置plugins.emqx_bridge_http后每条消息发 HTTP 请求调试友好可 curl 模拟✅ 课设采用curl -X POST http://localhost:8080/callback -d {topic:sensor/env/temperature_humidity,payload:{...}}即可触发MQTT Client 内嵌MqttSubscriberServletServlet 初始化时创建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/HibernateSQL 语句全写死在 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 namejdbc/IoTDB authContainer typejavax.sql.DataSource maxTotal20 maxIdle10 minIdle5 initialSize5 maxWaitMillis10000 usernameiot_user passwordiot_pass driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/iot_db?useSSLfalseamp;serverTimezoneUTC/对应的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 是 HTTPTomcat 到 MySQL 是 JDBC整条链路的信任锚点在 EMQX 的publish日志里如果你想验证emqx.log中搜索publish能看到clientidzigbee_bridge_001 topicsensor/env/temperature_humidity qos1的完整记录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-bit0-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 的Serial0USB 转串口波特率 115200。给 ZigBee 终端上电观察是否持续收到类似01 02 05 04 00 F3 04的十六进制流。✅ 成功01(SOH)02(TYPE温湿度)05(SEQ)04(LEN)00 F3(PAYLOAD243 → 24.3℃)04(ETX)❌ 失败无输出 → 检查 ZigBee 协调器供电、ESP32Serial2接线TX/RX 是否反接、ZigBee 固件是否烧录正确6.2 第二层MQTT 消息路由层10 秒验证在 EMQX Dashboard 的WebSocket工具页http://192.168.1.100:18083/#/tools/websocket用clientidtest_sub订阅sensor/#然后用另一窗口clientidtest_pub向sensor/env/temperature_humidity发送 JSON 消息。✅ 成功订阅窗口实时显示消息且Publish数字递增❌ 失败无消息 → 检查 EMQXacl_req返回是否为allowemqx.log中是否有ACL denied日志6.3 第三层Tomcat 接口层15 秒验证用curl直接调用 Tomcat 的回调接口绕过 MQTTcurl -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;✅ 成功返回一行value25.30unit℃created_at是当前时间❌ 失败无结果 → 检查 Tomcat 的context.xml中url是否指向正确 IPiot_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 拒绝了订阅——这种后悔药不如一开始就不吃。希望帮到你。本文还有配套的精品资源点击获取