物联网教学闭环:ZigBee+ESP32+MQTT+Tomcat全链路实践 简介本资源是一套面向物联网专业本科生的课程设计实践项目聚焦智能家居数据上传与远程控制系统的完整实现覆盖感知层ZigBeeESP32传感器节点、网络层MQTT协议EMQX消息中间件及应用层TomcatServlet后端接口MySQL数据库Android原生App适合巩固物联网三层架构思想与全栈开发能力。压缩包共153个文件含30个Java核心业务代码、2个WAR可部署包、55个XML配置与布局文件、46个PNG界面资源以及Gradle构建脚本、JSP页面、Python辅助工具等结构清晰便于分层学习与调试整体大小仅3.97MB轻量易导入。已有94人下载学习项目历时20天完成成本可控约180元包含烟雾/温湿度等真实传感器采集逻辑、LED执行器控制、移动端双向通信等典型场景。读者可直接复现完整链路获取从ZigBee组网、ESP32数据上报、EMQX消息路由、Servlet接口开发、MySQL数据持久化到Android App交互的全流程工程实践材料。1. 这不是拼凑技术栈一个能跑通、能调试、能讲清楚的物联网教学闭环到底长什么样“ZigBee ESP32 MQTT EMQX Tomcat Servlet MySQL Android App”——光看这个标题很多人第一反应是“又一个堆砌名词的课程包”甚至怀疑是不是把招聘JD直接当了课纲。但真实情况恰恰相反这是一套面向高校物联网实践课设计的、具备完整数据链路与教学可拆解性的最小可行闭环。它不追求商用高并发而聚焦于让学生亲手完成“传感器数据从物理层采集→无线组网→协议转换→云端接入→服务端处理→数据库持久化→移动端可视化”的全链路实操。关键在于每个环节都可单点验证、可断点调试、可替换演进ZigBee节点用CC2530或nRF52832均可ESP32只做透传桥接不写复杂业务逻辑EMQX用Docker一键启停避免学生卡在集群配置Servlet接口严格遵循RESTful风格返回JSON结构清晰MySQL表结构仅含device_id、timestamp、temperature、humidity四字段无冗余索引Android App采用原生JavaOkHttp不引入Kotlin协程或Jetpack Compose等教学干扰项。适合高校教师开课复用、实验室搭建实训平台、或自学开发者验证物联网系统分层协作逻辑。2. 从ZigBee节点到ESP32桥接物理层数据如何干净地“交棒”给IP网络ZigBee网络本身是自组织、低功耗、短距离的Mesh网络但它无法直接与互联网通信。这里的关键角色是ESP32——它不替代ZigBee协调器而是作为协议翻译桥Protocol Bridge在串口侧解析ZigBee模块如CC2530Z-Stack固件发来的AT指令或透明传输帧在Wi-Fi侧封装为MQTT消息。这种分离设计让ZigBee网络保持纯粹也避免学生陷入ZigBee路由算法或信道冲突的理论深坑。2.1 ZigBee终端节点配置用Z-Stack Home 1.2.2a实现标准数据上报我们使用CC2530Z-Stack Home 1.2.2a固件构建终端节点。重点不是写ZigBee应用层代码而是通过Z-Tool工具烧录预编译固件并修改zmain.c中以下参数// zmain.c 中关键配置段需重新编译固件 #define DEFAULT_CHANNEL 11 // 固定信道避免自动扫描耗时 #define DEFAULT_PANID 0x1234 // 统一PAN ID便于课堂统一管理 #define DEFAULT_LOGICAL_CHANNEL 11 #define DEFAULT_DEVICE_TYPE ZG_DEVICETYPE_ENDDEVICE提示不要让学生自己从零写ZigBee应用框架。提供已编译好的EndDevice_Sensor.hex固件仅需用SmartRF Flash Programmer烧录即可。终端上电后每5秒通过ZigBee网络向协调器发送一帧16进制数据7E 00 0A 01 02 03 04 05 06 07 08 09其中05 06为温度高位/低位07 08为湿度高位/低位。这是后续所有解析的基础。2.2 ESP32作为串口-MQTT桥用Arduino Core实现零依赖透传ESP32不运行ZigBee协议栈只做“字节搬运工”。我们使用Arduino IDE ESP32 Arduino Core开发核心逻辑是监听Serial2GPIO16/GPIO17收到完整帧后提取有效载荷再通过PubSubClient库发布到EMQX。关键代码如下#include WiFi.h #include PubSubClient.h // ZigBee帧解析假设帧格式为 0x7E LEN DATA CHECKSUM简化版 void parseZigbeeFrame(uint8_t *buf, size_t len) { if (len 10 || buf[0] ! 0x7E) return; // 帧头校验 uint8_t payload[8]; memcpy(payload, buf[4], 8); // 跳过帧头、长度、地址域取8字节有效载荷 // 提取温湿度示例payload[4]temp_h, payload[5]temp_l, payload[6]humi_h, payload[7]humi_l int temp (payload[4] 8) | payload[5]; int humi (payload[6] 8) | payload[7]; // 构造JSON payload String json {\device_id\:\ZB-001\,\temp\: String(temp) ,\humi\: String(humi) }; client.publish(sensor/data, json.c_str(), true); // QoS1确保送达 } void loop() { if (Serial2.available() 12) { // 等待完整帧7E LEN 8字节DATA CS uint8_t frame[12]; Serial2.readBytes(frame, 12); parseZigbeeFrame(frame, 12); } }参数说明Serial2使用GPIO16(RX2)/GPIO17(TX2)避开USB串口干扰调试client.publish(..., true)中第三个参数为retaintrue方便Android App首次连接时获取最新值温湿度单位未做标定教学场景下直接使用原始ADC值重点在链路贯通而非精度。2.3 为什么不用ESP32直接跑ZigBee——教学选型的底层逻辑有学生会问“ESP32-C3支持IEEE 802.15.4能不能直接替代CC2530”答案是能但不该在教学初期这么做。原因有三协议栈成熟度差异Z-Stack Home 1.2.2a经多年高校验证文档齐全、错误码明确ESP-IDF的802.15.4驱动仍属beta级报错信息常为MAC TX fail学生无法定位是信道冲突、功率不足还是ACK超时调试可见性断层ZigBee协调器通过Z-Tool可实时看到节点入网、LQI值、路由跳数而ESP32软实现缺乏此类可视化工具问题排查变成“黑匣子”分层教学目标本课程强调“感知层→网络层→传输层”的边界意识。让ESP32只做桥接恰好强化“ZigBee负责最后一米Wi-Fi负责最后一公里”的架构认知。3. EMQX Tomcat双服务协同消息中间件与Web容器如何各司其职很多初学者误以为“MQTT服务器就是后端服务”结果把业务逻辑全塞进EMQX的钩子Hook脚本里导致系统耦合、难以调试、无法单元测试。本方案明确划分职责EMQX只做消息路由与权限控制Tomcat只做HTTP接口与业务编排。两者通过EMQX的Web Hook或MQTT客户端桥接通信形成松耦合管道。3.1 EMQX 5.7.2 Docker部署3行命令启动教学专用实例教学环境不需集群单节点EMQX足够。使用Docker Compose启动关键配置在emqx.conf中关闭非必要功能# docker-compose.yml version: 3.8 services: emqx: image: emqx/emqx:5.7.2 ports: - 1883:1883 # MQTT - 8083:8083 # MQTT over WebSocket - 8084:8084 # HTTP API volumes: - ./emqx.conf:/opt/emqx/etc/emqx.conf environment: EMQX_LOADED_PLUGINS: emqx_management,emqx_recon,emqx_dashboard对应emqx.conf精简配置# 关闭TLS避免证书配置困扰 listener.ssl.external off # 开放匿名登录教学用生产必须禁用 allow_anonymous true # 限制最大连接数防学生误操作打满资源 zone.external.max_connections 100 # 启用Web Hook将sensor/data主题消息推给Tomcat plugins.emqx_web_hook.enable true plugins.emqx_web_hook.url http://host.docker.internal:8080/servlet/ReceiveMqttData plugins.emqx_web_hook.rules.sensor/data.1 on_message_publish注意host.docker.internal是Docker Desktop for Windows/macOS内置DNSLinux需改用宿主机IP如172.17.0.1。此配置让EMQX在每次收到sensor/data消息时以POST方式将原始MQTT Payload发往Tomcat的Servlet接口。3.2 Tomcat 9.0.83 Servlet用最朴素的方式接收并落库Tomcat不运行Spring Boot只用原生Servlet处理HTTP请求。ReceiveMqttData.java核心逻辑如下WebServlet(/servlet/ReceiveMqttData) public class ReceiveMqttData extends HttpServlet { private static final long serialVersionUID 1L; Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 读取EMQX推送的原始JSON非表单是Raw Body StringBuilder sb new StringBuilder(); String line; try (BufferedReader reader req.getReader()) { while ((line reader.readLine()) ! null) { sb.append(line); } } String jsonPayload sb.toString(); // 2. 解析JSON用org.json不引入Jackson等重型库 JSONObject obj new JSONObject(jsonPayload); String deviceId obj.getString(device_id); int temp obj.getInt(temp); int humi obj.getInt(humi); long timestamp System.currentTimeMillis(); // 3. 插入MySQL使用Druid连接池避免学生手写JDBC DriverManager String sql INSERT INTO sensor_data (device_id, temperature, humidity, create_time) VALUES (?, ?, ?, ?); try (Connection conn DruidDataSource.getInstance().getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, deviceId); ps.setInt(2, temp); ps.setInt(3, humi); ps.setLong(4, timestamp); ps.executeUpdate(); } // 4. 返回200告诉EMQX已成功消费 resp.setStatus(HttpServletResponse.SC_OK); resp.getWriter().write({\status\:\success\}); } }关键细节req.getReader()读取Raw Body而非getParameter()后者只适用于application/x-www-form-urlencodedDruidDataSource.getInstance()是单例连接池初始化代码放在ServletContextListener中避免每次请求新建连接表名sensor_data与字段名严格小写规避MySQL在Linux大小写敏感模式下的坑。3.3 为什么不用EMQX规则引擎直接写库——教学系统的“可打断”原则EMQX 5.x提供SQL规则引擎支持INSERT INTO mysql::sensor_data ...。但教学中我们坚持用Servlet中转理由很实在可打断调试学生可在Servlet中加断点查看jsonPayload原始内容、检查temp是否溢出、确认device_id格式是否一致而规则引擎日志只显示“SQL executed”失败时只报mysql error 1064无法定位是字段名错还是类型不匹配职责显性化让学生看清“消息来了→我收到了→我解析了→我存库了”四步动作而不是“消息来了→自动存了”演进友好后续若要增加告警逻辑如温度40℃发邮件只需在Servlet中加几行代码若用规则引擎就得学EMQX的$action语法和$connect事件绑定陡增学习曲线。4. MySQL建模与Android App对接数据库怎么设才不让学生查不到数据数据库不是越大越好教学库的核心指标是字段可理解、查询可预期、错误可复现。本方案MySQL表结构仅4字段但每个字段都承载明确的教学意图Android App不追求UI炫酷而聚焦于“如何用最简代码拿到最新一条记录”。4.1 sensor_data表设计去掉所有“看起来很专业”的冗余CREATE TABLE sensor_data ( id bigint NOT NULL AUTO_INCREMENT, device_id varchar(32) NOT NULL COMMENT 设备唯一标识如 ZB-001, temperature int NOT NULL COMMENT 温度原始值ADC单位0.1℃, humidity int NOT NULL COMMENT 湿度原始值ADC单位1%RH, create_time bigint NOT NULL COMMENT 毫秒时间戳, PRIMARY KEY (id), KEY idx_device_time (device_id,create_time) -- 按设备时间查询最快 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;设计理由不用TIMESTAMP类型避免时区转换问题学生常设错MySQL时区导致NOW()与JavaSystem.currentTimeMillis()差8小时create_time用BIGINT存毫秒与AndroidSystem.currentTimeMillis()完全对齐跨端计算无歧义复合索引idx_device_time覆盖90%查询场景如“查ZB-001最近10条”且不额外建created_at字段减少概念混淆。4.2 Android App核心逻辑用OkHttp同步GET拿最新数据拒绝RxJava玄学App采用原生Java非Kotlin不引入任何响应式框架。MainActivity.java中关键代码private void fetchLatestData() { String url http://192.168.3.10:8080/servlet/QueryLatest?device_idZB-001; OkHttpClient client new OkHttpClient(); Request request new Request.Builder() .url(url) .build(); try (Response response client.newCall(request).execute()) { if (response.isSuccessful() response.body() ! null) { String json response.body().string(); JSONObject obj new JSONObject(json); int temp obj.getInt(temperature); int humi obj.getInt(humidity); long time obj.getLong(create_time); // 更新UITextView tvTemp.setText(温度: (temp / 10.0) ℃); // 还原0.1℃单位 tvHumi.setText(湿度: humi %RH); tvTime.setText(更新于: formatTime(time)); } } catch (Exception e) { Log.e(MainActivity, Fetch failed, e); Toast.makeText(this, 请求失败 e.getMessage(), Toast.LENGTH_SHORT).show(); } }对应Tomcat端QueryLatest.javaServletWebServlet(/servlet/QueryLatest) public class QueryLatest extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String deviceId req.getParameter(device_id); if (deviceId null || deviceId.trim().isEmpty()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, missing device_id); return; } String sql SELECT temperature, humidity, create_time FROM sensor_data WHERE device_id ? ORDER BY id DESC LIMIT 1; try (Connection conn DruidDataSource.getInstance().getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, deviceId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { JSONObject result new JSONObject(); result.put(temperature, rs.getInt(temperature)); result.put(humidity, rs.getInt(humidity)); result.put(create_time, rs.getLong(create_time)); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write(result.toString()); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND, no data for deviceId); } } } } }血泪经验Android端URL写死192.168.3.10EMQX/Tomcat宿主机IP不写localhost或127.0.0.1那是手机自身formatTime(long ms)方法必须用SimpleDateFormat配合TimeZone.getTimeZone(GMT8)否则new Date(ms)在不同手机上显示时间不一致resp.sendError()比e.printStackTrace()对学生更友好——看到“no data for ZB-001”就知道先去Z-Tool查节点是否在线。4.3 避坑常见问题与排查清单现象原因解决Android App一直显示“请求失败Failed to connect to /192.168.3.10:8080”手机与开发机不在同一局域网或Windows防火墙拦截了8080端口或Tomcat未监听0.0.0.0:8080默认只监听127.0.0.1在Tomcatserver.xml中确认Connector port8080 address0.0.0.0关闭Windows防火墙或添加入站规则用手机浏览器访问http://192.168.3.10:8080确认连通性MySQL中sensor_data表有数据但Android查不到最新一条Servlet中ORDER BY id DESC LIMIT 1正确但id是自增主键而ZigBee节点可能离线重连导致id不连续或device_id大小写不一致如数据库存zb-001查询传ZB-001改用ORDER BY create_time DESC LIMIT 1在SQL中加LOWER(device_id) LOWER(?)或统一约定全大写EMQX Web Hook无日志Tomcat Servlet收不到请求emqx.conf中plugins.emqx_web_hook.url配置的host.docker.internal在Linux Docker中不可用或EMQX容器无法解析该域名Linux下改用宿主机真实IP如172.17.0.1或在docker-compose.yml中加extra_hosts: [host.docker.internal:host-gateway]Docker 20.10ZigBee节点数据上传后MySQL中temperature值异常巨大如65535ZigBee固件发送的是unsigned int 16位但JavagetInt()按signed int解析最高位为1时转为负数再自动补码在Servlet中改用rs.getShort(temperature) 0xFFFF强制转为无符号16位整数Android App首次启动时Toast显示“no data for ZB-001”但Z-Tool显示节点已入网数据库确实为空——因为ZigBee节点刚上电数据尚未经过ESP32→EMQX→Tomcat链路到达MySQL或EMQX Web Hook未启用在EMQX Dashboard的Web Hook页面确认状态为enabled用mosquitto_sub -t sensor/data -v手动订阅确认消息是否发出5. 从“能跑通”到“能讲透”用三层日志体系定位每一环的沉默故障一个物联网教学系统最怕的不是报错而是“静默失败”——数据在某一层被丢弃却没有任何日志提示。本方案建立设备层→网络层→服务层三级日志体系让每个环节的沉默都有迹可循。这不是为了炫技而是让学生养成“故障必有出口”的工程直觉。5.1 设备层日志ZigBee协调器与ESP32串口的双向印证ZigBee协调器CC2530本身不输出日志但我们用Z-Tool的Packet Sniffer功能捕获空中帧并导出为.csv。关键列包括Time,SrcAddr,DstAddr,Data。正常帧应类似12:05:23.142,0000,1234,7E 00 0A 01 02 03 04 05 06 07 08 09同时在ESP32代码中加入串口打印void loop() { if (Serial2.available() 12) { uint8_t frame[12]; Serial2.readBytes(frame, 12); // 【新增】设备层日志打印原始字节流 Serial.print(RX from ZigBee: ); for (int i 0; i 12; i) { Serial.printf(%02X , frame[i]); } Serial.println(); parseZigbeeFrame(frame, 12); } }价值当Android显示“无数据”时学生先看ESP32串口监视器。如果看到RX from ZigBee: 7E 00 0A ...说明ZigBee链路正常如果空白则问题在ZigBee侧节点没电、PAN ID不匹配、协调器未启动如果看到乱码如FF FF FF...则是串口波特率不匹配ZigBee模块常用115200ESP32需Serial2.begin(115200)。5.2 网络层日志EMQX Dashboard与MQTT.fx的交叉验证EMQX Dashboard的Metrics页显示Messages Received、Messages Published但这是总量。教学需要按主题、按客户端的实时观测。我们要求学生安装MQTT.fx轻量级桌面客户端配置如下Broker Address:192.168.3.10Port:1883Client ID:teacher_monitor避免与ESP32冲突Subscribe Topic:sensor/data当ESP32发布消息时MQTT.fx立即显示[12:08:33] sensor/data - {device_id:ZB-001,temp:256,humi:45}技巧在EMQX Dashboard的Clients页搜索esp32_bridge可看到该客户端的Received Messages计数是否递增。若Dashboard显示Received100而MQTT.fx收不到说明EMQX ACL访问控制列表阻止了订阅若MQTT.fx收到但Tomcat无日志则问题在Web Hook配置。5.3 服务层日志Tomcat access.log与自定义业务日志的黄金组合Tomcat自带logs/access_log.yyyy-mm-dd记录每次HTTP请求。一行典型日志192.168.3.20 - - [05/Jul/2024:12:10:22 0800] POST /servlet/ReceiveMqttData HTTP/1.1 200 22其中192.168.3.20是EMQX容器IP200表示Servlet成功返回。但200不保证业务成功——可能JSON解析失败、数据库插入失败。因此我们在Servlet中添加业务日志// 在ReceiveMqttData.java开头 private static final Logger logger LoggerFactory.getLogger(ReceiveMqttData.class); // 在doPost内解析JSON后 logger.info(Received from EMQX: {}, jsonPayload); // 在数据库插入后 logger.info(Saved to DB: device_id{}, temp{}, humi{}, deviceId, temp, humi);日志输出到logs/catalina.out格式为INFO c.e.s.ReceiveMqttData - Received from EMQX: {device_id:ZB-001,temp:256,humi:45} INFO c.e.s.ReceiveMqttData - Saved to DB: device_idZB-001, temp256, humi45排查心法若access_log有记录但catalina.out无Received from EMQX说明EMQX发出了请求但Tomcat未收到网络不通或端口错若catalina.out有Received但无Saved说明JSON解析或数据库操作出错此时catalina.out必有ERROR堆栈若Saved日志出现但Android查不到说明QueryLatestServlet未被调用检查Android URL是否拼错路径。5.4 一个真实翻车案例时间戳错位引发的“数据存在但查不到”某次实验学生发现MySQL里明明有100条记录但Android始终显示“no data for ZB-001”。三层日志排查过程如下设备层ESP32串口打印正常RX from ZigBee持续输出网络层MQTT.fx稳定收到sensor/data消息EMQX Clients页显示Published100服务层access_log有100次POST /servlet/ReceiveMqttDatacatalina.out有100行Saved to DB最终在catalina.out中发现蛛丝马迹INFO ... Saved to DB: device_idZB-001, temp256, humi45 ... INFO ... Saved to DB: device_idZB-001, temp256, humi45所有create_time值都是1719993600000对应2024-07-05 00:00:00而非当前时间。根源在ESP32代码中long timestamp millis(); // 错millis()是开机毫秒非绝对时间应改为// 从NTP服务器获取时间或直接用EMQX Web Hook自带的timestamp字段 // 教学简化由Tomcat生成System.currentTimeMillis()于是我们在Servlet中删除前端传来的create_time强制用System.currentTimeMillis()。问题解决。这个案例教会学生时间不是理所当然的每一层的时间源都需校验。这也是为什么我们坚持create_time用BIGINT而非TIMESTAMP——避免隐式转换带来的时区幻觉。希望帮到你。本文还有配套的精品资源点击获取