ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

工业级IoT数据链路闭环:ZigBee+ESP32+EMQX+Tomcat+Android全栈实现

2026/9/10 11:40:15 拓冰建站 浏览量
工业级IoT数据链路闭环:ZigBee+ESP32+EMQX+Tomcat+Android全栈实现 简介这是一套面向物联网开发初学者与进阶学习者的全栈实战项目资源覆盖硬件采集、协议通信、服务端处理及移动端交互全流程特别适合高校课程设计、毕业设计及嵌入式JavaAndroid综合实践。资源包含917个文件主体为203个Keil工程源码.r51/.lst/.s51、60个Java Servlet核心类与JSP页面、4个可部署war包、46张UI与架构图png以及MySQL建表脚本、EMQX配置、Tomcat部署说明等配套材料压缩包大小33.77MB。已有1100人学习下载反映出其在IoT系统集成教学中的高实用价值。读者可直接复现从ZigBee传感器节点ESP32→MQTT上报→EMQX消息路由→Tomcat Servlet业务处理→MySQL持久化→安卓App可视化控制的完整链路尤其适合理解多协议协同、跨平台数据流转与前后端联调的关键细节。1. 这不是“拼凑技术栈”的玩具项目而是一套可落地的工业级IoT数据链路闭环你手头这个.rar包里没有一句“Hello World”也没有空泛的架构图——它直接塞进你硬盘的是一个能跑通的、从 ZigBee 终端节点到安卓 App 控制界面的完整数据通路。它不教你怎么点亮 LED而是让你亲眼看到DHT11 温湿度值如何经 ZigBee 协调器透传给 ESP32ESP32 怎样用 MQTT QoS1 发送到 EMQXEMQX 的 Webhook 如何触发 Tomcat 上的 ServletServlet 又怎样用BaseDao.class封装的 JDBC 操作把数据写进 MySQL 表sensor_data最后安卓 App 通过http://192.168.1.100:8080/api/v1/latest?device_idesp32_01拉取最新记录并渲染成卡片。这不是教学 Demo它是用真实硬件coordinator.c DHT11Device.c、真实协议栈ZigBee 2007/PRO profile、真实中间件EMQX 5.7、真实 Java Web 容器Tomcat 9.0.83和真实 Android Activity 生命周期管理onResume()中轮询 API堆出来的生产就绪型链路。适合正在做智能楼宇传感器网关、农业小站边缘采集系统或实训平台二次开发的工程师——尤其当你已经卡在“MQTT 消息到了 EMQX 却没进数据库”或“安卓 App 显示空白但抓包看到 HTTP 200”这种具体断点上时这个包里的SimpleAPIExp.cspy.bat和ledDevice.c就是你的定位锚点。2. ZigBee-ESP32 协同层协调器固件解析与串口透传协议设计ZigBee 在本项目中并非独立组网而是作为 ESP32 的“外挂通信子系统”存在。核心逻辑藏在coordinator.c和DHT11Device.c两个文件中它们共同构成一个基于 Z-Stack Linux Gateway 的轻量级协调器固件。该固件不运行全功能 ZigBee 网关服务而是采用最简路径当 ZigBee 终端节点如 MQ2Device.c 描述的气体传感器入网后协调器仅将接收到的原始 APS 帧Application Support Sublayer按固定格式解包为 ASCII 字符串再通过 UART0波特率 1152008N1转发给 ESP32。这种设计规避了 ZigBee-to-WiFi 协议转换的复杂性把协议适配压力转移到 ESP32 端。2.1 coordinator.c 关键帧结构与状态机实现coordinator.c的主循环中ZComDef.h头文件定义了关键帧结构体typedef struct { uint8_t frameType; // 0x01 数据帧, 0x02 控制帧 uint16_t srcAddr; // ZigBee 短地址如 0x1234 uint8_t clusterId; // 集群IDDHT11用 0x0402温度测量 uint8_t attrId; // 属性ID0x0000 表示当前值 uint16_t value; // 原始值DHT11 温度乘以10存入 uint8_t checksum; // 前7字节异或校验 } ZigbeeFrame_t;提示checksum字段是硬编码校验非 IEEE 802.15.4 MAC 层 CRC。调试时若 ESP32 收不到数据先用逻辑分析仪抓 UART 波形确认frameType0x01且checksum计算正确——常见错误是value字段未按大端序填充。该固件使用 Z-Stack 的ZDO_RegisterForZdoCBs()注册设备入网回调并在zclSampleLight_ProcessIncomingMsg()中拦截所有ZCL_CLUSTER_ID_MS_TEMPERATURE_MEASUREMENT类型报文。实际处理逻辑被精简为// coordinator.c 片段 void zclSampleLight_ProcessIncomingMsg( zclIncomingMsg_t *pInMsg ) { if (pInMsg-clusterId ZCL_CLUSTER_ID_MS_TEMPERATURE_MEASUREMENT) { ZigbeeFrame_t frame; frame.frameType 0x01; frame.srcAddr BUILD_UINT16(pInMsg-srcAddr.addr.shortAddr); frame.clusterId pInMsg-clusterId 0xFF; frame.attrId 0x00; // 简化为只读当前值 frame.value BUILD_UINT16(pInMsg-attrCmd-attrData); // 直接取ZCL属性值 frame.checksum frame.frameType ^ frame.srcAddr ^ frame.clusterId ^ frame.attrId ^ (frame.value 0xFF) ^ ((frame.value 8) 0xFF); HalUARTWrite(HAL_UART_PORT_0, (uint8_t*)frame, sizeof(frame)); // 同步串口发送 } }2.2 ESP32 端串口解析与 MQTT 封装逻辑ESP32 固件由gradlew.bat触发编译使用 ESP-IDF v4.4.5在main.c中启动 UART 接收任务// main.c UART接收任务 static void uart_rx_task(void *pvParameters) { uint8_t rx_buffer[128]; ZigbeeFrame_t frame; while (1) { const int rxBytes uart_read_bytes(UART_NUM_0, rx_buffer, sizeof(rx_buffer), 100 / portTICK_PERIOD_MS); if (rxBytes sizeof(ZigbeeFrame_t)) { memcpy(frame, rx_buffer, sizeof(ZigbeeFrame_t)); if (frame.checksum (frame.frameType ^ frame.srcAddr ^ frame.clusterId ^ frame.attrId ^ (frame.value 0xFF) ^ ((frame.value 8) 0xFF))) { // 校验通过构造MQTT Topic char topic[64]; snprintf(topic, sizeof(topic), zigbee/%04x/%02x, frame.srcAddr, frame.clusterId); // 构造JSON Payload char payload[128]; snprintf(payload, sizeof(payload), {\value\:%d,\ts\:%ld}, frame.value, esp_timer_get_time() / 1000); mqtt_client_publish(topic, payload, strlen(payload), 1, 0); // QoS1, retain0 } } } }注意mqtt_client_publish()调用前必须确保mqtt_client_start()已成功连接 EMQX。若日志显示MQTT_CLIENT_STATE_DISCONNECTED检查sdkconfig中CONFIG_MQTT_BROKER_URImqtt://192.168.1.100:1883是否与 EMQX 实际 IP 一致更隐蔽的问题是CONFIG_MQTT_TRANSPORT_SSLy被误启用——本项目使用明文 MQTTSSL 必须关闭。2.3 ZigBee 设备节点行为验证表设备类型固件文件入网方式默认上报周期MQTT Topic 示例Payload 示例DHT11 温湿度DHT11Device.cATJOIN30秒zigbee/1234/02{value:256,ts:1712345678901}MQ2 气体传感器MQ2Device.cZDO Bind事件触发浓度阈值zigbee/5678/01{value:842,ts:1712345678902}LED 控制节点ledDevice.c手动配网不主动上报zigbee/abcd/06{cmd:ON,ts:1712345678903}验证方法在 EMQX Dashboard 的Clients页面观察esp32_coordinator客户端是否在线在Tools → WebSocket Client中订阅zigbee/#手动触发 DHT11 读取即可看到实时消息流。若无消息优先检查coordinator.c中HalUARTWrite()调用是否被注释其次确认 ESP32 的UART_NUM_0引脚GPIO3/GPIO1与协调器 TX/RX 是否交叉连接。3. EMQX-Tomcat 数据桥接Webhook 触发机制与 Servlet 参数映射EMQX 并非被动消息中转站而是本项目的数据调度中枢。它通过内置 Webhook 插件将 MQTT 消息自动转化为 HTTP POST 请求精准投递给 Tomcat 上的 Servlet。这一环节的配置错误是导致“数据进不了数据库”的最常见原因必须严格匹配webhook.conf与web.xml中的路径、方法和参数名。3.1 EMQX Webhook 配置详解与安全约束EMQX 5.7 的 Webhook 配置位于/etc/emqx/plugins/emqx_web_hook.conf关键参数如下## Webhook URL web.hook.url http://127.0.0.1:8080/servlet/DataReceiver ## 触发规则仅处理 zigbee/ 开头的 Topic web.hook.rule.message.publish [{topic: zigbee/#}] ## 请求头强制 application/json web.hook.headers.content-type application/json ## Payload 格式将 MQTT 消息体原样作为 JSON body web.hook.body {\topic\:\${topic}\,\payload\:\${payload}\,\qos\:${qos},\clientid\:\${clientid}\} ## 超时与重试 web.hook.request.timeout 5s web.hook.retry.interval 1s web.hook.retry.max_times 3提示web.hook.url必须指向 Tomcat 的实际监听地址。若 Tomcat 运行在 Docker 容器中此处不能写localhost而应使用宿主机 IP如http://172.17.0.1:8080或容器间 DNS 名如http://tomcat:8080。web.hook.body中的${payload}是 Base64 编码字符串Servlet 端需调用java.util.Base64.getDecoder().decode()解码。3.2 Servlet DataReceiver 的请求解析与 BaseDao 调用DataReceiver.java位于WEB-INF/classes/com/iot/servlet/下其doPost()方法处理 Webhook 请求// DataReceiver.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { StringBuilder jsonBuffer new StringBuilder(); String line; BufferedReader reader request.getReader(); while ((line reader.readLine()) ! null) { jsonBuffer.append(line); } JSONObject webhookJson new JSONObject(jsonBuffer.toString()); // 解析 MQTT 原始 PayloadBase64 String rawPayload webhookJson.getString(payload); byte[] decoded Base64.getDecoder().decode(rawPayload); String payloadStr new String(decoded, StandardCharsets.UTF_8); JSONObject payloadJson new JSONObject(payloadStr); // 提取关键字段映射到数据库实体 String topic webhookJson.getString(topic); // zigbee/1234/02 String deviceId topic.substring(8, 12); // 1234 String cluster topic.substring(13); // 02 int value payloadJson.getInt(value); long timestamp payloadJson.getLong(ts); // 调用 BaseDao 存储 SensorData data new SensorData(); data.setDeviceId(deviceId); data.setClusterId(cluster); data.setValue(value); data.setTimestamp(new Timestamp(timestamp)); BaseDao.getInstance().insert(INSERT INTO sensor_data(device_id, cluster_id, value, ts) VALUES(?,?,?,?), new Object[]{deviceId, cluster, value, new Timestamp(timestamp)}); response.setStatus(HttpServletResponse.SC_OK); response.getWriter().write({\status\:\success\}); }BaseDao.class是本项目的核心数据访问层其insert()方法封装了预编译 SQL 执行参数索引占位符对应值数据库列类型1?deviceIdVARCHAR(10)device_id2?clusterVARCHAR(4)cluster_id3?valueINTvalue4?new Timestamp(timestamp)tsDATETIME注意BaseDao.class文件重复出现四次表明它被多个模块Servlet、后台定时任务、管理接口共用。若修改BaseDao必须重新编译所有依赖它的 class 文件否则会出现NoSuchMethodError。建议用javap -cp WEB-INF/classes BaseDao查看其 public 方法签名确认insert(String sql, Object[] params)方法存在。3.3 Tomcat 部署与连接池配置验证Tomcat 的conf/context.xml中定义了 MySQL 连接池Resource namejdbc/IoTDB authContainer typejavax.sql.DataSource maxTotal20 maxIdle10 minIdle5 usernameiot_user passwordiot_pass123 driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://127.0.0.1:3306/iot_db?useSSLfalseamp;serverTimezoneAsia/Shanghaiamp;allowPublicKeyRetrievaltrue/验证步骤登录 MySQL执行SELECT USER(), CURRENT_USER();确认iot_user权限在 Tomcatlogs/catalina.out中搜索SEVERE确认无Cannot create JDBC driver错误访问http://localhost:8080/servlet/DataReceiverGET返回HTTP 405 Method Not Allowed即表示 Servlet 已注册成功手动发送测试 Webhookcurl -X POST http://127.0.0.1:8080/servlet/DataReceiver -H Content-Type: application/json -d {topic:zigbee/1234/02,payload:eyJ2YWx1ZSI6MjU2LCJ0cyI6MTcxMjM0NTY3ODkwMX0,qos:1,clientid:esp32_coordinator}然后查SELECT * FROM sensor_data ORDER BY ts DESC LIMIT 1;确认新记录插入。4. 安卓 App 数据消费层Retrofit 接口封装与实时刷新策略安卓 App 不是简单的 WebView 加载网页而是采用现代 Android 架构组件ViewModel LiveData Retrofit构建的响应式 UI。其核心在于SimpleAPIExp.cspy.bat所生成的 API 测试脚本它揭示了后端 Servlet 的真实接口契约——这是避免“App 显示空白”的第一道防线。4.1 Retrofit API Interface 与动态 URL 构建ApiService.java定义了与 Tomcat 交互的接口public interface ApiService { GET(api/v1/latest) CallLatestDataResponse getLatestData(Query(device_id) String deviceId); GET(api/v1/history) CallListHistoryData getHistoryData( Query(device_id) String deviceId, Query(start_ts) long startTs, Query(end_ts) long endTs ); POST(api/v1/control) CallControlResponse sendControlCommand(Body ControlCommand command); }关键点在于Query(device_id)的使用——它要求前端必须传入 ZigBee 设备的 4 位短地址如1234而非 ESP32 的 MAC 地址。getLatestData()返回的LatestDataResponse结构体与数据库sensor_data表字段严格对应{ device_id: 1234, cluster_id: 02, value: 256, ts: 1712345678901 }4.2 ViewModel 中的 LiveData 更新与生命周期绑定MainViewModel.java使用MediatorLiveData管理数据流public class MainViewModel extends ViewModel { private final MutableLiveDataLatestDataResponse latestData new MutableLiveData(); private final ApiService apiService; public MainViewModel() { apiService RetrofitClient.getInstance().getApiService(); // 初始化时加载默认设备DHT11 loadLatestData(1234); } public void loadLatestData(String deviceId) { apiService.getLatestData(deviceId).enqueue(new CallbackLatestDataResponse() { Override public void onResponse(CallLatestDataResponse call, ResponseLatestDataResponse response) { if (response.isSuccessful() response.body() ! null) { latestData.postValue(response.body()); // 主动触发UI更新 } else { // 处理HTTP错误码如404表示设备无数据 latestData.postValue(null); } } Override public void onFailure(CallLatestDataResponse call, Throwable t) { // 网络异常设置null触发错误UI latestData.postValue(null); } }); } public LiveDataLatestDataResponse getLatestData() { return latestData; } }提示latestData.postValue()必须在主线程调用enqueue()的回调已保证在主线程执行。若 UI 无响应检查Activity中observe()是否被正确调用viewModel.getLatestData().observe(this, data - { if (data ! null) { temperatureText.setText(String.valueOf(data.getValue() / 10.0)); // DHT11 value256 → 25.6°C } else { temperatureText.setText(N/A); } });4.3 安卓端网络权限与证书信任配置AndroidManifest.xml必须声明网络权限uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE /若 Tomcat 使用自签名证书常见于内网部署需在res/xml/network_security_config.xml中配置信任?xml version1.0 encodingutf-8? network-security-config domain-config domain includeSubdomainstrue192.168.1.100/domain trust-anchors certificates srcsystem / certificates srcuser / /trust-anchors /domain-config /network-security-config并在AndroidManifest.xml的application标签中引用application android:networkSecurityConfigxml/network_security_config ... 验证方法在安卓设备上打开 Chrome访问http://192.168.1.100:8080/api/v1/latest?device_id1234若返回 JSON 数据则网络连通若提示证书错误则network_security_config配置生效。5. 全链路排错技巧从 ZigBee 帧丢失到安卓 RecyclerView 空白的定位路径当整个链路某处中断不要逐个重启服务而是按数据流向逆向排查。以下是一套已被验证的五步定位法覆盖从硬件到 UI 的全部断点。5.1 ZigBee 层用逻辑分析仪捕获 UART 帧若coordinator.c无输出直接用 Saleae Logic Analyzer 抓 GPIO3ESP32 RX波形设置采样率 ≥ 1MS/s触发条件设为Falling Edge观察是否有 115200 波特率的连续数据流若无波形检查coordinator.c中HalUARTInit()是否被注释或ZStack的HAL_UART_DMA是否启用本项目禁用 DMA用轮询若有波形但内容乱码用串口助手如 XCOM以 115200 连接 ESP32 的 UART0发送ATVER确认固件版本排除波特率错配。5.2 MQTT 层EMQX Dashboard 实时监控在http://192.168.1.100:18083EMQX Dashboard中Clients标签页确认esp32_coordinator在线且Messages Sent计数递增Metrics标签页查看Message Publish曲线若为 0 则问题在 ESP32 或网络Tools → WebSocket Client订阅zigbee/#手动发布测试消息mosquitto_pub -t zigbee/1234/02 -m {value:256,ts:1712345678901}确认 Webhook 能触发 HTTP 请求Dashboard 的Webhook Logs中应有200 OK记录。5.3 Tomcat 层日志分级过滤与 SQL 追踪在logs/catalina.out中用grep -A 5 -B 5 DataReceiver过滤关键日志若出现java.sql.SQLException: Column device_id cannot be null说明webhook.conf中的${topic}解析失败检查topic字符串分割逻辑若出现org.apache.http.conn.HttpHostConnectException证明 Tomcat 无法连接 EMQX 的 Webhook URL检查web.hook.url地址及防火墙启用 MySQL 慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0;然后执行SELECT * FROM mysql.slow_log;查看INSERT INTO sensor_data是否被执行。5.4 安卓层ADB Logcat 精准过滤与网络抓包在终端执行adb logcat -v threadtime | grep -E (Retrofit|DataReceiver|MainViewModel)若输出Enqueueing request但无onResponse说明网络请求超时检查OkHttpClient的connectTimeout(30, TimeUnit.SECONDS)是否过短若输出onFailure: java.net.UnknownHostException证明 DNS 解析失败改用 IP 地址http://192.168.1.100:8080替代域名使用 Packet Capture需 root抓包确认 App 发出的 GET 请求 URL 为http://192.168.1.100:8080/api/v1/latest?device_id1234且返回200 OK及有效 JSON。5.5 数据一致性验证表跨组件字段对照组件关键字段来源数据类型示例值验证命令ZigBee 协调器srcAddrcoordinator.cBUILD_UINT16()uint16_t0x1234printf(Addr: %04x\n, frame.srcAddr);ESP32 MQTTtopicsnprintf(topic, ...)C stringzigbee/1234/02ESP_LOGI(TOPIC, %s, topic);EMQX Webhooktopic${topic}JSON stringzigbee/1234/02Dashboard Webhook LogsTomcat ServletdeviceIdtopic.substring(8,12)String1234System.out.println(Device: deviceId);MySQLdevice_idINSERT VALUESVARCHAR(10)1234SELECT device_id FROM sensor_data WHERE idLAST_INSERT_ID();安卓 Appdevice_idviewModel.loadLatestData(1234)String1234Log.d(API, Fetching for deviceId);当任一环节的值不匹配如 ZigBee 发送0x5678但 MySQL 存入5678 带空格即可锁定问题模块。本项目中BaseDao.class的insert()方法对参数数组长度敏感若传入new Object[]{1234 , 02, 256, ts}注意1234 后多空格会导致device_id字段被截断或入库失败——这是BaseDao.class重复出现四次的深层原因每个业务模块都可能引入自己的字符串处理 bug必须统一清洗。本文还有配套的精品资源点击获取