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.

Project Profile:
Difficulty: Intermediate Beginner (Requires basic soldering and I2C concepts)
Time to Build: 2 hours
Estimated Cost: $14 - $18 USD (2026 pricing)
Core Theory: Ohm's Law, Power Dissipation, Coulomb Counting, I2C Protocol

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
Default Recommendation: For testing standard 18650 cells without stressing the battery's internal protection BMS, terminate your decision path at the 10Ω 5W ceramic power resistor. It draws a safe ~370mA and the 5W rating ensures the resistor stays cool enough to handle without heat-sinking.

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
Lithium Fire Safety Warning: Never leave a discharging lithium cell unattended. If the MOSFET fails short-circuit, the resistor will drain the cell below 2.0V, causing permanent chemical damage and potential venting. Always wire a physical inline toggle switch between the battery positive terminal and the INA219 VIN pin as a hardware kill-switch.

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 0x40 address. 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 the if (testing) loop that takes longer than 1000ms. This skews the dt_hours calculation. Ensure your loop timing remains strictly tied to millis() deltas.
  • Cause B (Integer Overflow): You declared dt_hours or totalCapacity_mAh as an int instead of a float. 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.