The Verdict: Best Starter Build for Circuit Theory & Code
If you want to bridge the gap between abstract DC circuit theory and embedded firmware, the single most effective build is an automated 18650 lithium-ion battery capacity tester. This project forces you to calculate power dissipation, understand analog-to-digital converter (ADC) limitations, and write non-blocking I2C polling loops. Unlike blinking an LED, this build yields a highly practical bench tool that measures real-world milliamp-hours (mAh) using Coulomb counting.
Board Variant & Component Decision Path
The code and wiring below specifically target the ESP32-WROOM-32 DevKit v1 (30-pin variant). Do not use the 38-pin variant or the ESP32-C3 without adjusting the I2C pin definitions, as the internal routing differs.
Selecting the correct load resistor is where most beginners fail, resulting in melted breadboards or tripped battery protection circuits. Use this decision matrix to select your load:
| Battery Type | Target Test Current | Calculated Resistance (R=V/I) | Power Dissipation (P=I²R) | Final Concrete Pick |
|---|---|---|---|---|
| 18650 Li-ion (Standard) | 0.5A (500mA) | 7.4Ω (at 3.7V nominal) | 1.85W | 8.2Ω 5W Ceramic |
| 18650 Li-ion (Safe/Slow) | 0.37A (370mA) | 10.0Ω (at 3.7V nominal) | 1.36W | 10Ω 5W Ceramic |
| LiPo 1S (Small) | 0.2A (200mA) | 18.5Ω | 0.74W | 20Ω 2W Film |
Wiring Pinout & Spec Sheet
We use an external INA219 current sensor rather than the ESP32's internal ADC. The ESP32's internal ADC is notoriously non-linear above 2.5V, which ruins battery discharge curve accuracy. The INA219 handles the shunt voltage measurement and I2C conversion internally.
| Component | Exact Variant / Model | ESP32 Pin | Function |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit v1 (30-pin) | - | Main logic & I2C master |
| Current Sensor | Adafruit INA219 Breakout (0.1Ω shunt) | SDA (GPIO 21) SCL (GPIO 22) |
High-side current & bus voltage sensing |
| Switching MOSFET | IRLZ44N (Logic Level N-Channel) | Gate (GPIO 26) | Turns load on/off via 3.3V logic |
| Load Resistor | 10Ω 5W Ceramic Cement | Drain to Source | Dummy load to discharge battery |
The Theory: Why Bypass the Internal ADC?
To understand why we spend $4 on an INA219 breakout, you must understand the ESP32 ADC non-linearity. The ESP32 uses a 12-bit SAR ADC. Theoretically, 12 bits across a 3.3V reference yields 0.8mV resolution. However, the internal ADC curve flattens out severely above 2.5V. If you try to measure a 4.2V full-charge 18650 using a simple voltage divider into GPIO 34, your readings will jump erratically, ruining the Coulomb counting integration.
Coulomb counting calculates capacity by integrating current over time:
Capacity (mAh) = Σ [ Current (mA) × Δt (hours) ]
Because the INA219 uses a dedicated 12-bit delta-sigma ADC and a precision 0.1Ω shunt resistor, it provides linear, calibrated current readings via I2C, completely bypassing the ESP32's analog flaws. For a deeper look at how the INA219 calculates shunt voltage, refer to the Adafruit INA219 hardware guide.
Compilable Firmware with I2C Error Handling
This code targets the Arduino IDE with the ESP32 core installed. You must install the Adafruit_INA219 and Adafruit_BusIO libraries via the Library Manager before compiling.
#include <Wire.h>
#include <Adafruit_INA219.h>
// --- PIN DEFINITIONS ---
#define PIN_SDA 21
#define PIN_SCL 22
#define PIN_MOSFET_GATE 26
Adafruit_INA219 ina219;
// --- STATE VARIABLES ---
unsigned long lastMillis = 0;
float totalCapacity_mAh = 0.0;
bool testing = false;
const float CUTOFF_VOLTAGE = 2.80; // Li-ion safe discharge limit
void setup() {
Serial.begin(115200);
// Initialize MOSFET gate to LOW (Load OFF) to prevent boot-up drain
pinMode(PIN_MOSFET_GATE, OUTPUT);
digitalWrite(PIN_MOSFET_GATE, LOW);
// Initialize I2C with explicit pins
Wire.begin(PIN_SDA, PIN_SCL);
// Hardware check with explicit error string
if (!ina219.begin(&Wire)) {
Serial.println("Failed to find INA219 chip");
while (1) {
delay(10); // Halt execution indefinitely
}
}
// Optional: Calibrate for 32V / 1A range (default is 26V / 3.2A)
// ina219.setCalibration_16V_400mA();
Serial.println("INA219 Initialized. Send '1' via Serial Monitor to start test.");
}
void loop() {
// Serial command handler
if (Serial.available() > 0) {
char cmd = Serial.read();
if (cmd == '1' && !testing) {
testing = true;
digitalWrite(PIN_MOSFET_GATE, HIGH); // Turn on load
lastMillis = millis();
totalCapacity_mAh = 0.0; // Reset counter
Serial.println("Test Started... Monitoring discharge.");
}
}
if (testing) {
unsigned long currentMillis = millis();
// Calculate delta time in hours for Coulomb counting
float dt_hours = (currentMillis - lastMillis) / 3600000.0;
lastMillis = currentMillis;
float busvoltage = ina219.getBusVoltage_V();
float current_mA = ina219.getCurrent_mA();
// Cutoff logic to prevent over-discharge
if (busvoltage < CUTOFF_VOLTAGE) {
testing = false;
digitalWrite(PIN_MOSFET_GATE, LOW);
Serial.print("Test Complete. Total Capacity: ");
Serial.print(totalCapacity_mAh);
Serial.println(" mAh");
} else {
// Integrate current over time
totalCapacity_mAh += (current_mA * dt_hours);
Serial.print("V: "); Serial.print(busvoltage, 2);
Serial.print(" | I: "); Serial.print(current_mA, 1);
Serial.print(" mA | Cap: "); Serial.print(totalCapacity_mAh, 1);
Serial.println(" mAh");
}
delay(1000); // 1 Hz sample rate
}
}
Debugging: First Three Things to Check When It Fails
When the serial monitor halts or outputs garbage, follow this ranked troubleshooting path. These are the most common failure modes on the bench.
1. The Serial Monitor prints: "Failed to find INA219 chip"
This exact error string triggers when the ESP32 sends an I2C handshake to address 0x40 and receives no ACK.
- Cause A (Wiring): SDA and SCL are swapped. Verify GPIO 21 is SDA and GPIO 22 is SCL.
- Cause B (Power): The INA219 VCC pin is not receiving 3.3V. Measure across the breakout's VCC and GND pins with a multimeter. It must read between 3.2V and 3.4V.
- Cause C (Address Conflict): You bridged the A0 address jumper on the INA219 board. The code expects the default
0x40address. Cut the jumper trace if it is bridged.
2. Voltage Reads Correctly, but Current Reads "0.0 mA"
The I2C bus is working, but the shunt is not measuring voltage drop.
- Cause A (MOSFET Wiring): The load circuit is open. Measure the resistance across the Drain and Source of the IRLZ44N MOSFET while the GPIO is HIGH. It should read < 1 ohm. If it reads infinite, your Gate pin isn't triggering, or you are using a standard N-channel MOSFET (like the IRF520) that requires 10V to turn on, rather than a logic-level MOSFET.
- Cause B (Blown Shunt): You accidentally shorted the INA219 Vin- and Vin+ terminals, blowing the internal 0.1Ω SMD shunt resistor. Replace the breakout board.
3. Capacity Math is Wildly Inaccurate (e.g., 50 mAh for a 3000mAh cell)
The hardware is fine, but the integration math is failing.
- Cause A (Blocking Code): You added a
delay()or a blocking screen update inside theif (testing)loop that takes longer than 1000ms. This skews thedt_hourscalculation. Ensure your loop timing remains strictly tied tomillis()deltas. - Cause B (Integer Overflow): You declared
dt_hoursortotalCapacity_mAhas anintinstead of afloat. The fractional milliamp increments will truncate to zero.
How to Extend or Simplify the Build
Depending on your current skill level and bench needs, you can scale this project up or down.
To Simplify (No-Code Hardware Test):
Remove the IRLZ44N MOSFET entirely. Wire the 10Ω resistor directly across the INA219 Vin+ and Vin- terminals. Plug the battery in manually to start the test, and unplug it when the serial monitor shows the voltage hitting 2.8V. This removes the need for GPIO control logic and eliminates MOSFET failure modes, though it requires manual supervision.
To Extend (Standalone Bench Tool):
Add a 1.3-inch I2C OLED screen (SSD1306 driver, 128x64 resolution). Wire it to the same I2C bus (SDA/SCL) but ensure it has a separate I2C address (usually 0x3C). Update the firmware to draw a real-time discharge curve graph using the Adafruit_SSD1306 library. This allows you to test batteries in the field without tethering the ESP32 to a laptop for serial monitoring. For advanced thermal protection, tape a DS18B20 waterproof temperature probe to the ceramic resistor and program a hard abort if the resistor exceeds 80°C.






