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

> Learn how the ESP32 firmware uses a PID control loop to achieve precise pedal resistance by regulating force. Discover its closed-loop controller and stepper motor integration.

- Repository: [chrgri/diy-sim-racing-ffb-pedal](https://github.com/chrgri/diy-sim-racing-ffb-pedal)
- Tags: internals
- Published: 2026-02-27

---

**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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/StepperMovementStrategy.h) with explicit mode selections for derivative-on-measurement and proportional-on-error behavior:

```cpp
#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:

```cpp
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:

```cpp
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`):

```cpp
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:

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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:

```cpp
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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/ESP32/src/Main.cpp), while the Pedal-V3 variant references line 1250 of [`Firmware_for_V3/PedalFirmware/src/Main.cpp`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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`](https://github.com/chrgri/diy-sim-racing-ffb-pedal/blob/main/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.