78 Lab: ESP32 State Machines
78.1 Learning Objectives
By the end of this lab, you will be able to:
- Implement FSM Engines: Build production-quality state machine engines in C/C++ for ESP32 microcontrollers
- Apply Guard Conditions: Construct conditional transitions using potentiometer thresholds and runtime sensor data
- Design Hierarchical States: Create parent/child state relationships to manage complex device behaviors without state explosion
- Persist State Across Power Cycles: Store and restore FSM state using NVS (Non-Volatile Storage) for reliable long-running operation
- Handle Event Queues Safely: Process multiple concurrent events using circular buffers without race conditions
- Trace and Debug State Logic: Validate state machine execution in real-time using serial output and Wokwi simulation
A state machine is a way of modeling device behavior as a series of states with clear rules for transitioning between them. Think of a washing machine: it goes through states like filling, washing, rinsing, and spinning, with specific triggers (like a timer or water level) causing each transition. IoT devices use state machines to manage complex behavior reliably.
78.2 Prerequisites
Before starting this lab, you should be familiar with:
- State Machine Fundamentals: Core concepts of states, transitions, events, and guards
- Microcontroller Programming Essentials: Basic C/C++ programming and GPIO handling
78.3 State Machine Lab: ESP32 Implementation
78.3.1 What You Will Learn
In this hands-on lab, you will build and experiment with a comprehensive state machine implementation on ESP32. The simulation demonstrates:
- Basic FSM patterns: State definition, transitions, and event handling
- Guard conditions: Conditional transitions based on runtime data
- Hierarchical states: Parent/child state relationships
- State persistence: Saving and restoring state across power cycles
- Event queues: Handling multiple events without race conditions
- Timeout handling: Time-based state transitions
- State history: Returning to previous states
78.3.2 Lab Components
| Component | Purpose | Simulation Role |
|---|---|---|
| ESP32 DevKit | Main controller | Runs state machine engine |
| Push Buttons (4) | Event triggers | Simulate external events |
| LEDs (4) | State indicators | Visual feedback for current state |
| Potentiometer | Guard condition input | Simulates sensor threshold |
| Serial Monitor | Debug output | Shows state transitions and logs |
The ESP32 state machine lab processes events via an event queue to avoid race conditions. Calculate event processing overhead. Assume each state transition requires:
\[\text{Transition Time} = \text{Exit action (2ms)} + \text{Guard evaluation (0.5ms)} + \text{Entry action (2ms)} + \text{LED update (0.1ms)} = 4.6 \text{ ms}\]
With button debounce delay of 50ms, maximum event rate:
\[\text{Max Event Rate} = \frac{1}{50 + 4.6} = \frac{1}{54.6} = 18.3 \text{ events/second}\]
For a real-time control system requiring <10ms response, the 4.6ms transition overhead consumes 46% of the time budget. Optimization: move heavy logging to background task, reduce transition time to 1.2ms → leaves 88% of 10ms budget for application logic.
78.3.3 Wokwi Simulator Environment
Wokwi is a free online simulator for Arduino, ESP32, and other microcontrollers. It allows you to build and test IoT projects entirely in your browser without purchasing hardware. This lab demonstrates state machine concepts using standard components.
Launch the simulator below and copy the provided code to explore state machine concepts interactively.
- Click the + button to add components (search for “Push Button”, “LED”, “Potentiometer”)
- Use the Serial Monitor to see state transitions (115200 baud)
- Press buttons to trigger events and observe state changes
- Adjust the potentiometer to test guard conditions
- The code demonstrates production-quality state machine patterns
78.3.4 Step-by-Step Instructions
78.3.4.1 Step 1: Set Up the Circuit
- Add 4 Push Buttons: Click + and search for “Push Button” - add 4 buttons
- Add 4 LEDs: Click + and search for “LED” - add Red, Yellow, Green, and Blue LEDs
- Add 1 Potentiometer: Click + and search for “Potentiometer”
- Wire the components to ESP32:
- Button 1 (Event A) -> GPIO 12
- Button 2 (Event B) -> GPIO 13
- Button 3 (Event C) -> GPIO 14
- Button 4 (Reset) -> GPIO 15
- Red LED (State 1) -> GPIO 25
- Yellow LED (State 2) -> GPIO 26
- Green LED (State 3) -> GPIO 27
- Blue LED (State 4) -> GPIO 32
- Potentiometer signal -> GPIO 34 (ADC)
- All button other pins -> GND
- All LED cathodes -> GND (with resistors)
78.3.4.2 Step 2: Understanding the State Machine Architecture
This lab implements a multi-layered state machine demonstrating real-world IoT patterns:
78.3.4.3 Step 3: Copy the State Machine Lab Code
Copy the following code into the Wokwi code editor. This comprehensive implementation demonstrates professional state machine patterns used in production IoT systems.
/*
* State Machine Lab - ESP32 Implementation
*
* Comprehensive demonstration of state machine concepts for IoT:
* - Finite State Machine (FSM) implementation
* - Guard conditions for conditional transitions
* - Hierarchical State Machines (HSM) with parent/child states
* - Event-driven architecture with event queue
* - State persistence using EEPROM/Preferences
* - Timeout handling and watchdog patterns
* - State history for return-to-previous functionality
*
* Hardware Configuration:
* - Button 1 (GPIO 12): Event A - Primary trigger
* - Button 2 (GPIO 13): Event B - Secondary trigger
* - Button 3 (GPIO 14): Event C - Tertiary trigger
* - Button 4 (GPIO 15): Reset - Force return to IDLE
* - Red LED (GPIO 25): IDLE state indicator
* - Yellow LED (GPIO 26): ACTIVE state indicator
* - Green LED (GPIO 27): PROCESSING state indicator
* - Blue LED (GPIO 32): ERROR state indicator
* - Potentiometer (GPIO 34): Guard condition threshold
*
* Serial Monitor: 115200 baud for state transition logs
*/
#include <Arduino.h>
#include <Preferences.h>
// ============================================================
// PIN DEFINITIONS
// ============================================================
#define BTN_EVENT_A 12 // Primary event trigger
#define BTN_EVENT_B 13 // Secondary event trigger
#define BTN_EVENT_C 14 // Tertiary event trigger
#define BTN_RESET 15 // Force reset to IDLE
#define LED_IDLE 25 // Red LED - IDLE state
#define LED_ACTIVE 26 // Yellow LED - ACTIVE state
#define LED_PROCESSING 27 // Green LED - PROCESSING state
#define LED_ERROR 32 // Blue LED - ERROR state
#define POT_THRESHOLD 34 // Potentiometer for guard conditions
// ============================================================
// STATE MACHINE TYPE DEFINITIONS
// ============================================================
// Main state enumeration
enum MainState {
STATE_IDLE = 0,
STATE_ACTIVE,
STATE_PROCESSING,
STATE_ERROR,
STATE_COUNT // Used for array sizing
};
// Sub-states for IDLE
enum IdleSubState {
IDLE_WAITING = 0,
IDLE_LOW_POWER
};
// Sub-states for ACTIVE
enum ActiveSubState {
ACTIVE_MONITORING = 0,
ACTIVE_RESPONDING
};
// Sub-states for PROCESSING
enum ProcessingSubState {
PROC_ACQUIRING = 0,
PROC_ANALYZING,
PROC_TRANSMITTING,
PROC_COMPLETE
};
// Sub-states for ERROR
enum ErrorSubState {
ERROR_DIAGNOSTIC = 0,
ERROR_RECOVERY,
ERROR_FATAL
};
// Event enumeration
enum Event {
EVENT_NONE = 0,
EVENT_A, // Button 1 pressed
EVENT_B, // Button 2 pressed
EVENT_C, // Button 3 pressed
EVENT_RESET, // Reset button pressed
EVENT_TIMEOUT, // Timer expired
EVENT_SENSOR, // Simulated sensor trigger
EVENT_DATA_READY, // Data acquisition complete
EVENT_TX_COMPLETE, // Transmission complete
EVENT_FAULT, // System fault detected
EVENT_COUNT
};
// State machine context structure
struct StateMachineContext {
MainState currentState;
MainState previousState;
uint8_t subState;
uint8_t previousSubState;
unsigned long stateEntryTime;
unsigned long lastEventTime;
uint32_t transitionCount;
uint32_t eventCount;
int guardThreshold;
bool faultFlag;
bool recoverable;
// History for return-to-previous
MainState historyState;
uint8_t historySubState;
};
// Event queue for handling multiple events
#define EVENT_QUEUE_SIZE 16
struct EventQueue {
Event events[EVENT_QUEUE_SIZE];
uint8_t head;
uint8_t tail;
uint8_t count;
};
// ============================================================
// GLOBAL VARIABLES
// ============================================================
StateMachineContext fsm;
EventQueue eventQueue;
Preferences preferences;
// Debouncing
unsigned long lastButtonPress[4] = {0, 0, 0, 0};
const unsigned long DEBOUNCE_MS = 200;
// Timing constants (milliseconds)
const unsigned long IDLE_TIMEOUT = 30000; // 30 seconds to low power
const unsigned long ACTIVE_TIMEOUT = 60000; // 60 seconds max in active
const unsigned long PROCESSING_TIMEOUT = 10000; // 10 seconds max processing
const unsigned long RESPONSE_DURATION = 2000; // 2 seconds response time
// Statistics
uint32_t totalEvents = 0;
uint32_t totalTransitions = 0;
// State names for logging
const char* stateNames[] = {
"IDLE", "ACTIVE", "PROCESSING", "ERROR"
};
const char* idleSubStateNames[] = {"WAITING", "LOW_POWER"};
const char* activeSubStateNames[] = {"MONITORING", "RESPONDING"};
const char* procSubStateNames[] = {"ACQUIRING", "ANALYZING", "TRANSMITTING", "COMPLETE"};
const char* errorSubStateNames[] = {"DIAGNOSTIC", "RECOVERY", "FATAL"};
const char* eventNames[] = {
"NONE", "EVENT_A", "EVENT_B", "EVENT_C", "RESET",
"TIMEOUT", "SENSOR", "DATA_READY", "TX_COMPLETE", "FAULT"
};
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
// Get current timestamp string
void getTimestamp(char* buffer, size_t len) {
unsigned long ms = millis();
unsigned long sec = ms / 1000;
unsigned long min = sec / 60;
snprintf(buffer, len, "[%02lu:%02lu.%03lu]",
min % 60, sec % 60, ms % 1000);
}
// Log state transition
void logTransition(MainState from, MainState to, Event trigger) {
char timestamp[16];
getTimestamp(timestamp, sizeof(timestamp));
Serial.printf("%s TRANSITION: %s -> %s (trigger: %s)\n",
timestamp, stateNames[from], stateNames[to],
eventNames[trigger]);
}
// Log sub-state transition
void logSubTransition(const char* stateName,
const char* fromSub, const char* toSub) {
char timestamp[16];
getTimestamp(timestamp, sizeof(timestamp));
Serial.printf("%s SUB-TRANSITION [%s]: %s -> %s\n",
timestamp, stateName, fromSub, toSub);
}
// Log event
void logEvent(Event event) {
char timestamp[16];
getTimestamp(timestamp, sizeof(timestamp));
Serial.printf("%s EVENT: %s received\n",
timestamp, eventNames[event]);
}
// ============================================================
// LED CONTROL FUNCTIONS
// ============================================================
void setAllLEDs(bool idle, bool active, bool processing, bool error) {
digitalWrite(LED_IDLE, idle ? HIGH : LOW);
digitalWrite(LED_ACTIVE, active ? HIGH : LOW);
digitalWrite(LED_PROCESSING, processing ? HIGH : LOW);
digitalWrite(LED_ERROR, error ? HIGH : LOW);
}
void blinkLED(int pin, int count, int onMs, int offMs) {
for (int i = 0; i < count; i++) {
digitalWrite(pin, HIGH);
delay(onMs);
digitalWrite(pin, LOW);
if (i < count - 1) delay(offMs);
}
}
// LED pattern for current state
void updateLEDsForState() {
switch (fsm.currentState) {
case STATE_IDLE:
if (fsm.subState == IDLE_LOW_POWER) {
// Dim blink for low power
static bool toggle = false;
toggle = !toggle;
setAllLEDs(toggle, false, false, false);
} else {
setAllLEDs(true, false, false, false);
}
break;
case STATE_ACTIVE:
if (fsm.subState == ACTIVE_RESPONDING) {
setAllLEDs(false, true, true, false); // Yellow + Green
} else {
setAllLEDs(false, true, false, false);
}
break;
case STATE_PROCESSING:
// Progressive LEDs based on sub-state
switch (fsm.subState) {
case PROC_ACQUIRING:
setAllLEDs(false, false, true, false);
break;
case PROC_ANALYZING:
setAllLEDs(false, true, true, false);
break;
case PROC_TRANSMITTING:
setAllLEDs(true, true, true, false);
break;
case PROC_COMPLETE:
setAllLEDs(true, true, true, true);
break;
}
break;
case STATE_ERROR:
if (fsm.subState == ERROR_FATAL) {
// Fast blink all
static bool errorToggle = false;
errorToggle = !errorToggle;
setAllLEDs(errorToggle, errorToggle, errorToggle, true);
} else {
setAllLEDs(false, false, false, true);
}
break;
}
}
// ============================================================
// EVENT QUEUE FUNCTIONS
// ============================================================
void initEventQueue() {
eventQueue.head = 0;
eventQueue.tail = 0;
eventQueue.count = 0;
memset(eventQueue.events, EVENT_NONE, sizeof(eventQueue.events));
}
bool enqueueEvent(Event event) {
if (eventQueue.count >= EVENT_QUEUE_SIZE) {
Serial.println("WARNING: Event queue full, event dropped!");
return false;
}
eventQueue.events[eventQueue.tail] = event;
eventQueue.tail = (eventQueue.tail + 1) % EVENT_QUEUE_SIZE;
eventQueue.count++;
totalEvents++;
return true;
}
Event dequeueEvent() {
if (eventQueue.count == 0) {
return EVENT_NONE;
}
Event event = eventQueue.events[eventQueue.head];
eventQueue.head = (eventQueue.head + 1) % EVENT_QUEUE_SIZE;
eventQueue.count--;
return event;
}
bool hasEvents() {
return eventQueue.count > 0;
}
// ============================================================
// STATE PERSISTENCE FUNCTIONS
// ============================================================
void saveState() {
preferences.begin("fsm", false);
preferences.putUChar("mainState", (uint8_t)fsm.currentState);
preferences.putUChar("subState", fsm.subState);
preferences.putULong("transCnt", fsm.transitionCount);
preferences.putULong("eventCnt", fsm.eventCount);
preferences.putUChar("histState", (uint8_t)fsm.historyState);
preferences.putUChar("histSub", fsm.historySubState);
preferences.end();
Serial.println("State saved to flash");
}
bool loadState() {
preferences.begin("fsm", true);
if (!preferences.isKey("mainState")) {
preferences.end();
return false;
}
fsm.currentState = (MainState)preferences.getUChar("mainState", STATE_IDLE);
fsm.subState = preferences.getUChar("subState", 0);
fsm.transitionCount = preferences.getULong("transCnt", 0);
fsm.eventCount = preferences.getULong("eventCnt", 0);
fsm.historyState = (MainState)preferences.getUChar("histState", STATE_IDLE);
fsm.historySubState = preferences.getUChar("histSub", 0);
preferences.end();
Serial.println("State restored from flash");
Serial.printf(" Restored to: %s (sub: %d)\n",
stateNames[fsm.currentState], fsm.subState);
Serial.printf(" Historical transitions: %lu, events: %lu\n",
fsm.transitionCount, fsm.eventCount);
return true;
}
void clearSavedState() {
preferences.begin("fsm", false);
preferences.clear();
preferences.end();
Serial.println("Saved state cleared");
}
// ============================================================
// GUARD CONDITION FUNCTIONS
// ============================================================
// Read and normalize potentiometer value (0-100)
int readGuardThreshold() {
int raw = analogRead(POT_THRESHOLD);
return map(raw, 0, 4095, 0, 100);
}
// Guard: Check if threshold is above 50%
bool guardThresholdHigh() {
fsm.guardThreshold = readGuardThreshold();
bool result = fsm.guardThreshold > 50;
if (!result) {
Serial.printf(" GUARD FAILED: Threshold %d%% <= 50%%\n",
fsm.guardThreshold);
}
return result;
}
// Guard: Check if system is not in fault state
bool guardNoFault() {
return !fsm.faultFlag;
}
// Guard: Check if error is recoverable
bool guardRecoverable() {
return fsm.recoverable;
}
// Guard: Check if in specific sub-state
bool guardSubState(uint8_t required) {
return fsm.subState == required;
}
// ============================================================
// STATE ENTRY/EXIT ACTIONS
// ============================================================
void onEnterIdle() {
Serial.println(" [ENTER] IDLE state");
fsm.subState = IDLE_WAITING;
fsm.stateEntryTime = millis();
setAllLEDs(true, false, false, false);
}
void onExitIdle() {
Serial.println(" [EXIT] IDLE state");
}
void onEnterActive() {
Serial.println(" [ENTER] ACTIVE state");
fsm.subState = ACTIVE_MONITORING;
fsm.stateEntryTime = millis();
setAllLEDs(false, true, false, false);
// Simulate starting sensor monitoring
Serial.println(" Starting sensor monitoring...");
}
void onExitActive() {
Serial.println(" [EXIT] ACTIVE state");
Serial.println(" Stopping sensor monitoring...");
}
void onEnterProcessing() {
Serial.println(" [ENTER] PROCESSING state");
fsm.subState = PROC_ACQUIRING;
fsm.stateEntryTime = millis();
setAllLEDs(false, false, true, false);
Serial.println(" Starting data acquisition...");
}
void onExitProcessing() {
Serial.println(" [EXIT] PROCESSING state");
}
void onEnterError() {
Serial.println(" [ENTER] ERROR state");
fsm.subState = ERROR_DIAGNOSTIC;
fsm.stateEntryTime = millis();
setAllLEDs(false, false, false, true);
Serial.println(" Running diagnostics...");
// Determine if error is recoverable
fsm.recoverable = (random(100) > 30); // 70% chance recoverable
Serial.printf(" Error recoverable: %s\n",
fsm.recoverable ? "YES" : "NO");
}
void onExitError() {
Serial.println(" [EXIT] ERROR state");
fsm.faultFlag = false;
}
// ============================================================
// STATE TRANSITION FUNCTION
// ============================================================
void transitionTo(MainState newState, Event trigger) {
if (newState == fsm.currentState) {
return; // No transition needed
}
MainState oldState = fsm.currentState;
// Save history before transition
fsm.historyState = fsm.currentState;
fsm.historySubState = fsm.subState;
// Execute exit action for current state
switch (fsm.currentState) {
case STATE_IDLE: onExitIdle(); break;
case STATE_ACTIVE: onExitActive(); break;
case STATE_PROCESSING: onExitProcessing(); break;
case STATE_ERROR: onExitError(); break;
}
// Update state
fsm.previousState = fsm.currentState;
fsm.currentState = newState;
fsm.transitionCount++;
totalTransitions++;
// Log the transition
logTransition(oldState, newState, trigger);
// Execute entry action for new state
switch (fsm.currentState) {
case STATE_IDLE: onEnterIdle(); break;
case STATE_ACTIVE: onEnterActive(); break;
case STATE_PROCESSING: onEnterProcessing(); break;
case STATE_ERROR: onEnterError(); break;
}
// Save state periodically (every 5 transitions)
if (fsm.transitionCount % 5 == 0) {
saveState();
}
updateLEDsForState();
}
// ============================================================
// SUB-STATE TRANSITION FUNCTION
// ============================================================
void transitionSubState(uint8_t newSubState) {
if (newSubState == fsm.subState) {
return;
}
uint8_t oldSub = fsm.subState;
fsm.previousSubState = fsm.subState;
fsm.subState = newSubState;
// Get sub-state names based on current main state
const char** subNames = NULL;
switch (fsm.currentState) {
case STATE_IDLE: subNames = idleSubStateNames; break;
case STATE_ACTIVE: subNames = activeSubStateNames; break;
case STATE_PROCESSING: subNames = procSubStateNames; break;
case STATE_ERROR: subNames = errorSubStateNames; break;
}
if (subNames) {
logSubTransition(stateNames[fsm.currentState],
subNames[oldSub], subNames[newSubState]);
}
updateLEDsForState();
}
// ============================================================
// STATE HANDLERS (Per-State Logic)
// ============================================================
void handleIdleState(Event event) {
unsigned long elapsed = millis() - fsm.stateEntryTime;
// Handle sub-states
switch (fsm.subState) {
case IDLE_WAITING:
if (elapsed > IDLE_TIMEOUT) {
transitionSubState(IDLE_LOW_POWER);
Serial.println(" Entering low power mode...");
}
break;
case IDLE_LOW_POWER:
// Any event wakes us up
if (event != EVENT_NONE && event != EVENT_TIMEOUT) {
transitionSubState(IDLE_WAITING);
fsm.stateEntryTime = millis();
Serial.println(" Waking from low power...");
}
break;
}
// Handle events
switch (event) {
case EVENT_A:
if (guardThresholdHigh()) {
transitionTo(STATE_ACTIVE, event);
} else {
Serial.println(" Transition blocked by guard condition");
}
break;
case EVENT_C:
// Simulate fault trigger
fsm.faultFlag = true;
transitionTo(STATE_ERROR, event);
break;
default:
break;
}
}
void handleActiveState(Event event) {
unsigned long elapsed = millis() - fsm.stateEntryTime;
// Timeout check
if (elapsed > ACTIVE_TIMEOUT) {
Serial.println(" ACTIVE state timeout - returning to IDLE");
transitionTo(STATE_IDLE, EVENT_TIMEOUT);
return;
}
// Handle sub-states
switch (fsm.subState) {
case ACTIVE_MONITORING:
// Simulate random sensor trigger (5% chance per loop)
if (event == EVENT_SENSOR || random(100) < 5) {
transitionSubState(ACTIVE_RESPONDING);
fsm.stateEntryTime = millis(); // Reset for response timing
}
break;
case ACTIVE_RESPONDING:
if (millis() - fsm.stateEntryTime > RESPONSE_DURATION) {
transitionSubState(ACTIVE_MONITORING);
Serial.println(" Response complete, back to monitoring");
}
break;
}
// Handle events
switch (event) {
case EVENT_A:
// Toggle back to IDLE
transitionTo(STATE_IDLE, event);
break;
case EVENT_B:
// Start processing
transitionTo(STATE_PROCESSING, event);
break;
case EVENT_C:
// Critical fault
fsm.faultFlag = true;
transitionTo(STATE_ERROR, event);
break;
default:
break;
}
}
void handleProcessingState(Event event) {
unsigned long elapsed = millis() - fsm.stateEntryTime;
// Timeout check
if (elapsed > PROCESSING_TIMEOUT) {
Serial.println(" PROCESSING timeout - entering ERROR");
fsm.faultFlag = true;
fsm.recoverable = true;
transitionTo(STATE_ERROR, EVENT_TIMEOUT);
return;
}
// Automatic sub-state progression (simulated timing)
switch (fsm.subState) {
case PROC_ACQUIRING:
if (elapsed > 2000) {
transitionSubState(PROC_ANALYZING);
Serial.println(" Data acquired, starting analysis...");
}
break;
case PROC_ANALYZING:
if (elapsed > 4000) {
transitionSubState(PROC_TRANSMITTING);
Serial.println(" Analysis complete, transmitting...");
}
break;
case PROC_TRANSMITTING:
if (elapsed > 6000) {
transitionSubState(PROC_COMPLETE);
Serial.println(" Transmission complete!");
}
break;
case PROC_COMPLETE:
if (elapsed > 7000) {
transitionTo(STATE_ACTIVE, EVENT_TX_COMPLETE);
}
break;
}
// Handle events
switch (event) {
case EVENT_C:
// Abort processing
fsm.faultFlag = true;
transitionTo(STATE_ERROR, event);
break;
case EVENT_RESET:
transitionTo(STATE_IDLE, event);
break;
default:
break;
}
}
void handleErrorState(Event event) {
unsigned long elapsed = millis() - fsm.stateEntryTime;
// Handle sub-states
switch (fsm.subState) {
case ERROR_DIAGNOSTIC:
if (elapsed > 2000) {
if (fsm.recoverable) {
transitionSubState(ERROR_RECOVERY);
Serial.println(" Starting recovery procedure...");
} else {
transitionSubState(ERROR_FATAL);
Serial.println(" FATAL ERROR - Manual reset required");
}
}
break;
case ERROR_RECOVERY:
if (elapsed > 5000) {
Serial.println(" Recovery successful!");
// Return to history state
transitionTo(fsm.historyState, EVENT_NONE);
}
break;
case ERROR_FATAL:
// Only reset button can exit
break;
}
// Handle events
switch (event) {
case EVENT_RESET:
Serial.println(" Manual reset triggered");
fsm.faultFlag = false;
transitionTo(STATE_IDLE, event);
break;
default:
break;
}
}
// ============================================================
// MAIN STATE MACHINE DISPATCHER
// ============================================================
void processStateMachine(Event event) {
if (event != EVENT_NONE) {
logEvent(event);
fsm.eventCount++;
fsm.lastEventTime = millis();
}
// Dispatch to current state handler
switch (fsm.currentState) {
case STATE_IDLE:
handleIdleState(event);
break;
case STATE_ACTIVE:
handleActiveState(event);
break;
case STATE_PROCESSING:
handleProcessingState(event);
break;
case STATE_ERROR:
handleErrorState(event);
break;
}
}
// ============================================================
// BUTTON HANDLING
// ============================================================
void checkButtons() {
unsigned long now = millis();
// Button 1 - Event A
if (digitalRead(BTN_EVENT_A) == LOW) {
if (now - lastButtonPress[0] > DEBOUNCE_MS) {
enqueueEvent(EVENT_A);
lastButtonPress[0] = now;
}
}
// Button 2 - Event B
if (digitalRead(BTN_EVENT_B) == LOW) {
if (now - lastButtonPress[1] > DEBOUNCE_MS) {
enqueueEvent(EVENT_B);
lastButtonPress[1] = now;
}
}
// Button 3 - Event C
if (digitalRead(BTN_EVENT_C) == LOW) {
if (now - lastButtonPress[2] > DEBOUNCE_MS) {
enqueueEvent(EVENT_C);
lastButtonPress[2] = now;
}
}
// Button 4 - Reset
if (digitalRead(BTN_RESET) == LOW) {
if (now - lastButtonPress[3] > DEBOUNCE_MS) {
enqueueEvent(EVENT_RESET);
lastButtonPress[3] = now;
}
}
}
// ============================================================
// STATISTICS AND STATUS DISPLAY
// ============================================================
void printStatus() {
Serial.println("\n========== STATE MACHINE STATUS ==========");
Serial.printf("Current State: %s\n", stateNames[fsm.currentState]);
const char** subNames = NULL;
switch (fsm.currentState) {
case STATE_IDLE: subNames = idleSubStateNames; break;
case STATE_ACTIVE: subNames = activeSubStateNames; break;
case STATE_PROCESSING: subNames = procSubStateNames; break;
case STATE_ERROR: subNames = errorSubStateNames; break;
}
if (subNames) {
Serial.printf("Sub-State: %s\n", subNames[fsm.subState]);
}
Serial.printf("Guard Threshold: %d%%\n", readGuardThreshold());
Serial.printf("Time in State: %lu ms\n", millis() - fsm.stateEntryTime);
Serial.printf("Total Transitions: %lu\n", totalTransitions);
Serial.printf("Total Events: %lu\n", totalEvents);
Serial.printf("Event Queue: %d pending\n", eventQueue.count);
Serial.printf("Fault Flag: %s\n", fsm.faultFlag ? "SET" : "CLEAR");
Serial.println("==========================================\n");
}
// ============================================================
// INITIALIZATION
// ============================================================
void initPins() {
// Configure button pins with internal pull-up
pinMode(BTN_EVENT_A, INPUT_PULLUP);
pinMode(BTN_EVENT_B, INPUT_PULLUP);
pinMode(BTN_EVENT_C, INPUT_PULLUP);
pinMode(BTN_RESET, INPUT_PULLUP);
// Configure LED pins as output
pinMode(LED_IDLE, OUTPUT);
pinMode(LED_ACTIVE, OUTPUT);
pinMode(LED_PROCESSING, OUTPUT);
pinMode(LED_ERROR, OUTPUT);
// Initialize LEDs off
setAllLEDs(false, false, false, false);
// Potentiometer is analog input (no pinMode needed)
}
void initStateMachine() {
// Initialize FSM context
fsm.currentState = STATE_IDLE;
fsm.previousState = STATE_IDLE;
fsm.subState = IDLE_WAITING;
fsm.previousSubState = IDLE_WAITING;
fsm.stateEntryTime = millis();
fsm.lastEventTime = millis();
fsm.transitionCount = 0;
fsm.eventCount = 0;
fsm.guardThreshold = 50;
fsm.faultFlag = false;
fsm.recoverable = true;
fsm.historyState = STATE_IDLE;
fsm.historySubState = 0;
// Initialize event queue
initEventQueue();
// Try to restore previous state
if (!loadState()) {
Serial.println("No saved state found, starting fresh");
}
// Execute entry action for initial state
switch (fsm.currentState) {
case STATE_IDLE: onEnterIdle(); break;
case STATE_ACTIVE: onEnterActive(); break;
case STATE_PROCESSING: onEnterProcessing(); break;
case STATE_ERROR: onEnterError(); break;
}
}
// ============================================================
// SETUP AND MAIN LOOP
// ============================================================
void setup() {
Serial.begin(115200);
delay(1000); // Wait for serial monitor
Serial.println("\n");
Serial.println("==============================================");
Serial.println(" STATE MACHINE LAB - ESP32 Implementation");
Serial.println("==============================================");
Serial.println("Demonstrating FSM concepts for IoT devices:");
Serial.println(" - Finite State Machine patterns");
Serial.println(" - Guard conditions");
Serial.println(" - Hierarchical sub-states");
Serial.println(" - State persistence");
Serial.println(" - Event queue processing");
Serial.println("----------------------------------------------");
Serial.println("CONTROLS:");
Serial.println(" Button 1 (GPIO 12): Event A - IDLE<->ACTIVE");
Serial.println(" Button 2 (GPIO 13): Event B - Start Processing");
Serial.println(" Button 3 (GPIO 14): Event C - Trigger Error");
Serial.println(" Button 4 (GPIO 15): Reset to IDLE");
Serial.println(" Potentiometer: Guard threshold (needs >50%)");
Serial.println("==============================================\n");
// Initialize random seed
randomSeed(analogRead(0));
// Initialize hardware
initPins();
// Initialize state machine
initStateMachine();
// Print initial status
printStatus();
Serial.println("State machine running. Press buttons to trigger events.\n");
}
// LED update timing
unsigned long lastLEDUpdate = 0;
const unsigned long LED_UPDATE_INTERVAL = 100;
// Status print timing
unsigned long lastStatusPrint = 0;
const unsigned long STATUS_INTERVAL = 15000; // Every 15 seconds
void loop() {
unsigned long now = millis();
// Check for button presses
checkButtons();
// Process any pending events
while (hasEvents()) {
Event event = dequeueEvent();
processStateMachine(event);
}
// Run state machine with no event (for timeout processing)
processStateMachine(EVENT_NONE);
// Update LEDs periodically
if (now - lastLEDUpdate > LED_UPDATE_INTERVAL) {
updateLEDsForState();
lastLEDUpdate = now;
}
// Print status periodically
if (now - lastStatusPrint > STATUS_INTERVAL) {
printStatus();
lastStatusPrint = now;
}
// Small delay to prevent CPU hogging
delay(10);
}78.3.4.4 Step 4: Run the Simulation
- Start the simulation: Click the green “Play” button
- Open Serial Monitor: Click on the Serial Monitor tab (set to 115200 baud)
- Observe initial state: The ESP32 starts in IDLE state (Red LED on)
- Adjust the potentiometer: Turn it above 50% to enable the guard condition
78.3.4.5 Step 5: Experiment with State Transitions
Try these scenarios to understand state machine behavior:
Scenario 1: Basic Transitions
- Ensure potentiometer is above 50%
- Press Button 1 (Event A) - Observe transition from IDLE to ACTIVE
- Press Button 1 again - Return to IDLE
Scenario 2: Guard Conditions
- Turn potentiometer below 50%
- Press Button 1 - Observe guard failure message
- Turn potentiometer above 50%
- Press Button 1 - Now transition succeeds
Scenario 3: Processing Flow
- Start in ACTIVE state
- Press Button 2 (Event B) - Enter PROCESSING
- Watch automatic sub-state progression (Acquiring -> Analyzing -> Transmitting -> Complete)
- Observe automatic return to ACTIVE when complete
Scenario 4: Error Handling
- Press Button 3 (Event C) from any state - Enter ERROR
- Watch diagnostic sub-state determine recoverability
- If recoverable, watch automatic recovery
- If fatal, use Button 4 (Reset) to return to IDLE
Scenario 5: State Persistence
- Make several state transitions
- Stop and restart the simulation
- Observe state restoration from saved data
78.3.5 Challenge Exercises
Extend the state machine to include a deep sleep state:
- After 60 seconds in IDLE LOW_POWER, transition to SLEEP
- SLEEP should turn off all LEDs except brief periodic blinks
- Only Button 4 (Reset) should wake from SLEEP
- Track sleep duration and report on wake
Hints:
- Add
STATE_SLEEPto the MainState enum - Implement
onEnterSleep()andonExitSleep()actions - Use ESP32’s
esp_deep_sleep()for real hardware
Add a serial command interface to:
- Print a text-based state diagram showing current position
- List all valid transitions from current state
- Show transition history (last 10 transitions)
- Export state machine statistics in JSON format
Hints:
- Add a
handleSerialCommand()function - Use character commands: ‘d’ for diagram, ‘h’ for history, ‘s’ for stats
Modify the event queue to support priority:
- Reset events should always be processed first
- Error events should have high priority
- Normal events processed in FIFO order
- Add queue statistics (dropped events, max depth)
Hints:
- Create a priority field in the event structure
- Use a heap or multiple queues for different priorities
Create a parallel watchdog state machine that:
- Monitors the main state machine for stuck states
- Triggers automatic recovery if no transition in 2 minutes
- Logs suspected stuck states with context
- Implements a separate ERROR_WATCHDOG sub-state
Hints:
- Track
lastTransitionTimein the FSM context - Check in each loop iteration
- Consider using a hardware watchdog timer
78.3.6 Expected Outcomes
After completing this lab, you should be able to:
| Skill | Demonstration |
|---|---|
| Define states clearly | Each state has a single responsibility and clear LED indication |
| Implement guard conditions | Potentiometer threshold blocks/allows transitions |
| Handle hierarchical states | Sub-states within IDLE, ACTIVE, PROCESSING, ERROR |
| Persist state across resets | State restored from flash on power-up |
| Process events safely | Event queue prevents race conditions |
| Debug state machines | Serial output shows all transitions and timing |
Building a state machine on a real microcontroller is like programming a robot’s brain – you tell it exactly what to do in every situation!
78.3.7 The Sensor Squad Adventure: Programming Robo the Robot
The Sensor Squad got a brand new robot called Robo (that is the ESP32!). But Robo just sat there doing nothing. “We need to program his brain!” said Max the Microcontroller.
They gave Robo four colored lights and four buttons:
- Red light = “I am resting” (IDLE)
- Yellow light = “I am watching!” (ACTIVE)
- Green light = “I am working!” (PROCESSING)
- Blue light = “Something went wrong!” (ERROR)
Then they wrote Robo’s brain rules:
Button 1: “Wake up, Robo!” – But ONLY if the energy dial (potentiometer) is above 50%. If Robo is too tired, he stays resting. That is a guard condition!
Button 2: “Start your job!” – Robo goes through steps: Collect data, Analyze it, Send it out, Done! Each step lights up more LEDs.
Button 3: “Uh oh, problem!” – Robo turns on the blue error light. He checks if he can fix himself (70% of the time he can!). If not, he waits for…
Button 4: “Reset!” – The magic button that always brings Robo back to resting. Even if everything else is broken!
Sammy the Sensor’s favorite part? “Robo remembers what he was doing even if the power goes out! His brain saves to flash memory!” That is state persistence.
Bella the Battery added, “And he never gets confused by two buttons pressed at once because he has an event queue – he handles one thing at a time, in order!”
78.3.8 Key Words for Kids
| Word | What It Means |
|---|---|
| ESP32 | A tiny computer (like a robot brain) that can control LEDs, read buttons, and talk wirelessly |
| Guard Condition | A rule that must be true before the robot can change what it is doing |
| Event Queue | A line where button presses wait their turn, like people in a queue at a shop |
| State Persistence | Saving what the robot was doing so it remembers after a power nap |
In one sentence: Production-quality state machines on ESP32 combine guard conditions, hierarchical sub-states, event queues, and state persistence to create reliable, debuggable, and power-cycle-safe IoT device behavior.
Remember this rule: Always use an event queue (not direct processing) to prevent race conditions, and persist state to flash storage so devices resume correctly after unexpected resets.
Scenario: A factory robot arm uses an ESP32 state machine for safety-critical control. The arm must transition from MOVING → EMERGENCY_STOP within 5ms when an obstacle is detected. Each state transition involves entry/exit actions. Calculate if the transition overhead meets the safety requirement.
Given Data:
- ESP32-WROOM-32 @ 240 MHz (Xtensa dual-core)
- Current state: MOVING (executing continuous motion commands)
- Target state: EMERGENCY_STOP (disables motors, applies brake)
- State transition involves: exit_moving() → transition_logic() → enter_emergency_stop()
- Measured execution times (via micros() timing):
- exit_moving(): 180 µs (stops PWM, logs final position)
- transition_logic(): 50 µs (state variable update, history save)
- enter_emergency_stop(): 320 µs (GPIO write to brake relay, disable motor drivers, sound alarm)
Step 1: Calculate Total Transition Time
Transition overhead = exit + logic + entry = 180 µs + 50 µs + 320 µs = 550 µs = 0.55 ms
Step 2: Add Sensor-to-Transition Latency
Total response time = sensor detection + transition overhead: - Ultrasonic sensor response: 25 µs (speed of sound @ 10 cm) - GPIO interrupt latency: 15 µs (ESP32 interrupt handler entry) - Event queue dequeue: 8 µs (RTOS queue read) - State machine dispatch: 12 µs (switch statement execution) - Total pre-transition: 60 µs = 0.06 ms
Step 3: Calculate End-to-End Latency
Total safety stop time = 0.06 ms (detection) + 0.55 ms (transition) = 0.61 ms
Comparison to requirement: 0.61 ms << 5 ms ✓ Meets safety requirement with 8.2x margin
Step 4: Worst-Case Analysis
What if the state machine is processing another event when the emergency occurs?
- Longest event handler: PROCESSING state handles data transmission (worst-case 1.2 ms)
- Event queue is FIFO, so emergency event waits behind current event
- Worst-case total: 1.2 ms (current event) + 0.61 ms (emergency transition) = 1.81 ms
Still under 5 ms budget ✓ with 2.8x margin in worst case
Step 5: Validate with Oscilloscope
Connect oscilloscope to: - Channel 1: Ultrasonic sensor trigger output (event source) - Channel 2: Brake relay GPIO (emergency stop confirmation)
Measured time from trigger edge to relay activation: 0.64 ms (matches calculation ±5% measurement error)
Decision: The state machine transition overhead (0.55 ms) is fast enough for safety-critical control. The 8.2x margin (best case) or 2.8x margin (worst case) provides buffer for future firmware complexity without violating the 5 ms hard real-time constraint.
Production Configuration:
// Safety-critical state machine configuration
#define EMERGENCY_PRIORITY 1000 // Highest priority event
#define MAX_EVENT_DURATION_MS 2 // Abort any event taking >2ms
#define WATCHDOG_TIMEOUT_MS 10 // Reset if emergency not handled in 10ms
// Interrupt-driven emergency detection (bypass event queue for zero latency)
void IRAM_ATTR obstacle_detected_ISR() {
transitionTo(STATE_EMERGENCY_STOP, EVENT_OBSTACLE); // Direct call, no queue
}State persistence ensures devices resume correctly after power loss or reset. This framework helps choose the right persistence strategy based on device constraints and failure modes.
| Criterion | No Persistence | Checkpoint on Transition | Periodic Checkpoint | Continuous Journaling |
|---|---|---|---|---|
| Write Frequency | Never | Every state change (1-100 writes/hour) |
Fixed interval (e.g., every 60s) |
Every event (100-1000 writes/hour) |
| Flash Wear Impact | None | Low-Medium (10K writes = 100-1000 hours @ 100 transitions/hour) |
Low (10K writes = 167 hours @ 1/min) |
High (10K writes = 10-100 hours) |
| Recovery Granularity | Lost (reset to IDLE) | Exact state before reset | Up to 60s of lost state | Sub-second accuracy |
| Recovery Time | Instant (default state) | 50-200 ms (read NVS) | 50-200 ms (read NVS) | 100-500 ms (replay journal) |
| Code Complexity | Minimal | Low (saveState() in transition function) | Medium (timer-based trigger) | High (event log + replay logic) |
| Best For | Stateless devices (sensors with no memory) |
Long-lived states (hours between transitions) |
Moderate state churn (minutes between transitions) |
Mission-critical state (billing, safety logs) |
Quick Decision Guide:
Use No Persistence if:
- Device state is fully reconstructable from environment (e.g., temperature sensor just reads current value)
- State transitions are infrequent and resets rare (e.g., wall-powered gateway)
- Flash writes must be minimized for extreme longevity (100K+ cycles on SPI Flash)
Use Checkpoint on Transition if:
- State changes are meaningful milestones (e.g., door lock: LOCKED → UNLOCKED → LOCKED)
- Transitions happen <1,000 times per day (well within Flash endurance)
- Recovery to exact pre-reset state is required (e.g., irrigation controller mid-watering)
Use Periodic Checkpoint if:
- State contains gradual accumulations (e.g., packet counters, sensor averages)
- Exact recovery not critical, but “close enough” needed (e.g., energy meter can lose <1 minute of readings)
- Flash wear budget allows 1,440 writes/day (every minute)
Use Continuous Journaling if:
- Financial transactions or safety-critical logs (regulatory compliance)
- Every event must be reconstructable after reset (e.g., medical device dosage log)
- External Flash with high endurance (1M+ write cycles)
Flash Wear Calculation Example:
ESP32 NVS (Flash) endurance: 100,000 write cycles per block
| Strategy | Writes/Day | Days to Failure | Years to Failure |
|---|---|---|---|
| Checkpoint on transition (10 transitions/hour) | 240 | 417 | 1.1 years |
| Periodic (every 60s) | 1,440 | 69 | 0.2 years (too short) |
| Checkpoint on transition + wear leveling (4 blocks rotated) | 240 | 1,668 | 4.6 years ✓ |
| Periodic (every 5 min) + wear leveling | 288 | 1,389 | 3.8 years ✓ |
Recommendation for IoT: Use checkpoint-on-transition with wear leveling (NVS library on ESP32 does this automatically). Provides exact recovery without excessive wear.
The Problem: A smart thermostat’s state machine uses delay(2000) in the HEATING state’s entry action to “warm up” the relay. During this 2-second delay, the state machine cannot process any events – including the EMERGENCY_OVERHEAT event. A sensor failure causes temperature to spike, but the emergency event sits in the queue for 2 seconds until the entry action completes. By then, the heating element has overheated.
Why It Happens: Developers think of entry/exit actions as “initialization code” and assume it’s safe to block briefly. But in event-driven systems, any blocking code prevents the event loop from processing new events.
The Bug:
void onEnterHeating() {
digitalWrite(RELAY_PIN, HIGH); // Turn on heater
delay(2000); // Wait for relay to stabilize -- BLOCKS EVENT LOOP
Serial.println("Heating started");
}
// During this 2-second delay, events pile up in the queue unprocessed
// If EMERGENCY_OVERHEAT arrives during delay, it waits 2 secondsThe Solution: Non-Blocking Entry Actions
void onEnterHeating() {
digitalWrite(RELAY_PIN, HIGH); // Turn on heater immediately
heatingStartTime = millis(); // Record when we started
heatingStabilized = false; // Flag for stabilization check
Serial.println("Heating relay activated");
// Exit immediately -- let loop() handle timing
}
void handleHeatingState(Event event) {
// Check if relay has stabilized (non-blocking check in state handler)
if (!heatingStabilized && (millis() - heatingStartTime > 2000)) {
heatingStabilized = true;
Serial.println("Heating stabilized");
}
// Process events immediately, even during stabilization period
switch (event) {
case EVENT_EMERGENCY_OVERHEAT:
transitionTo(STATE_EMERGENCY_STOP, event); // Handled instantly
break;
case EVENT_TARGET_REACHED:
if (heatingStabilized) { // Only act after stabilization
transitionTo(STATE_IDLE, event);
}
break;
}
}Key Principles:
- Entry/exit actions must complete in <1ms (just variable initialization, GPIO writes)
- Never use delay() in state machine code – use millis() timers and check in loop()
- Timers belong in state handlers, not entry actions
- Test with worst-case event timing: trigger events during entry actions to verify non-blocking behavior
Real-World Impact: After removing the 2-second delay, the thermostat’s emergency response time dropped from 2.3 seconds (2s delay + 300ms transition) to 15 milliseconds (just the transition overhead). This prevented thermal runaway during a sensor glitch.
Key Concepts
- FreeRTOS Task: A schedulable unit of execution in ESP32 firmware running a state machine loop, with its own stack allocation and priority level, receiving events from queues to drive state transitions
- Event Queue: A FreeRTOS message queue carrying event identifiers (sensor_ready, timeout, button_press) from producers (interrupt handlers, timer callbacks) to the state machine consumer task, enabling decoupled event generation and handling
- State Machine Switch Statement: The canonical C implementation pattern using nested switch statements — outer switch on current_state, inner switch on incoming_event — mapping each state-event pair to a transition action and next state
- Entry and Exit Actions: Code executed when entering (start LED blink, activate sensor) or leaving (disable peripheral, log state) a state, implementing Moore machine output behavior independent of which event triggered the transition
- State Timeout Timer: A FreeRTOS timer created on state entry and deleted on state exit, generating a timeout event if the device spends longer than expected in a state — used to detect hung states and trigger recovery transitions
- xEventGroupWaitBits: A FreeRTOS API function blocking a task until specified event bits are set in an event group, enabling state machines to wait for multiple concurrent event sources with a single blocking call
Common Pitfalls
Using vTaskDelay(), HAL_Delay(), or blocking I2C reads inside switch-case blocks. Blocking calls in state machine handlers prevent processing of other events during the block duration. Move all time-consuming operations to separate tasks and communicate completion via event queue.
Implementing power-saving deep sleep without saving the current state machine state and relevant context to NVS (non-volatile storage). After deep sleep, the ESP32 reboots — firmware that restores state from NVS resumes where it left off; firmware that does not always starts from the initial state.
Storing state machine state and context in global variables accessible from ISRs and multiple tasks. Race conditions cause state corruption when multiple execution contexts modify shared state without synchronization. Use task-local variables and pass events via queues.
Starting a new FreeRTOS timer without first stopping the previous state’s timer when transitioning between states. Expired timers from a previous state generate stale events in the new state, causing incorrect transitions. Always cancel active timers during state exit actions.
78.4 Summary
This lab demonstrated professional-grade state machine implementation for ESP32:
- State definitions with clear responsibilities and visual indicators
- Guard conditions using potentiometer as threshold sensor
- Hierarchical states with parent/child relationships
- State persistence using ESP32 Preferences API
- Event queues for safe, ordered event processing
- Entry/exit actions for state initialization and cleanup
The patterns learned here apply to everything from simple sensor nodes to complex industrial controllers.
78.5 Knowledge Check
78.6 What’s Next
| If you want to… | Read this |
|---|---|
| Study state machine fundamentals | State Machine Fundamentals |
| Apply state machines to IoT design | State Machines in IoT |
| Explore process control and PID | Process Control and PID |
| Learn about IoT reference architectures | IoT Reference Architectures |
| Study production architecture management | Production Architecture Management |