How the PID Control Loop Regulates Force in the ESP32 FFB Pedal Firmware

The ESP32 firmware implements a 1 kHz closed-loop controller using the QuickPID library to compare measured load-cell force against a target force curve, computing a fractional correction that drives the stepper motor position to maintain precise pedal resistance.

The DIY sim-racing force-feedback pedal relies on precise force regulation to simulate realistic brake and throttle feel. At the heart of the ESP32 firmware in the chrgri/diy-sim-racing-ffb-pedal repository lies a PID control loop that continuously adjusts the stepper motor based on real-time load-cell feedback. This article examines the implementation details, covering the data flow from sensor input to motor output, the runtime tuning architecture, and the safety mechanisms that prevent integral windup.

Architecture of the Control Cycle

The force regulation loop executes seven discrete steps within the pedalUpdateTask, which runs at approximately 1 kHz. The process transforms raw sensor data into a motor position command through the following pipeline:

  1. Sensor Acquisition and Filtering – The loadcellReadingTask() samples the load cell and applies a first-order Kalman filter (KalmanFilter_1st_order) to reduce noise, producing a filteredReading value.
  2. Force Conversion – The filtered ADC value is converted to a force measured in kilograms before being passed to the control strategy.
  3. Target Force Lookup – The controller determines the desired resistance by evaluating the calibrated force curve at the current stepper position using forceCurve->EvalForceCubicSpline().
  4. Normalization – Both the measured input and the target setpoint are normalized to the [0, 1] range based on the configured minimum and maximum force limits (lines 57–63 of StepperMovementStrategy.h).
  5. PID Computation – The QuickPID::Compute() method processes the error between normalized input and setpoint, generating an output correction factor.
  6. Position Translation – The PID output is converted from a fractional value into an absolute stepper position, with optional velocity feedforward added to improve transient response.
  7. Limit Enforcement – The final position is clipped to mechanical limits using constrain() before being commanded to the stepper driver.

QuickPID Implementation and Initialization

The firmware utilizes the QuickPID library to execute the control algorithm. The controller is instantiated in StepperMovementStrategy.h with explicit mode selections for derivative-on-measurement and proportional-on-error behavior:

#include <QuickPID.h>

float Setpoint, Input, Output;                // PID I/O variables
float Kp = 0.3f, Ki = 50.0f, Kd = 0.0f;       // Default gains

QuickPID myPID(&Input, &Output, &Setpoint,
               Kp, Ki, Kd,
               myPID.pMode::pOnError,
               myPID.dMode::dOnMeas,
               myPID.iAwMode::iAwOff,
               myPID.Action::direct);

bool pidWasInitialized = false;

During the first execution of a control cycle, the controller transitions from manual to automatic mode and configures anti-windup clamping:

if (!pidWasInitialized) {
    myPID.SetTunings(Kp, Ki, Kd);
    myPID.SetMode(myPID.Control::automatic);
    myPID.SetAntiWindupMode(myPID.iAwMode::iAwClamp);
    pidWasInitialized = true;
    myPID.SetSampleTimeUs(PUT_TARGET_CYCLE_TIME_IN_US);
    myPID.SetOutputLimits(-PID_OUTPUT_LIMIT_FL32, PID_OUTPUT_LIMIT_FL32);
}

Runtime Tuning and Anti-Windup Protection

The PID gains are not hardcoded; they are dynamically loaded from the configuration structure DAP_config_st via the tunePidValues() function:

void tunePidValues(DAP_config_st& cfg) {
    Kp = cfg.payLoadPedalConfig_.PID_p_gain;
    Ki = cfg.payLoadPedalConfig_.PID_i_gain;
    Kd = cfg.payLoadPedalConfig_.PID_d_gain;
    myPID.SetTunings(Kp, Ki, Kd);
}

This allows users to adjust P, I, and D gains through the configuration payload without reflashing the firmware.

Adaptive Output Limits

To prevent integral windup when the pedal approaches its mechanical end-stops, the firmware dynamically tightens the PID output limits based on the current stepper position fraction (stepperFrac):

float neg_limit = 1.0f - stepperFrac;
float pos_limit = stepperFrac;

if (pos_limit < PID_OUTPUT_LIMIT_FL32) {
    myPID.SetOutputLimits(-PID_OUTPUT_LIMIT_FL32, pos_limit);
}
else if (neg_limit < PID_OUTPUT_LIMIT_FL32) {
    myPID.SetOutputLimits(-neg_limit, PID_OUTPUT_LIMIT_FL32);
}
else {
    myPID.SetOutputLimits(-PID_OUTPUT_LIMIT_FL32, PID_OUTPUT_LIMIT_FL32);
}

By restricting the integral term’s ability to accumulate when the actuator is saturated, this mechanism ensures the controller recovers immediately when the error reverses direction.

Dynamic Gain Scaling

When control_strategy_b is set to 1, the firmware implements dynamic gain scaling to compensate for varying stiffness in the force curve. The controller evaluates the gradient of the force curve at the current position and inversely scales the PID gains:

float grad = forceCurve->EvalForceGradientCubicSpline(..., true);
float gain_mod = (fabs(grad) > 1e-5f) ? 1.0f / pow(fabs(grad), 1.0f) : 10.0f;
gain_mod = constrain(gain_mod, 0.1f, 10.0f);

myPID.SetTunings(gain_mod * Kp, gain_mod * Ki, gain_mod * Kd);

This technique reduces controller aggression when the force curve is steep (high gradient) and increases responsiveness when the curve is shallow, maintaining consistent feel across the pedal travel.

Invoking the Controller from pedalUpdateTask

The PID strategy is invoked from the main control task in Main.cpp. When the configuration selects the PID control strategy, the MoveByPidStrategy function is called with the filtered load-cell reading, current stepper fraction, and configuration pointers:

Position_Next = MoveByPidStrategy(
        filteredReading,
        stepperPosFraction,
        stepper,
        &forceCurve,
        &dap_calculationVariables_st,
        &dap_config_pedalUpdateTask_st,
        0.0f,               // effect_force (unused in standard PID mode)
        changeVelocity);

In the ESP32 master board implementation, this call occurs at line 1937 of ESP32/src/Main.cpp, while the Pedal-V3 variant references line 1250 of Firmware_for_V3/PedalFirmware/src/Main.cpp.

Summary

  • The PID control loop runs at 1 kHz inside pedalUpdateTask, comparing normalized load-cell force against a target force curve derived from cubic spline interpolation.
  • The implementation uses the QuickPID library with derivative-on-measurement and runtime-configurable gains stored in DAP_config_st.
  • Adaptive output limits prevent integral windup by constraining the controller output based on proximity to mechanical end-stops (lines 91–105 of StepperMovementStrategy.h).
  • Dynamic gain scaling optionally adjusts PID parameters inversely to the force curve gradient to maintain consistent responsiveness.
  • The computed output is converted to a stepper position command with optional velocity feedforward and constrained to safe mechanical limits before execution.

Frequently Asked Questions

How often does the PID control loop execute?

The control loop executes at approximately 1 kHz within the pedalUpdateTask FreeRTOS task. This high frequency ensures responsive force regulation and stable closed-loop behavior during rapid pedal movements.

What prevents integral windup when the pedal reaches mechanical limits?

The firmware implements adaptive output limiting that adjusts the PID output bounds based on the current stepper position fraction (stepperFrac). As the pedal approaches either end-stop (0.0 or 1.0 fraction), the positive or negative output limit is tightened proportionally, preventing the integral term from accumulating while the actuator is saturated.

Can PID gains be adjusted without reflashing the ESP32 firmware?

Yes. The gains are loaded at runtime from the DAP_config_st configuration structure via the tunePidValues() function. Users can modify the PID_p_gain, PID_i_gain, and PID_d_gain fields in the configuration payload and send them to the pedal, where myPID.SetTunings() applies the new values immediately without requiring a firmware reflash.

Why does the firmware normalize force values to the [0, 1] range?

Normalization ensures the PID controller operates on a consistent scale regardless of the physical load-cell calibration or user-defined force limits. By mapping both the measured input and target setpoint to [0, 1] using the calibrated Force_Min and Force_Range values, the controller maintains predictable behavior across different hardware configurations and allows for standardized gain tuning parameters.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →