跳转到内容

状态管理逻辑

状态管理逻辑

本节介绍了资产追踪系统的状态管理逻辑,包括ESP32端的状态处理、服务器端文件管理以及竞态条件预防。学习本节将使您能够:

  • 实现ESP32端的签到/签退切换逻辑
  • 使用平面文件处理服务器端状态
  • 预防MQTT通信中的竞态条件
  • 对失败的API调用实现重试逻辑

开始本节之前,请确保您已完成:

  • 在Node-RED中实现签到/签退流程(05-08)
  • ESP32 MQTT发布功能正常(见第01章)
  • 理解基于文件的状态机制
  • 掌握JavaScript异步编程基础知识

资产追踪系统在两个层级管理状态:

状态管理层:
┌──────────────────────────────────┐
│ 层级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秒 │
└────┬─────┘ └────┬─────┘
│ │
└──────┬──────────┘
┌─────────┐
│ 空闲 │
└─────────┘

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;
}
}

防止同一标签被快速重复读取:

// 去抖配置
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);
}
}

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;

当两条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;

当前操作完成后,处理任何已排队的消息:

// 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; // 没有排队的消息

当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) {
// 根据服务器响应更新本地状态
}

症状:两个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); // 释放锁

症状:重启后文件仍然存在→已签到但未收到通知

解决方案:添加上电检查:

// 在"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()(使用非阻塞模式)
  1. 三个状态层:ESP32(本地)→ Node-RED(文件)→ TimeTagger(永久)
  2. 去抖:在5秒窗口内防止重复读取
  3. 竞态条件处理:将竞争请求排队,顺序处理
  4. 重试逻辑:对临时API故障使用指数退避
  5. 过期状态恢复:处理服务器重启而不丢失数据