The Theory: How a Constant Current Sink Works
Building a programmable electronic load is a rite of passage for practical electrical engineering projects. Whether you are testing the ripple on a bench power supply, profiling a LiFePO4 battery discharge curve, or verifying a solar charge controller, you need a load that draws a precise, constant current regardless of voltage fluctuations. This project targets the ESP32 DevKit V1 (ESP32-WROOM-32) paired with an external 12-bit MCP4725 DAC to achieve milliamp-level precision.
The core of this circuit relies on an op-amp feedback loop. The ESP32 commands the MCP4725 DAC to output a reference voltage ($V_{ref}$) to the non-inverting input of an LM358 op-amp. The inverting input is tied to the high side of a shunt resistor ($R_{shunt}$) placed in series with the load's ground path. The op-amp drives the gate of an N-channel MOSFET to force the voltage across the shunt ($V_{sense}$) to equal $V_{ref}$. By Ohm's Law, the current is strictly defined as $I_{load} = V_{ref} / R_{shunt}$.
Component Selection and Thermal Limits
The most common failure mode in DIY electronic loads is MOSFET thermal runaway or exceeding the op-amp's output swing. The LM358 is not a rail-to-rail output op-amp; when powered by 5V, its maximum output swing is roughly 3.5V. Therefore, you must use a logic-level MOSFET (like the IRLZ44N) that is fully enhanced at $V_{gs} = 3.5V$, rather than a standard IRF540N which requires 10V to achieve its rated $R_{DS(on)}$.
Selecting the correct shunt resistor dictates your maximum current range and the thermal design of your load. The table below maps shunt values to maximum current limits, assuming a conservative 0.5V maximum DAC reference voltage to keep power dissipation manageable.
| Shunt Value | Max Current (at 0.5V $V_{ref}$) | Power Dissipation at Max | Recommended Wattage Rating | Physical Package |
|---|---|---|---|---|
| 1.0 Ω | 0.5 A | 0.25 W | 1 W | Axial / Through-hole |
| 0.1 Ω | 5.0 A | 2.50 W | 5 W | Ceramic block / TO-220 mount |
| 0.05 Ω | 10.0 A | 5.00 W | 10 W | Chassis mount (aluminum housed) |
| 0.01 Ω | 50.0 A | 25.0 W | 50 W | Heavy chassis mount + forced air |
Note: For currents above 10A, the MOSFET's Safe Operating Area (SOA) in linear mode becomes the limiting factor, not just total wattage. See the All About Circuits semiconductor guide for deep-dive theory on secondary breakdown in linear MOSFET operation.
Parts List and Pin Mapping
This build uses off-the-shelf breakout boards to minimize custom PCB fabrication, making it ideal for the workbench.
- Microcontroller: ESP32 DevKit V1 (30-pin, ESP32-WROOM-32)
- DAC: MCP4725 I2C 12-bit DAC breakout (Adafruit or generic)
- Op-Amp: LM358P Dual Op-Amp (DIP-8 or SOIC-8)
- MOSFET: IRLZ44N Logic-Level N-Channel (TO-220)
- Shunt: 0.1 Ω 5W Precision Power Resistor
- Display: 128x64 SSD1306 I2C OLED
- Heatsink: Minimum 10°C/W finned aluminum heatsink for TO-220
| ESP32 GPIO | Target Component | Function / Notes |
|---|---|---|
| GPIO 21 | I2C SDA | Shared bus for MCP4725 and SSD1306 |
| GPIO 22 | I2C SCL | Shared bus for MCP4725 and SSD1306 |
| GPIO 34 | Shunt Sense (ADC) | Input to ESP32 ADC. Use ADC1 channel to avoid WiFi conflicts. |
| GPIO 35 | Current Set Pot | 10kΩ potentiometer wiper for manual current adjustment |
Assembly and Calibration Steps
- Prepare the Power Stage: Mount the IRLZ44N to the heatsink using thermal paste. Connect the Source pin to one side of the 0.1Ω shunt resistor. Connect the other side of the shunt to the system Ground.
- Wire the Feedback Loop: Connect the LM358 Output (Pin 1) to the MOSFET Gate. Connect the Inverting Input (Pin 2) to the MOSFET Source (the high side of the shunt). Connect the Non-Inverting Input (Pin 3) to the MCP4725 VOUT.
- Kelvin Connection: When wiring the ESP32 ADC (GPIO 34) to measure the shunt voltage, solder the sense wires directly to the resistor body, not the PCB pads. This 4-wire Kelvin connection eliminates voltage drop errors from the trace resistance carrying high current.
- Power the Logic: Feed 5V to the ESP32 DevKit via USB or the 5V pin. The LM358 and MCP4725 should also run off this 5V rail. Ensure the DUT ground is tied to the load ground.
- Verify I2C Addresses: The SSD1306 is typically at
0x3C. The MCP4725 is at0x60(if the A0 pin is tied low) or0x61(if A0 is high). Check your specific breakout board's silkscreen.
Complete ESP32 Control Code
The following C++ code targets the ESP32 Arduino Core. It reads a manual potentiometer to set the target current, commands the DAC, reads the actual shunt voltage via the ADC, and displays both on the OLED. It includes robust I2C error handling to prevent silent failures.
#include <Wire.h>
#include <Adafruit_MCP4725.h>
#include <Adafruit_SSD1306.h>
// --- PIN DEFINITIONS ---
#define I2C_SDA 21
#define I2C_SCL 22
#define ADC_SENSE_PIN 34 // ADC1_CH6 (Shunt voltage)
#define POT_SET_PIN 35 // ADC1_CH7 (Target current pot)
// --- HARDWARE CONSTANTS ---
#define SHUNT_RESISTANCE 0.1f // Ohms
#define DAC_RESOLUTION 4095.0f // 12-bit
#define VCC 5.0f // Op-amp and DAC reference voltage
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
Adafruit_MCP4725 dac;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
float filtered_adc_voltage = 0.0f;
const float alpha = 0.1f; // Low-pass filter coefficient
void setup() {
Serial.begin(115200);
Wire.begin(I2C_SDA, I2C_SCL);
// Initialize DAC with error handling
if (!dac.begin(0x60)) {
Serial.println("[FATAL] MCP4725 not found on I2C bus (0x60).");
Serial.println("Check wiring, pull-ups, and A0 pin state.");
while (1) { delay(100); } // Halt execution
}
// Initialize OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("[FATAL] SSD1306 OLED not found.");
while (1) { delay(100); }
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.println("Electronic Load");
display.println("Initializing...");
display.display();
// Set initial safe state (0A)
dac.setVoltage(0, false);
analogReadResolution(12); // Set ESP32 ADC to 12-bit for matching math
}
void loop() {
// 1. Read target current from potentiometer (0 to 5.0A max)
int pot_raw = analogRead(POT_SET_PIN);
float target_current = (pot_raw / 4095.0f) * 5.0f;
// 2. Calculate required DAC voltage (V = I * R)
float target_vref = target_current * SHUNT_RESISTANCE;
// 3. Convert to 12-bit DAC value
uint16_t dac_value = (target_vref / VCC) * DAC_RESOLUTION;
if (dac_value > 4095) dac_value = 4095;
dac.setVoltage(dac_value, false);
// 4. Read actual shunt voltage with exponential moving average filter
int adc_raw = analogRead(ADC_SENSE_PIN);
float instant_voltage = (adc_raw / 4095.0f) * 3.3f; // ESP32 ADC max is ~3.3V
filtered_adc_voltage = (alpha * instant_voltage) + ((1.0f - alpha) * filtered_adc_voltage);
float actual_current = filtered_adc_voltage / SHUNT_RESISTANCE;
// 5. Update OLED Display
display.clearDisplay();
display.setCursor(0, 0);
display.printf("Target: %.2f A", target_current);
display.setCursor(0, 20);
display.printf("Actual: %.2f A", actual_current);
display.setCursor(0, 40);
display.printf("V_shunt: %.3f V", filtered_adc_voltage);
display.display();
delay(50); // 20Hz update rate
}
Debugging: First Three Things to Check When It Fails
When working with mixed-signal circuits on the ESP32, I2C bus lockups and ADC noise are the primary culprits. If your serial monitor outputs the exact error string [E][Wire.cpp:513] requestFrom(): i2cRead returned 0 followed by the DAC initialization failure, the ESP32 is failing to receive an ACKnowledge (ACK) bit from the MCP4725. Here is the ranked decision path to fix it:
- Missing or Weak I2C Pull-ups: The ESP32's internal pull-ups are roughly 45kΩ, which is far too weak for a 400kHz I2C bus with any capacitance. Ensure your MCP4725 and SSD1306 breakout boards have 4.7kΩ pull-up resistors populated to 3.3V or 5V. If using bare chips, add external 4.7kΩ resistors on SDA and SCL.
- Address Mismatch (A0 Pin State): The MCP4725 address is hardcoded by the A0 pin on the silicon or breakout. If your breakout board ties A0 to VCC, the address is
0x61, not0x60. Run an I2C scanner sketch to verify the actual hex address on the bus, and updatedac.begin(0x60)accordingly. - Logic Level Shifting Conflict: If you are powering the MCP4725 with 5V but the ESP32 is outputting 3.3V logic on SDA/SCL, the DAC might not recognize the HIGH state. The MCP4725 I2C threshold is typically $0.7 \times V_{DD}$. Power the DAC from the ESP32's 3.3V pin, or use a bidirectional logic level shifter (like the BSS138-based modules) to bridge the 3.3V and 5V domains.
For deeper troubleshooting on ESP32 I2C peripheral quirks, consult the official Espressif I2C API documentation, specifically the section on glitch filtering and timeout configurations.
Extending and Simplifying the Build
This baseline constant current (CC) load is highly modular. Depending on your bench needs, you can adapt the design in two distinct directions:
Simplifying for Quick Bench Tests
If you don't need programmable precision and just want a manual dummy load, strip out the ESP32, MCP4725, and OLED. Replace the DAC output with a multi-turn 10kΩ precision trimpot wired as a voltage divider between 5V and GND. Feed the wiper directly into the LM358 non-inverting input. You lose digital readouts, but you gain a purely analog, zero-latency constant current sink that costs under $5 in parts.
Extending for Advanced Profiling
To turn this into a true programmable load, implement Constant Power (CP) and Constant Resistance (CR) modes. This requires adding a voltage divider to measure the DUT's actual terminal voltage via a second ESP32 ADC channel. For CP mode, the firmware calculates the required current dynamically: $I_{target} = P_{desired} / V_{measured}$. Because the DUT voltage will sag as current increases, you must implement a PID (Proportional-Integral-Derivative) control loop in the ESP32 firmware to prevent oscillation. Start with a low integral gain to avoid the feedback loop ringing when testing low-impedance battery cells.
By mastering the analog feedback loop and the digital control layer, this single electrical engineering project bridges the gap between abstract circuit theory and practical, bench-verified power electronics.






