Skip to content

State Management Logic

State Management Logic

This section covers the state management logic for the asset tracking system, including the ESP32-side state handling, server-side file management, and race condition prevention. Learning this section will enable you to:

  • Implement ESP32-side check-in/check-out toggle logic
  • Handle server-side state with flat files
  • Prevent race conditions in MQTT communication
  • Implement retry logic for failed API calls

Before starting this section, ensure you have:

  • Check-in/check-out flow implemented in Node-RED (05-08)
  • ESP32 MQTT publishing working (see Chapter 01)
  • Understanding of the file-based state mechanism
  • Basic knowledge of JavaScript async patterns

The asset tracking system manages state at two levels:

State Management Layers:
┌──────────────────────────────────┐
│ Layer 1: ESP32 (Local) │
│ ┌────────────────────────────┐ │
│ │ • ledState (which LED on) │ │
│ │ • lastTagUID (debounce) │ │
│ │ • connectionStatus │ │
│ └────────────────────────────┘ │
├──────────────────────────────────┤
│ Layer 2: Node-RED (Server) │
│ ┌────────────────────────────┐ │
│ │ • timerecord.txt file │ │
│ │ • API auth tokens │ │
│ │ • MQTT subscription cache │ │
│ └────────────────────────────┘ │
├──────────────────────────────────┤
│ Layer 3: TimeTagger (Final) │
│ ┌────────────────────────────┐ │
│ │ • All completed records │ │
│ │ • Reporting data │ │
│ │ • Historical tracking │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
  • Layer 1 (ESP32): Manages LEDs, debounce timers, connection state
  • Layer 2 (Node-RED): Manages pending check-ins via file system
  • Layer 3 (TimeTagger): Stores completed records permanently
Complete State Transition Diagram:
┌─────────┐
│ IDLE │
│ (Waiting)│
└────┬─────┘
│ Tag Detected
┌───────────────┐
│ MQTT Publish │
│ Tag UID+Name │
└───────┬───────┘
┌──────▼───────┐
│ Wait for ACK │
│ (MQTT msg) │
└──────┬───────┘
┌─────────┴──────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ CHECK-IN │ │ CHECK-OUT│
│ Success │ │ Success │
├──────────┤ ├──────────┤
│ LED GREEN│ │ LED RED │
│ ON 3 sec │ │ ON 3 sec │
└────┬─────┘ └────┬─────┘
│ │
└──────┬──────────┘
┌─────────┐
│ IDLE │
└─────────┘

The ESP32 maintains a local state to track whether operations are in progress:

// ESP32 State Variables
bool isProcessing = false; // Prevents multiple concurrent operations
unsigned long lastTagTime = 0; // Debounce timestamp
String lastProcessedUID = ""; // Last processed tag UID
// MQTT Callback
void callback(char* topic, byte* payload, unsigned int length) {
String message;
for (int i = 0; i < length; i++) {
message += (char)payload[i];
}
// Parse the feedback message from Node-RED
// Expected: {"action":"CHECK_IN","name":"ESP32_TEAM",...}
DynamicJsonDocument doc(256);
deserializeJson(doc, message);
String action = doc["action"] | "";
if (action == "CHECK_IN") {
// Green LED indicates successful check-in
digitalWrite(LED_GREEN, HIGH);
delay(3000);
digitalWrite(LED_GREEN, LOW);
isProcessing = false;
}
else if (action == "CHECK_OUT") {
// Red LED indicates successful check-out
digitalWrite(LED_RED, HIGH);
delay(3000);
digitalWrite(LED_RED, LOW);
isProcessing = false;
}
}

Prevent rapid re-reads of the same tag:

// Debounce configuration
const unsigned long DEBOUNCE_TIME = 5000; // 5 seconds
const unsigned long PROCESSING_TIMEOUT = 15000; // 15 seconds max
void handleRFIDTag() {
String uid = readTagUID();
if (uid.length() > 0) {
unsigned long now = millis();
// Check debounce: same tag within debounce period
if (uid == lastProcessedUID && (now - lastTagTime) < DEBOUNCE_TIME) {
Serial.println("Debounce: ignoring repeated read");
return;
}
// Check if already processing
if (isProcessing) {
Serial.println("Busy: still waiting for previous operation");
return;
}
// Check for processing timeout
if (isProcessing && (now - lastTagTime) > PROCESSING_TIMEOUT) {
Serial.println("Timeout: resetting processing state");
isProcessing = false;
}
// Process the tag
lastProcessedUID = uid;
lastTagTime = now;
isProcessing = true;
// Publish to MQTT
publishTagData(uid);
}
}

The Node-RED flow manages the JSON flat file:

// Function Node: "File State Manager"
// Handles all file operations with error handling
const fs = require('fs');
const path = '/data/';
// File configuration
const FILE_NAME = 'timerecord.txt';
const FILE_PATH = path + FILE_NAME;
// Check file state
function checkFileState(filePath) {
try {
fs.accessSync(filePath, fs.constants.F_OK);
return 'EXISTS';
} catch (err) {
return 'NOT_FOUND';
}
}
// Read and parse the record file
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 the first record
}
return null;
} catch (err) {
node.error('Failed to read record file: ' + err.message);
return null;
}
}
// Write record to file
function writeRecordFile(filePath, record) {
try {
const content = JSON.stringify([record]);
fs.writeFileSync(filePath, content, 'utf8');
return true;
} catch (err) {
node.error('Failed to write record file: ' + err.message);
return false;
}
}
// Delete the record file
function deleteRecordFile(filePath) {
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
node.error('Failed to delete record file: ' + err.message);
return false;
}
}
// Determine action based on file state
const fileState = checkFileState(FILE_PATH);
if (fileState === 'NOT_FOUND') {
// --- CHECK-IN ---
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 = 'Failed to create record file';
}
} else {
// --- CHECK-OUT ---
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 = 'Found file but could not read record';
}
}
return msg;

Race conditions can occur when two MQTT messages arrive in quick succession:

// Function Node: "Race Condition Handler"
// Prevents concurrent processing of the same tag
const processingState = context.get('processingState') || {
isProcessing: false,
lastProcessedUID: '',
processingStartTime: 0,
timeout: 15000
};
const currentUID = msg.payload?.uid || '';
const now = Date.now();
// Check timeout
if (processingState.isProcessing) {
const elapsed = now - processingState.processingStartTime;
if (elapsed > processingState.timeout) {
// Timeout — force reset
node.warn('Processing timeout for UID: ' + processingState.lastProcessedUID);
processingState.isProcessing = false;
}
}
// Check for concurrent processing
if (processingState.isProcessing &&
currentUID !== processingState.lastProcessedUID) {
// Different tag is being processed — queue this one
msg.status = 'QUEUED';
node.warn('Another tag is being processed. Queueing: ' + currentUID);
// Store for later processing
const queue = context.get('queue') || [];
queue.push(msg);
context.set('queue', queue);
return null; // Don't process now
}
// Start processing
processingState.isProcessing = true;
processingState.lastProcessedUID = currentUID;
processingState.processingStartTime = now;
context.set('processingState', processingState);
// Store for cleanup after processing
context.set('currentMsg', msg);
return msg;

Step 5: Queue Processing for Delayed Operations

Section titled “Step 5: Queue Processing for Delayed Operations”

After the current operation completes, process any queued messages:

// Function Node: "Process Queue"
// After completing an operation, process the next queued message
const queue = context.get('queue') || [];
// Reset processing state
context.set('processingState', {
isProcessing: false,
lastProcessedUID: '',
processingStartTime: 0,
timeout: 15000
});
// Process next in queue
if (queue.length > 0) {
const nextMsg = queue.shift();
context.set('queue', queue);
node.warn('Processing queued tag: ' + nextMsg.payload?.uid);
return nextMsg;
}
return null; // Nothing queued

Implement retry when the TimeTagger API is temporarily unavailable:

// Function Node: "API Retry Logic"
// Retries failed API calls with exponential backoff
const maxRetries = 3;
const retryCount = msg.retryCount || 0;
if (msg.statusCode === 503 || msg.statusCode === 502 || msg.statusCode === 0) {
// Server error or timeout — retry with backoff
if (retryCount < maxRetries) {
const delay = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
msg.retryCount = retryCount + 1;
msg.retryDelay = delay;
node.warn(`API retry ${retryCount + 1}/${maxRetries} in ${delay}ms`);
// Send to a delay node for retry
return msg;
} else {
node.error(`API failed after ${maxRetries} retries`);
msg.apiFailed = true;
// Store the file for later manual processing
return msg;
}
}
// Success — continue
return msg;

Test the state management logic:

// Upload the ESP32 sketch with state management
// 1. Test rapid successive reads
// Hold tag on reader, remove, re-hold within 5 seconds
// Expected: Second read is ignored (debounce)
// 2. Test check-in → check-out cycle
// Scan tag → Green LED → Scan same tag → Red LED
// Expected: Complete cycle works
// 3. Test processing timeout
// Scan tag, kill Node-RED within 15 seconds
// Expected: ESP32 resets processing state after timeout
// 4. Test queue handling
// Scan Tag A, immediately scan Tag B
// Expected: A processed first, B processed after

Expected Behavior:

ScenarioExpected Result
Same tag twice in 5 secondsSecond ignored (debounce)
Tag A → process → Tag A againBookended → check-out
Tag A → Tag B during processingB queued, processed after A
API unavailable for check-outRetry 3 times, then keep file for manual processing

Issue 1: State Drift Between ESP32 and Server

Section titled “Issue 1: State Drift Between ESP32 and Server”

Symptom: ESP32 thinks tag is checked in, but server says otherwise

Solution: Implement state synchronization:

// ESP32: Request current state on startup
void requestStateSync() {
client.publish("asset/tracking/sync", "REQUEST");
}
// In callback, handle sync response
if (strcmp(topic, "asset/tracking/state") == 0) {
// Update local state based on server response
}

Symptom: Two Node-RED flows try to write the same file simultaneously

Solution: Use Node-RED’s built-in locking:

// Use context-based locking
const lock = context.get('fileLock');
if (lock && (Date.now() - lock) < 5000) {
node.warn('File is locked, queuing operation');
return msg; // Route to queue
}
context.set('fileLock', Date.now());
// ...perform file operation...
context.set('fileLock', 0); // Release lock

Issue 3: Missing Check-Out After Node-RED Restart

Section titled “Issue 3: Missing Check-Out After Node-RED Restart”

Symptom: After restart, file still exists → checked in but not notified

Solution: Add startup check:

// In a "Node-RED startup" triggered flow
const fs = require('fs');
const filePath = '/data/timerecord.txt';
try {
fs.accessSync(filePath, fs.constants.F_OK);
node.warn('WARNING: Stale check-in found: ' + filePath);
node.warn('File will be used when next RFID tag is scanned');
// Send notification to admin
} catch (err) {
// No stale file — normal operation
}
  • Recommended: Always include debounce logic on the ESP32 side
  • Recommended: Use processing timeouts to recover from failures
  • Recommended: Implement retry with exponential backoff for API calls
  • Avoid: Relying solely on ESP32 state — prefer server-authoritative state management
  • Avoid: Blocking ESP32 loop() during MQTT publishing (use non-blocking patterns)
  1. Three state layers: ESP32 (local) → Node-RED (file) → TimeTagger (permanent)
  2. Debounce: Prevent duplicate reads within a 5-second window
  3. Race condition handling: Queue competing requests, process sequentially
  4. Retry logic: Exponential backoff for transient API failures
  5. Stale state recovery: Handle server restarts without data loss

Writing Date: 2026-05-17
Based on Source File: 校正版/10 Time recording witht RFID und TimeTagger.md
Target Audience: Alibaba.com IoT Pre-sales Engineer
Status: ✅ Completed