状态管理逻辑
状态管理逻辑
本节介绍了资产追踪系统的状态管理逻辑,包括ESP32端的状态处理、服务器端文件管理以及竞态条件预防。学习本节将使您能够:
- 实现ESP32端的签到/签退切换逻辑
- 使用平面文件处理服务器端状态
- 预防MQTT通信中的竞态条件
- 对失败的API调用实现重试逻辑
开始本节之前,请确保您已完成:
- 在Node-RED中实现签到/签退流程(05-08)
- ESP32 MQTT发布功能正常(见第01章)
- 理解基于文件的状态机制
- 掌握JavaScript异步编程基础知识
状态管理层级
Section titled “状态管理层级”资产追踪系统在两个层级管理状态:
状态管理层:┌──────────────────────────────────┐│ 层级1:ESP32(本地) ││ ┌────────────────────────────┐ ││ │ • ledState(哪个LED亮起) │ ││ │ • lastTagUID(去抖动) │ ││ │ • connectionStatus │ ││ └────────────────────────────┘ │├──────────────────────────────────┤│ 层级2:Node-RED(服务器) ││ ┌────────────────────────────┐ ││ │ • timerecord.txt 文件 │ ││ │ • API认证令牌 │ ││ │ • MQTT订阅缓存 │ ││ └────────────────────────────┘ │├──────────────────────────────────┤│ 层级3:TimeTagger(最终存储) ││ ┌────────────────────────────┐ ││ │ • 所有已完成的记录 │ ││ │ • 报告数据 │ ││ │ • 历史追踪 │ ││ └────────────────────────────┘ │└──────────────────────────────────┘- 层级1(ESP32):管理LED、去抖定时器、连接状态
- 层级2(Node-RED):通过文件系统管理待处理的签到记录
- 层级3(TimeTagger):永久存储已完成的记录
完整状态转换图:
┌─────────┐ │ 空闲 │ │ (等待) │ └────┬─────┘ │ 检测到标签 ▼ ┌───────────────┐ │ MQTT 发布 │ │ 标签UID+名称 │ └───────┬───────┘ │ ┌──────▼───────┐ │ 等待确认 ACK │ │ (MQTT消息) │ └──────┬───────┘ │ ┌─────────┴──────────┐ │ │ ▼ ▼┌──────────┐ ┌──────────┐│ 签到 │ │ 签退 ││ 成功 │ │ 成功 │├──────────┤ ├──────────┤│ 绿灯亮 │ │ 红灯亮 ││ 持续3秒 │ │ 持续3秒 │└────┬─────┘ └────┬─────┘ │ │ └──────┬──────────┘ ▼ ┌─────────┐ │ 空闲 │ └─────────┘步骤1:ESP32签到状态追踪
Section titled “步骤1:ESP32签到状态追踪”ESP32维护本地状态以跟踪操作是否进行中:
// ESP32状态变量bool isProcessing = false; // 防止多个并发操作unsigned long lastTagTime = 0; // 去抖时间戳String lastProcessedUID = ""; // 最后处理的标签UID
// MQTT回调void callback(char* topic, byte* payload, unsigned int length) { String message; for (int i = 0; i < length; i++) { message += (char)payload[i]; }
// 解析来自Node-RED的反馈消息 // 预期格式:{"action":"CHECK_IN","name":"ESP32_TEAM",...} DynamicJsonDocument doc(256); deserializeJson(doc, message);
String action = doc["action"] | "";
if (action == "CHECK_IN") { // 绿灯表示签到成功 digitalWrite(LED_GREEN, HIGH); delay(3000); digitalWrite(LED_GREEN, LOW); isProcessing = false; } else if (action == "CHECK_OUT") { // 红灯表示签退成功 digitalWrite(LED_RED, HIGH); delay(3000); digitalWrite(LED_RED, LOW); isProcessing = false; }}步骤2:RFID读取去抖逻辑
Section titled “步骤2:RFID读取去抖逻辑”防止同一标签被快速重复读取:
// 去抖配置const unsigned long DEBOUNCE_TIME = 5000; // 5秒const unsigned long PROCESSING_TIMEOUT = 15000; // 最长15秒
void handleRFIDTag() { String uid = readTagUID();
if (uid.length() > 0) { unsigned long now = millis();
// 去抖检查:同一标签在去抖时间内 if (uid == lastProcessedUID && (now - lastTagTime) < DEBOUNCE_TIME) { Serial.println("去抖:忽略重复读取"); return; }
// 检查是否正在处理中 if (isProcessing) { Serial.println("忙碌:仍在等待上一操作完成"); return; }
// 检查处理超时 if (isProcessing && (now - lastTagTime) > PROCESSING_TIMEOUT) { Serial.println("超时:重置处理状态"); isProcessing = false; }
// 处理标签 lastProcessedUID = uid; lastTagTime = now; isProcessing = true;
// 发布到MQTT publishTagData(uid); }}步骤3:服务器端文件状态管理
Section titled “步骤3:服务器端文件状态管理”Node-RED流程管理JSON平面文件:
// Function节点:"文件状态管理器"// 处理所有文件操作并包含错误处理
const fs = require('fs');const path = '/data/';
// 文件配置const FILE_NAME = 'timerecord.txt';const FILE_PATH = path + FILE_NAME;
// 检查文件状态function checkFileState(filePath) { try { fs.accessSync(filePath, fs.constants.F_OK); return 'EXISTS'; } catch (err) { return 'NOT_FOUND'; }}
// 读取并解析记录文件function readRecordFile(filePath) { try { const content = fs.readFileSync(filePath, 'utf8'); const records = JSON.parse(content); if (Array.isArray(records) && records.length > 0) { return records[0]; // 返回第一条记录 } return null; } catch (err) { node.error('读取记录文件失败:' + err.message); return null; }}
// 写入记录到文件function writeRecordFile(filePath, record) { try { const content = JSON.stringify([record]); fs.writeFileSync(filePath, content, 'utf8'); return true; } catch (err) { node.error('写入记录文件失败:' + err.message); return false; }}
// 删除记录文件function deleteRecordFile(filePath) { try { fs.unlinkSync(filePath); return true; } catch (err) { node.error('删除记录文件失败:' + err.message); return false; }}
// 根据文件状态决定操作const fileState = checkFileState(FILE_PATH);
if (fileState === 'NOT_FOUND') { // --- 签到 --- msg.action = 'CHECK_IN'; msg.record.t1 = Math.floor(Date.now() / 1000);
if (writeRecordFile(FILE_PATH, msg.record)) { msg.success = true; msg.feedback = { action: 'CHECK_IN', name: msg.record.ds }; } else { msg.success = false; msg.error = '创建记录文件失败'; }} else { // --- 签退 --- const storedRecord = readRecordFile(FILE_PATH);
if (storedRecord) { msg.action = 'CHECK_OUT'; msg.storedRecord = storedRecord; msg.currentTime = Math.floor(Date.now() / 1000); msg.success = true; } else { msg.success = false; msg.error = '找到文件但无法读取记录'; }}
return msg;步骤4:竞态条件预防
Section titled “步骤4:竞态条件预防”当两条MQTT消息快速连续到达时,可能发生竞态条件:
// Function节点:"竞态条件处理器"// 防止同一标签的并发处理
const processingState = context.get('processingState') || { isProcessing: false, lastProcessedUID: '', processingStartTime: 0, timeout: 15000};
const currentUID = msg.payload?.uid || '';const now = Date.now();
// 检查超时if (processingState.isProcessing) { const elapsed = now - processingState.processingStartTime; if (elapsed > processingState.timeout) { // 超时 — 强制重置 node.warn('UID处理超时:' + processingState.lastProcessedUID); processingState.isProcessing = false; }}
// 检查并发处理if (processingState.isProcessing && currentUID !== processingState.lastProcessedUID) { // 正在处理不同标签 — 将此标签加入队列 msg.status = 'QUEUED'; node.warn('另一个标签正在处理中。排队:' + currentUID);
// 存储以供后续处理 const queue = context.get('queue') || []; queue.push(msg); context.set('queue', queue);
return null; // 暂不处理}
// 开始处理processingState.isProcessing = true;processingState.lastProcessedUID = currentUID;processingState.processingStartTime = now;context.set('processingState', processingState);
// 存储当前消息以供处理完成后清理context.set('currentMsg', msg);
return msg;步骤5:延迟操作的队列处理
Section titled “步骤5:延迟操作的队列处理”当前操作完成后,处理任何已排队的消息:
// Function节点:"处理队列"// 完成一个操作后,处理下一个排队消息
const queue = context.get('queue') || [];
// 重置处理状态context.set('processingState', { isProcessing: false, lastProcessedUID: '', processingStartTime: 0, timeout: 15000});
// 处理队列中的下一个if (queue.length > 0) { const nextMsg = queue.shift(); context.set('queue', queue); node.warn('处理排队标签:' + nextMsg.payload?.uid); return nextMsg;}
return null; // 没有排队的消息步骤6:API调用失败重试逻辑
Section titled “步骤6:API调用失败重试逻辑”当TimeTagger API临时不可用时实现重试:
// Function节点:"API重试逻辑"// 使用指数退避重试失败的API调用
const maxRetries = 3;const retryCount = msg.retryCount || 0;
if (msg.statusCode === 503 || msg.statusCode === 502 || msg.statusCode === 0) { // 服务器错误或超时 — 使用退避重试 if (retryCount < maxRetries) { const delay = Math.pow(2, retryCount) * 1000; // 1秒、2秒、4秒 msg.retryCount = retryCount + 1; msg.retryDelay = delay;
node.warn(`API重试 ${retryCount + 1}/${maxRetries},${delay}毫秒后`);
// 发送到延迟节点进行重试 return msg; } else { node.error(`API在${maxRetries}次重试后仍失败`); msg.apiFailed = true; // 保留文件以便后续手动处理 return msg; }}
// 成功 — 继续return msg;测试状态管理逻辑:
// 上传包含状态管理的ESP32代码// 1. 测试快速连续读取// 将标签放在读卡器上,移开,5秒内再次靠近// 预期:第二次读取被忽略(去抖)
// 2. 测试签到→签退循环// 扫描标签→绿灯亮→扫描同一标签→红灯亮// 预期:完整循环正常工作
// 3. 测试处理超时// 扫描标签,在15秒内关闭Node-RED// 预期:ESP32在超时后重置处理状态
// 4. 测试队列处理// 扫描标签A,立即扫描标签B// 预期:A先处理,B随后处理预期行为:
| 场景 | 预期结果 |
|---|---|
| 同一标签5秒内出现两次 | 第二次被忽略(去抖) |
| 标签A→处理→再次标签A | 收尾操作→签退 |
| 处理中标签A→标签B出现 | B排队,在A之后处理 |
| 签退时API不可用 | 重试3次,保留文件供手动处理 |
问题1:ESP32与服务器之间的状态漂移
Section titled “问题1:ESP32与服务器之间的状态漂移”症状:ESP32认为标签已签到,但服务器显示相反
解决方案:实现状态同步:
// ESP32:启动时请求当前状态void requestStateSync() { client.publish("asset/tracking/sync", "REQUEST");}
// 在回调中处理同步响应if (strcmp(topic, "asset/tracking/state") == 0) { // 根据服务器响应更新本地状态}问题2:文件锁争用
Section titled “问题2:文件锁争用”症状:两个Node-RED流程同时尝试写入同一文件
解决方案:使用Node-RED内置锁定机制:
// 使用基于上下文的锁定const lock = context.get('fileLock');if (lock && (Date.now() - lock) < 5000) { node.warn('文件已锁定,操作排队中'); return msg; // 路由到队列}context.set('fileLock', Date.now());// ...执行文件操作...context.set('fileLock', 0); // 释放锁问题3:Node-RED重启后缺失签退
Section titled “问题3:Node-RED重启后缺失签退”症状:重启后文件仍然存在→已签到但未收到通知
解决方案:添加上电检查:
// 在"Node-RED启动"触发的流程中const fs = require('fs');const filePath = '/data/timerecord.txt';
try { fs.accessSync(filePath, fs.constants.F_OK); node.warn('警告:发现过期签到记录:' + filePath); node.warn('下次扫描RFID标签时将使用该文件'); // 向管理员发送通知} catch (err) { // 没有过期文件 — 正常运行}- ✅ 推荐:始终在ESP32端包含去抖逻辑
- ✅ 推荐:使用处理超时从故障中恢复
- ✅ 推荐:对API调用实现指数退避重试
- ❌ 避免:仅依赖ESP32状态——优先使用服务器权威状态管理
- ❌ 避免:在MQTT发布期间阻塞ESP32 loop()(使用非阻塞模式)
- 三个状态层:ESP32(本地)→ Node-RED(文件)→ TimeTagger(永久)
- 去抖:在5秒窗口内防止重复读取
- 竞态条件处理:将竞争请求排队,顺序处理
- 重试逻辑:对临时API故障使用指数退避
- 过期状态恢复:处理服务器重启而不丢失数据