How to Implement a State Machine for Safe Pneumatic Actuator Control on STM32
State Machine Design for Safe Pneumatic Actuator Control on STM32
Pneumatic actuators can move fast and with real force. If your firmware gets the control logic wrong, you risk damaging equipment or worse. A well-structured state machine gives you predictable behavior and clean fault handling -- exactly what you need when safety matters.
This walkthrough covers building a state machine on an STM32 using the HAL library to control a pneumatic actuator with proper fault detection. The pattern here works for any safety-related actuator control, not just pneumatics.
Prerequisites
- Familiarity with STM32 development and the HAL library
- STM32CubeIDE v1.16+ installed
- Working knowledge of C and basic state machine concepts
- Understanding of your pneumatic system's safety requirements
Parts and Tools
- STM32 development board (F4 or F7 series work well for this)
- Pneumatic actuator with solenoid valve
- Appropriate power supply for the actuator (typically 12V or 24V)
- Pressure sensor or position feedback sensor
- Relay or MOSFET driver circuit for the solenoid
- Wires, connectors, and a multimeter for testing
Steps
- Set Up the STM32CubeIDE Project
Open STM32CubeIDE v1.16+ and create a new STM32 project. Select your specific MCU (for example, STM32F446RE) and let CubeMX generate the initialization code. Configure the GPIO pin that will drive your solenoid valve as an output, and set up an ADC channel if you're reading an analog pressure sensor.
One thing people skip: configure a hardware watchdog timer (IWDG) from the start. If your state machine locks up, the watchdog resets the MCU and puts the actuator in a known-safe state. For safety-related applications, this isn't optional.
- Define the State Machine Structure
Keep your states minimal and explicit. Every state should have a clear purpose and clear exit conditions. Here's a solid starting point:
typedef enum { STATE_IDLE, STATE_ACTUATE_EXTEND, STATE_ACTUATE_RETRACT, STATE_FAULT, STATE_EMERGENCY_STOP } ActuatorState; typedef struct { ActuatorState current; uint32_t stateEntryTick; uint32_t faultCode; } StateMachine; StateMachine sm = { .current = STATE_IDLE, .stateEntryTick = 0, .faultCode = 0 };I like wrapping state info in a struct rather than using bare globals. It makes the code easier to test and lets you run multiple state machines if needed later.
- Implement the State Machine Logic
The main update function runs each loop iteration. Each state checks its own transition conditions and handles its own behavior:
void StateMachine_Update(StateMachine *sm) { switch (sm->current) { case STATE_IDLE: HAL_GPIO_WritePin(VALVE_GPIO_Port, VALVE_Pin, GPIO_PIN_RESET); if (commandReceived() && systemHealthy()) { sm->current = STATE_ACTUATE_EXTEND; sm->stateEntryTick = HAL_GetTick(); } break; case STATE_ACTUATE_EXTEND: HAL_GPIO_WritePin(VALVE_GPIO_Port, VALVE_Pin, GPIO_PIN_SET); if (checkFault(sm)) { sm->current = STATE_FAULT; sm->stateEntryTick = HAL_GetTick(); } else if (positionReached() || (HAL_GetTick() - sm->stateEntryTick > ACTUATION_TIMEOUT_MS)) { sm->current = STATE_ACTUATE_RETRACT; sm->stateEntryTick = HAL_GetTick(); } break; case STATE_ACTUATE_RETRACT: HAL_GPIO_WritePin(VALVE_GPIO_Port, VALVE_Pin, GPIO_PIN_RESET); if (checkFault(sm)) { sm->current = STATE_FAULT; sm->stateEntryTick = HAL_GetTick(); } else if (homePositionReached()) { sm->current = STATE_IDLE; } break; case STATE_FAULT: handleFault(sm); break; case STATE_EMERGENCY_STOP: HAL_GPIO_WritePin(VALVE_GPIO_Port, VALVE_Pin, GPIO_PIN_RESET); // Stays here until manual reset break; } }Notice the timeout on the actuation state. If the actuator doesn't reach its target position within a reasonable window, something is wrong -- maybe a stuck valve, lost air pressure, or a mechanical jam. Timeouts catch problems that sensor-based fault detection might miss.
- Implement Fault Detection
Your fault detection should cover the failure modes that actually happen in pneumatic systems: pressure out of range, actuator stall, sensor disconnect, and overcurrent on the solenoid driver.
#define PRESSURE_MIN 100 // ADC counts -- calibrate to your sensor #define PRESSURE_MAX 3800 #define SENSOR_DISCONNECT_THRESHOLD 10 // Near-zero usually means disconnected bool checkFault(StateMachine *sm) { uint32_t pressure = readPressureSensor(); if (pressure < SENSOR_DISCONNECT_THRESHOLD) { sm->faultCode = FAULT_SENSOR_DISCONNECT; return true; } if (pressure < PRESSURE_MIN) { sm->faultCode = FAULT_PRESSURE_LOW; return true; } if (pressure > PRESSURE_MAX) { sm->faultCode = FAULT_PRESSURE_HIGH; return true; } return false; }Watch out for noisy ADC readings triggering false faults. Add a simple moving average or require N consecutive out-of-range readings before latching a fault. A single glitchy sample shouldn't shut down your system.
- Handle Faults Safely
When a fault is detected, the first priority is getting the actuator to a safe state. Then log the fault so you can diagnose it later.
void handleFault(StateMachine *sm) { // Immediately de-energize the solenoid HAL_GPIO_WritePin(VALVE_GPIO_Port, VALVE_Pin, GPIO_PIN_RESET); // Log the fault (to UART, flash, or wherever) logFault(sm->faultCode); // Blink an LED or activate a buzzer to signal the fault HAL_GPIO_TogglePin(FAULT_LED_GPIO_Port, FAULT_LED_Pin); // Optionally allow recovery after a cooldown period if ((HAL_GetTick() - sm->stateEntryTick > FAULT_COOLDOWN_MS) && !checkFault(sm)) { sm->current = STATE_IDLE; sm->faultCode = 0; } }Whether you allow automatic recovery depends on your application's safety requirements. For many industrial systems, faults should latch and require a manual reset. The code above shows auto-recovery, but for anything where injury is possible, force an operator to acknowledge the fault before restarting.
- Main Loop Integration
Keep the main loop clean. The state machine does the heavy lifting:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_IWDG_Init(); // Watchdog timer StateMachine sm = { .current = STATE_IDLE, .stateEntryTick = 0, .faultCode = 0 }; while (1) { StateMachine_Update(&sm); HAL_IWDG_Refresh(&hiwdg); // Feed the watchdog HAL_Delay(10); // 10ms loop -- fast enough for most pneumatics } }The 10ms loop period is a good starting point for pneumatic control. If you need tighter timing, consider running the state machine from a timer interrupt or using FreeRTOS v11 with a dedicated control task at a fixed period.
Troubleshooting
- Actuator not responding at all: Check your power supply voltage and current rating first. Then verify the GPIO pin is actually toggling with a multimeter or scope. A common mistake is forgetting to enable the GPIO clock in the CubeMX configuration.
- Fault state triggering on every cycle: Your sensor thresholds are probably too tight or the ADC readings are noisy. Add some filtering (even a simple 4-sample average helps) and verify your thresholds against actual sensor readings using a debugger.
- State machine seems stuck: Set breakpoints in each state's case block to see where execution is landing. Check that your transition conditions are actually being evaluated -- a missing
breakstatement or an always-false condition are the usual culprits. - MCU resets unexpectedly: If you enabled the watchdog timer, make sure you're feeding it in every code path. A fault handler that runs for a long time without refreshing the watchdog will trigger a reset.
Design Considerations
This example covers the fundamentals, but for a production safety system, you'd also want to consider: redundant sensor inputs (don't trust a single sensor for safety decisions), a proper logging mechanism that survives resets (write faults to internal flash or external EEPROM), and compliance with whatever safety standard applies to your application (IEC 61508, ISO 13849, etc.). Test your fault paths as thoroughly as your happy paths -- an untested fault handler is worse than no fault handler because it gives you false confidence.