Part 2 – Hardware Architecture, Discharge Load, and Precision Measurements
Introduction
I started 18650 Battery Tester project because I have a huge stash of 18650 cells pulled from old laptop battery packs. And it’s not just me—I know plenty of people who have drawers full of these batteries just sitting there. Reusing them is a win-win: it’s a great way to help the environment by keeping e-waste out of landfills, and it gives us a massive supply of cheap, reliable power for our own DIY projects. Plus, there’s nothing more satisfying than testing a pile of old cells and finding the healthy ones to put back to work.
In Part 1, we handled the ‘brain’ of the operation—the firmware state machine. Now, it’s time to build the body. In this part, we’ll dive into the hardware: how to charge the cell, build a reliable discharge load, and get it all talking to the ESP32. I’ll also get real about the hurdles I ran into—specifically, why measurement accuracy turned out to be the biggest bottleneck in this entire design.
Title in Other Languages
- German: 18650-Batterietester selber bauen: Hardware & Messverfahren
- French: Comment fabriquer un testeur de batterie 18650 : Matériel et mesure
- Spanish: Cómo construir un probador de baterías 18650: Hardware y medición
- Italian: Come costruire un tester per batterie 18650: Hardware e misurazione
- Portuguese (Brazil): Como construir um testador de bateria 18650: Hardware e medição
Tip: Right-click anywhere on this page and choose “Translate to your language”.
System Overview
This design needs a charging circuit to control the battery’s charging process, and I wanted to keep it separate from the ESP32. For that, I went with the TP4056. The discharge circuit is built as a constant-current load based on the MCP6002. For safety, we also have to think about what happens if the user plugs the DC adapter or the battery in backwards, so I added a reverse-polarity protection circuit. The ESP32 is the boss: it decides which unit should act and which one should shut down, based on the timing and the voltages and currents it measures.
Download Project Files
Download the complete files associated with this tutorial. The package includes files in the below list:
- Schematic and PCB in Altium Designer
- MicroPython file
- Schematic in PDF
- PCB in PDF
Free download · Educational resources
Charging the Cell: The TP4056 Module
For the charging stage, I decided to use the TP4056. I had several specific requirements in mind when selecting this module, and the TP4056 checked almost all the boxes.
Ease of Integration & Use
One of my main goals was to keep the charging process independent. By using a dedicated charger, I ensured that the charging task puts no extra electrical or computational load on the ESP32. The module is also incredibly easy to work with; it’s a tiny package that is perfect for manual soldering, and it requires very few supporting components to get up and running. Plus, being widely available and inexpensive made it a very practical choice for a DIY project.
Note on thermal design: I designed a custom aluminum PCB for the charger because during high-current charging operations, the IC gets quite hot, and a standard FR4 PCB does not have sufficient heat dissipation. Switching to an aluminum substrate solved this thermal issue nicely.
Control and Performance
Beyond just being easy to use, the TP4056 fits the functional needs of this tester perfectly. Its status LEDs provide a simple way for the ESP32 to monitor whether a cell is currently charging or if the process is complete. In terms of power, the 1A charging current is more than sufficient for standard 18650 cells, and the accuracy provided by the chip is well within the acceptable range for the goals of this project.
The Discharge Circuit: Constant-Current Active Load
Once a cell is rested, the core job of the capacity tester begins: drawing a controlled current down to a safe cutoff voltage (3.0 V) while measuring total milliamp-hours (mAh).
Why an Active MOSFET Load Instead of a Fixed Resistor?
A beginner might simply hook up a power resistor (like a 4 Ω or 8 Ω cement resistor) to discharge the battery. However, fixed resistors introduce a serious issue governed by Ohm’s Law (I = V / R): as the battery voltage drops from 4.2 V down to 3.0 V, the discharge current drops proportionally. Calculating capacity from an ever-changing current curve complicates math and degrades measurement accuracy.
Instead, I used an active dummy load centered around an N-channel logic-level MOSFET (IRLZ44N). The MOSFET acts as a dynamically variable resistor. As the cell voltage sags during discharge, the control circuitry continuously adjusts the MOSFET’s gate voltage to lower its internal channel resistance, holding the discharge current rock-steady.
This design brings two extra advantages:
- Software-programmable current: By altering the analog reference voltage from the ESP32, discharge current can be set anywhere from 100 mA to 1 A via firmware without touching physical switches.
- Instant electronic cutoff: When the cell hits the 3.0 V threshold, pulling the reference to 0 V instantly shuts off the MOSFET, isolating the cell without needing mechanical relays.
The Dual-Stage MCP6002 Circuit
To regulate and measure this current reliably, I chose the MCP6002 dual operational amplifier. It is cheap, widely available, operates cleanly from the ESP32’s 3.3 V rail, and—crucially—features rail-to-rail input and output (RRIO), meaning it can sense signals right down to ground without clipping.
I utilized both op-amp channels in a dual-stage setup:
Stage 1: The Closed-Loop Constant-Current Regulator
The ESP32 supplies a reference voltage (via DAC or filtered PWM) to the non-inverting pin of op-amp 1. Current flows from the battery, through the IRLZ44N MOSFET, and across a 100 mΩ shunt resistor connected to ground. The voltage across this shunt feeds back into the inverting input. The op-amp continuously drives the MOSFET gate to keep the shunt drop equal to the reference voltage, locking in the target current.
Stage 2: Precision Current-Sensing Amplifier
At low discharge rates (such as 100 mA), a 100 mΩ resistor drops only 10 mV—far too small for the ESP32’s built-in ADC to read with any meaningful resolution. The second op-amp in the MCP6002 is configured as a non-inverting amplifier with a gain of 10 (9 kΩ feedback and 1 kΩ ground resistor). This scales the 0–100 mV shunt drop up to 0–1.0 V, mapping comfortably into the ESP32 ADC’s linear region for precise logging.
Thermal Management
Burning energy from an 18650 cell means generating heat. At a 1 A discharge rate and an initial battery voltage of 4.2 V, the discharge load dissipates roughly 4 W of continuous power. Most of this power is dropped across the IRLZ44N, so I mounted a dedicated aluminum heatsink on the MOSFET to ensure thermal stability and prevent thermal runaway during long discharge runs.
Peripheral Circuits: Reverse-Polarity Protection & Error Indicators
In any test fixture where users swap cells frequently, human error is inevitable: sooner or later, an 18650 cell will be inserted backwards, or the wrong polarity DC adapter will be plugged in. Without protection, a reversed battery would direct reverse voltage straight into the sensitive inputs of the ESP32 ADC, the TP4056, and the MCP6002 op-amp, easily destroying them.
While a simple Schottky diode in series is the common beginner fix, its 0.3 V to 0.5 V forward drop wastes power, generates heat, and steals valuable voltage headroom needed for low-voltage battery measurements.
Instead, I implemented two active P-channel MOSFET reverse-polarity protection circuits:
- One on the DC barrel input to guard against reverse power supplies.
- One on the battery positive terminal to safeguard against reversed cell insertion.
Orientation is Everything: Taming the Body Diode
A common pitfall when designing MOSFET reverse-polarity protection is the MOSFET’s internal body diode. In a standard P-channel circuit, if you connect the Drain to the load and the Source to the input, the intrinsic body diode will forward-bias and conduct current if the input polarity is reversed—completely defeating the protection!
To prevent this, I deliberately swapped the drain and source connections:
- Under normal, correct polarity, the gate-to-source voltage (VGS) pulls sufficiently negative, turning the MOSFET channel fully ON with minimal on-resistance (RDS(on)), resulting in virtually zero voltage drop.
- Under reverse-polarity conditions, the body diode remains reverse-biased and blocks conduction. The gate voltage cannot pull below the source, keeping the MOSFET completely turned OFF and isolating the entire circuit downstream.
Visual User Feedback: Red Reverse-Polarity LEDs
Protection is great, but user awareness is just as important. I added a dedicated red warning LED across each protection stage:
- Under correct operating conditions, these LEDs remain reverse-biased (or unpowered) and consume zero current.
- The moment a user plugs in a DC adapter backwards or pops an 18650 cell into the holder with reversed polarity, the reversed voltage immediately forward-biases the corresponding red LED.
This gives the user instant, unmistakable visual feedback that something is plugged in backwards, while the P-channel MOSFET quietly protects all downstream electronics.
Second Layer: Overcurrent and Overvoltage Clamping
Reverse-polarity protection is only the first line of defense. The DC input also has to survive the two other classic failure modes: a short circuit downstream, and voltage spikes on the adapter line.
After the reverse-polarity P-channel MOSFET, I placed two additional protection devices in series on the protected rail:
- MF-MSMF150-2 resettable PTC fuse: This is a polymer positive temperature coefficient fuse rated at 1.5 A holding current. Under normal operation it behaves like a very low-value resistor (tens of milliohms), so it has almost no effect on the charging path. If the current exceeds its trip point—for example due to a shorted trace or a failed component—the fuse heats up, its resistance rises sharply, and it limits current to a safe level. Once the fault is removed and the fuse cools down, it returns to its low-resistance state automatically. No replacement needed, and no user access to a blown fuse.
- SMAJ5.0A TVS diode: This is a unidirectional transient voltage suppressor with a 5.0 V standoff voltage and 400 W peak pulse power capability. It sits across the protected rail and ground, normally invisible to the circuit because it only conducts above its breakdown voltage. If the adapter produces an inductive spike (for example when it is unplugged under load), or if ESD hits the connector, the TVS clamps the transient to a safe level within nanoseconds, protecting the TP4056, the ESP32 regulator, and the op-amp supply.
Together, the three stages give the DC input a complete protection chain: reverse polarity, then overcurrent, then overvoltage transients—each one handling a failure mode the others cannot.
Auxiliary Interfaces, Power Isolation, and High-Side Gate Drivers
Beyond core charging and discharging, a robust development board needs practical interfaces for debugging, programming, and power management.
1. USB and DC Power Isolation Header
When developing firmware, you often need to plug a USB cable into the ESP32 for serial monitoring and flashing while the rest of the hardware is powered by the main AC adapter. To prevent dangerous voltage contention (back-feeding between the USB 5 V rail and the onboard DC power supply regulator), I included a 2-pin header with a removable jumper.
- When programming via USB with the external adapter connected, you simply remove the jumper to safely isolate the power rails.
- For normal standalone operation, the jumper is kept shorted.
2. Expansion and Debugging Ports
To keep the tester versatile for future upgrades, I routed several auxiliary headers on the PCB:
- I2C Header: For connecting external OLED displays, real-time clock (RTC) modules, or environmental sensors.
- UART2 Header: Reserved for external two-way serial communication.
- Dedicated UART0 TX Header: A simple 2-pin header tapping into the ESP32’s primary TX line, allowing logs to be streamed directly to an external server, wireless module, or secondary display without interfering with USB flashing.
3. Level-Safe High-Side Switching (2N7002 + SSM3J332R)
Because the ESP32 GPIOs operate strictly at 3.3 V, they cannot directly drive high-side switches connected to higher voltages like the battery rail (3.0 V to 4.2 V) or the 5 V DC supply without risking damage or incomplete turn-off.
To solve this cleanly, I used a 2N7002 N-channel MOSFET as a low-side driver to control an SSM3J332R P-channel MOSFET on the high side:
- When the ESP32 drives the 2N7002 gate high (3.3 V), the 2N7002 pulls the P-channel gate to ground, turning the SSM3J332R fully ON.
- When the ESP32 pin goes low, a pull-up resistor raises the P-channel gate to the source voltage, turning it cleanly OFF.
This circuit provides complete logic-level translation, protecting the ESP32’s 3.3 V GPIOs while giving the firmware absolute control over higher-voltage power rails.
Thermal Management & Fan Header (FAN1)
During high-current discharge cycles, the IRLZ44N MOSFET dissipates significant power as heat (up to ∼4 W continuous). While a dedicated aluminum heatsink handles the baseline thermal load, active cooling is essential for prolonged heavy testing.
To support this, I included a 2-pin FAN1 header on the board. This header is versatile: it can be used to connect a cooling fan directly for continuous operation, or it can interface with an independent thermostatic control circuit that dynamically regulates the fan based on the actual temperature of the IRLZ44N heatsink.
Data Logging & CSV Storage
During the testing process, monitoring battery parameters in real time is crucial for analysis and debugging. The ESP32 logs test data directly to a CSV file named battery_log.csv stored in its internal flash filesystem.
As the state machine transitions through different phases (such as charging, resting, discharging, and final storage charge), the firmware periodically records key metrics:
Key Logged Metrics
- Current State: The active phase (e.g., CHECK_BATTERY, CHARGE, DISCHARGE, FINAL_RECHARGE, MEASURE_IR).
- Timestamp: High-resolution timestamps via
ticks_ms()to track exact test duration. - Discharge Current: Measured current flowing through the shunt resistor during active load phases.
- Battery Voltage (VBAT): The scaled and calibrated voltage reading of the 18650 cell.
- Calculated Results: Final capacity measurements and internal resistance values logged upon test completion.
This structured CSV output allows you to easily pull the log file from the ESP32 via MicroPython tools (like Thonny or mpremote) and plot discharge curves or analyze battery health over time using software like Excel or Python.
Frequently Asked Questions (FAQ)
Why use an active constant-current load instead of a high-power cement resistor?
A fixed resistor obeys Ohm’s Law (I = V / R). As an 18650 cell discharges from 4.2 V down to 3.0 V, the current drops continuously, requiring complex dynamic integration and reducing real-world measurement accuracy. An active dummy load using an N-channel MOSFET (IRLZ44N) and an operational amplifier acts like a self-adjusting variable resistor, maintaining a flat, rock-steady discharge current regardless of battery voltage drop. Furthermore, it enables programmatic software cut-offs without mechanical relays.
Can I charge and discharge the 18650 cell at the same time?
No. The tester is governed by a strict firmware state machine. The charging circuit (TP4056) and the constant-current discharge circuit (IRLZ44N load) operate in mutually exclusive phases separated by resting periods. Running both concurrently would create a circulating current, distorting capacity calculations and potentially overheating the board.
Why did you design a custom aluminum PCB for the TP4056 rather than standard FR4?
When charging at its full 1 A rating, the linear regulator on the TP4056 IC dissipates significant thermal energy. A standard 2-layer FR4 board has poor thermal conductivity, causing the IC to throttle its charging current or overheat. An aluminum-core PCB acts as an integrated heatsink, spreading heat rapidly and maintaining full 1 A throughput.
Why use P-channel MOSFETs for reverse-polarity protection instead of standard Schottky diodes?
While a Schottky diode is simple, it introduces a forward voltage drop of 0.3 V to 0.5 V. In a 3.0 V–4.2 V system, that drop wastes significant power as heat and robs critical voltage headroom from low-voltage cut-off measurements. A properly oriented P-channel MOSFET turns on with negligible on-resistance, creating an almost zero-volt drop during normal operation.
Why are the Drain and Source swapped on the reverse-polarity P-MOSFETs?
Every power MOSFET has an internal intrinsic body diode. In a standard high-side orientation, connecting the Source to the input and Drain to the load allows the body diode to conduct if a reverse voltage is applied, defeating the protection entirely. By swapping the Drain and Source, the body diode remains reverse-biased when polarity is inverted, completely blocking current flow and isolating downstream circuitry.
What happens if the PTC fuse trips? Do I need to replace it?
No replacement is necessary. The MF-MSMF150-2 is a polymeric positive temperature coefficient (PPTC) resettable fuse. When an overcurrent event occurs, self-heating causes its crystalline structure to expand, increasing its internal resistance by several orders of magnitude to throttle the fault current. Once the short circuit or fault is cleared and the fuse cools down, it automatically resets to its low-resistance state.
Why does the MCP6002 require a second stage with a gain of 10?
At low discharge currents (e.g., 100 mA), our 100 mΩ shunt resistor drops only 10 mV. The ESP32’s internal 12-bit ADC suffers from known non-linearities and noise floor issues near 0 V. By amplifying the 0–100 mV shunt drop by a factor of 10 using the second op-amp channel, the signal is scaled to 0–1.0 V, placing it squarely within the linear, high-resolution region of the ADC.
What cut-off voltage is recommended for 18650 cells, and why 3.0 V?
Most standard 18650 Lithium-ion datasheets specify an absolute safe cut-off limit between 2.5 V and 2.75 V under load. However, setting the cut-off to 3.0 V captures roughly 98% of the cell’s usable capacity while avoiding over-discharge stress, prolonging cycle life, and accommodating the voltage bounce-back that occurs once the load is disconnected.
Why is the isolation jumper necessary when connecting a USB cable?
The USB port supplies 5 V from your computer, while the tester relies on its own high-current DC barrel jack. Connecting both simultaneously without isolation risks back-feeding, voltage contention between regulators, and potential ground loops that can damage your PC motherboard or the ESP32. Removing the isolation jumper disconnects the onboard DC supply rail from the USB rail during firmware programming.
Where is battery_log.csv stored, and how do I export it?
The test log is written directly to the internal flash memory using the ESP32 SPIFFS/LittleFS filesystem. You can download the file over the USB serial connection using MicroPython tools such as Thonny IDE (via the Files panel) or the mpremote command-line utility for seamless data retrieval and plotting in Python or Excel.
How the Code Works (Step-by-Step)
Click here to Show MicroPython code
import micropython
from machine import Pin, ADC, DAC, reset, SoftI2C
from time import sleep, sleep_ms, ticks_ms, ticks_diff
from machine_i2c_lcd import I2cLcd
import sys
# =========================================================
# Constants & Configuration
# =========================================================
VBAT_MIN_ABSENT = 1.0
VBAT_MIN_DEEP = 2.0
VBAT_MAX_VALID = 4.3
VBAT_CHARGE_SKIP = 4.10
VBAT_CHARGE_DONE = 4.18
VBAT_DISCHARGE_CUTOFF = 3
CHARGE_SAMPLE_INTERVAL_S = 60
CHARGE_MAX_NORISE_TIMEOUT_S = 3 * 3600
CHARGE_MAX_TIMEOUT_S = 6 * 3600
REST_BEFORE_DISCHARGE_S = 15 * 60 #(15 seconds is used during development)
REST_BEFORE_IR_S = 10 * 60
PRINT_INTERVAl_MS = 5 * 1000
LOG_INTERVAL_MS = 300 * 1000
DISCHARGE_SAMPLE_INTERVAL_MS = 60 * 1000
IR_PULSE_MS = 100
SHUNT_RESISTOR_OHM = 0.1
TARGET_DISCHARGE_CURRENT_A = 0.43
CURRENT_DISCHARGE_CURRENT_A = 0
IR_LOAD_CURRENT_A = 1.0
# ADC conversion placeholders
ADC_MAX = 4095
ADC_REF = 3.3
# Example divider ratio:
# Vbat_actual = Vadc_measured * VBAT_DIVIDER_RATIO
VBAT_DIVIDER_RATIO = 4.3
VBAT_CAL = 0.947
# If current ADC reads shunt voltage through amplifier, adapt this:
CURRENT_SENSE_GAIN = 10.0
# =========================================================
# States
# =========================================================
STATE_DEBUG = True
STATE_IDLE = 'IDLE'
STATE_CHECK_BATTERY = 'CHECK_BATTERY'
STATE_DECIDE_CHARGE = 'DECIDE_CHARGE'
STATE_CHARGE = 'CHARGE'
STATE_REST_BEFORE_DISCHARGE = 'REST_BEFORE_DISCHARGE'
STATE_DISCHARGE = 'DISCHARGE'
STATE_FINAL_RECHARGE = 'FINAL_RECHARGE'
STATE_REST_BEFORE_IR = 'REST_BEFORE_IR'
STATE_MEASURE_IR = 'MEASURE_IR'
STATE_SHOW_RESULT = 'SHOW_RESULT'
STATE_ERROR = 'ERROR'
STATE_MACHINE_PAUSE = 'PAUSE'
STATE_MACHINE_RUN = 'RUN'
CURRENT_STATE = STATE_IDLE
MACHINE_STATE = STATE_MACHINE_RUN
STATE_CHRG = True
STATE_STDBY = True
STATE_I2C_LCD = False
SW1_PRESSED = False
SW2_PRESSED = False
BUTTON_PRESSED_STATE = 'BUTTON_PRESSED'
BUTTON_RELEASED_STATE = 'BUTTON_RELEASED'
SW1_BUTTON_STATE = None
SW2_BUTTON_STATE = None
# =========================================================
# Hardware pins
# Inputs have hardware 10KΩ pull-up resistor
# =========================================================
# Outputs
Q_BAT = Pin(25, Pin.OUT) # main battery connection
Q_CHARGE = Pin(32, Pin.OUT) # controls Q202/Q204
Q_DISCHARGE = Pin(14, Pin.OUT) # controls Q302/Q304
i2c = SoftI2C(scl=Pin(22), sda=Pin(21), freq=400_000)
devices = i2c.scan()
I2C_ADDR = 0x27
if I2C_ADDR in devices:
STATE_I2C_LCD = True
lcd = I2cLcd(i2c, I2C_ADDR, 2, 16)
lcd.clear()
lcd.putstr(f'I2C LCD is connected to {hex(I2C_ADDR)}.')
# DAC
try:
DAC_VREF = DAC(26)
except OSError:
reset()
# INPUTS
LED_RED = Pin(2, Pin.OUT)
LED_BLUE = Pin(4, Pin.OUT) # BLUE or GREEN LED
BUZZER = Pin(23, Pin.OUT)
SW1 = Pin(18, Pin.IN)
SW2 = Pin(19, Pin.IN)
# Charger status pins (adjust logic to your actual hardware)
TP4056_CHRG = Pin(35,Pin.IN)
TP4056_STDBY = Pin(34,Pin.IN)
# ADCs
ADC_VBAT = ADC(Pin(33))
ADC_CURRENT = ADC(Pin(13))
# Configure ADC attenuation if needed on your board/firmware version
# adc_vbat.atten(ADC.ATTN_11DB)
# adc_current.atten(ADC.ATTN_11DB)
IR_OHM = 0
# =========================================================
# Global Variables
# =========================================================
VBAT_MEASURED = -1
CHARGE_START_TIME_MS = -1
CHARGE_START_VOLTAGE = -1
CHARGE_TOTAL_TIME = -1
CAPACITY_NOMINAL = 2200
CAPACITY_MEASURED = -1
LOG_RESULTS = True
file_name = 'battery_log.csv'
# =========================================================
# Helper functions
# =========================================================
def debug_print(content):
if STATE_DEBUG:
print(content)
def show_print(content):
print(content)
if STATE_I2C_LCD == True :
lcd.clear()
lcd.putstr(content)
def beep(duration_ms=200):
BUZZER.on()
sleep_ms(duration_ms)
BUZZER.off()
def safe_all_off():
#Safely disable all active circuits and loads.
DAC_VREF.write(0)
Q_BAT.off()
Q_CHARGE.off()
Q_DISCHARGE.off()
LED_RED.value(True)
LED_BLUE.value(True)
def read_adc_voltage(adc, samples=10, delay_ms=20):
#Read averaged voltage from an ADC pin.
total = 0
for _ in range(samples):
total += adc.read_uv()/1000000
sleep_ms(delay_ms)
raw = total / samples
return raw
def read_battery_voltage():
vadc = read_adc_voltage(ADC_VBAT)
return vadc * VBAT_DIVIDER_RATIO * VBAT_CAL
def read_shunt_voltage():
vadc = read_adc_voltage(ADC_CURRENT)
return vadc / CURRENT_SENSE_GAIN * VBAT_CAL
def read_discharge_current():
vshunt = read_shunt_voltage()
return vshunt / SHUNT_RESISTOR_OHM
def charger_done():
# Adjust logic according to your wiring and TP4056 module behavior
chrg = tp4056_chrg.value()
stdby = tp4056_stdby.value()
return (chrg == 1 and stdby == 0)
def set_discharge_current(current_a):
# Placeholder DAC mapping
# You must calibrate this based on your analog load circuit
if current_a <= 0:
DAC_VREF.write(0)
return
# DAC range (0-255) and DAC highest voltage is 3.3V
dac_value = int((current_a / 3.3) * 255)
dac_value = max(0, min(255, dac_value))
DAC_VREF.write(dac_value)
def capacity_grade(capacity_percent):
if capacity_percent >= 90:
return 'Excellent '
elif capacity_percent >= 80:
return 'Good '
elif capacity_percent >= 70:
return 'Fair '
return 'Poor/Replace '
def ir_grade(ir_mohm):
if 20 <= ir_mohm <= 50:
return 'Very good'
elif ir_mohm <= 100:
return 'Usable'
elif ir_mohm <= 200:
return 'Aged'
return 'Poor'
def fail(message):
set_state(STATE_ERROR)
safe_all_off()
print('Test has been canceled, because -> ',message)
# -----------------------------------------------------
# State handlers
# -----------------------------------------------------
def set_machine_state(NEW_STATE):
global MACHINE_STATE
print(MACHINE_STATE, '>', NEW_STATE)
MACHINE_STATE = NEW_STATE
if MACHINE_STATE == STATE_MACHINE_PAUSE:
safe_all_off()
if MACHINE_STATE == STATE_MACHINE_RUN:
if CURRENT_STATE == STATE_CHARGE or CURRENT_STATE == STATE_FINAL_RECHARGE:
safe_all_off()
Q_BAT.on()
Q_CHARGE.on()
def set_state(NEW_STATE):
global CURRENT_STATE
show_print(f'{CURRENT_STATE[:15]:<15}>{NEW_STATE[:16]:<16}')
CURRENT_STATE = NEW_STATE
if NEW_STATE == STATE_ERROR:
beep(1000)
else:
beep()
sleep_ms(1000)
def state_idle():
safe_all_off()
show_print('Ready. Press button to start.')
while CURRENT_STATE == STATE_IDLE:
sleep_ms(200)
def state_check_battery():
global VBAT_MEASURED
VBAT = read_battery_voltage()
with open(file_name, 'w') as f:
f.write('state,time,current,VBAT\n')
f.write(f'{CURRENT_STATE},{ticks_ms()},,{VBAT}')
debug_print('Battery check is activated!')
safe_all_off()
VBAT_MEASURED = read_battery_voltage()
show_print(f'VBAT = {VBAT_MEASURED:4.2f}')
sleep_ms(1500)
if VBAT_MEASURED < VBAT_MIN_ABSENT:
fail('BT absent, reversed, or faulty')
return
if VBAT_MEASURED < VBAT_MIN_DEEP:
fail('BT deeply discharged (<2.0V)')
return
if VBAT_MEASURED > VBAT_MAX_VALID:
fail('BT overvoltage / wrong BT type')
return
set_state(STATE_DECIDE_CHARGE)
def state_decide_charge():
show_print('Checking: chargeor skip to rest.')
sleep_ms(1000)
if VBAT_MEASURED < VBAT_CHARGE_SKIP:
set_state(STATE_CHARGE)
else:
set_state(STATE_REST_BEFORE_DISCHARGE)
def read_charger_state():
c1 = TP4056_CHRG.value()
s1 = TP4056_STDBY.value()
sleep_ms(20)
c2 = TP4056_CHRG.value()
s2 = TP4056_STDBY.value()
if c1 == c2 and s1 == s2:
return c1, s1
return None, None
def state_charge():
global CHARGE_START_TIME_MS, CHARGE_START_VOLTAGE, CHARGE_TOTAL_TIME
last_log_ms = -1 * LOG_INTERVAL_MS
current_log_ms = ticks_ms()
safe_all_off()
sleep_ms(500)
CHARGE_TOTAL_TIME = -1
show_print('Charging ...')
CHARGE_START_VOLTAGE = read_battery_voltage()
CHARGE_START_TIME_MS = ticks_ms()
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{current_log_ms},,{CHARGE_START_VOLTAGE}\n')
# Enable charge procedure
Q_BAT.on()
Q_CHARGE.on()
sleep_ms(500)
while CURRENT_STATE == STATE_CHARGE:
LED_RED.value(TP4056_CHRG.value())
LED_BLUE.value(TP4056_STDBY.value())
CHARGE_DURATION_S = ticks_diff(ticks_ms(),CHARGE_START_TIME_MS) / 1000
VBAT = read_battery_voltage()
STATE_CHRG, STATE_STDBY = read_charger_state()
if VBAT_MEASURED > VBAT_MAX_VALID:
fail('BT overvoltage during charge.')
break
break
if MACHINE_STATE == STATE_MACHINE_RUN:
current_log_ms = ticks_ms()
if ticks_diff(current_log_ms,last_log_ms) > LOG_INTERVAL_MS :
last_log_ms = current_log_ms
show_print(f'{VBAT=:.2f} V')
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{current_log_ms},,{VBAT}\n')
if STATE_CHRG == None and STATE_STDBY == None:
continue
elif STATE_CHRG and STATE_STDBY:
sleep_ms(500)
if STATE_CHRG and STATE_STDBY:
fail('Charge terminate\nTP4056 ERROR.')
break
elif not STATE_CHRG and STATE_STDBY:
if CHARGE_DURATION_S >= CHARGE_MAX_TIMEOUT_S:
fail('BT is not charged after 6 hours!')
break
elif CHARGE_DURATION_S >= CHARGE_MAX_NORISE_TIMEOUT_S:
if VBAT < (CHARGE_START_VOLTAGE + 0.1):
fail('VBAT did no rise BT is defective.')
break
elif STATE_CHRG and not STATE_STDBY:
if VBAT > 4:
CHARGE_TOTAL_TIME = ticks_diff(ticks_ms(), CHARGE_START_TIME_MS)
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},,{VBAT}\n')
f.write(f'{CURRENT_STATE},total_time,{CHARGE_TOTAL_TIME},{VBAT}\n')
set_state(STATE_REST_BEFORE_DISCHARGE)
return
fail('VBAT too low after charge.')
break
else:
fail('Unknown error.')
sleep_ms(500)
def state_rest_before_discharge():
safe_all_off()
rest_state_start_time_ms = ticks_ms()
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},,{read_battery_voltage()}\n')
while CURRENT_STATE == STATE_REST_BEFORE_DISCHARGE:
show_print(f' {ticks_diff(ticks_ms(),rest_state_start_time_ms)/1000:5.0f} seconds passed!')
sleep_ms(1000)
if ticks_diff(ticks_ms(), rest_state_start_time_ms) / 1000 > REST_BEFORE_DISCHARGE_S:
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},,{read_battery_voltage()}\n')
set_state(STATE_DISCHARGE)
break
def state_discharge():
global CAPACITY_MEASURED
last_log_ms = -1 * LOG_INTERVAL_MS
current_log_ms = ticks_ms()
last_print_ms = -1 * PRINT_INTERVAl_MS
safe_all_off()
Q_BAT.on()
Q_DISCHARGE.on()
discharge_start_time_ms = ticks_ms()
discharge_termination_ms = 0
total_current = 0
sample_counter = 0
# Discharge current increses gradually.
for i in range(10):
set_discharge_current(TARGET_DISCHARGE_CURRENT_A * i / 10)
sleep_ms(100)
cutoff_check_counter = 0
while CURRENT_STATE == STATE_DISCHARGE:
sample_counter += 1
LED_RED.value(not LED_RED.value())
LED_BLUE.value(not LED_BLUE.value())
vbat = read_battery_voltage()
discharge_current = read_discharge_current()
total_current += discharge_current
if ticks_diff(ticks_ms(), last_print_ms) > PRINT_INTERVAl_MS:
last_print_ms = ticks_ms()
show_print(f'{vbat=:.2f} V current={discharge_current:.2f} A')
current_log_ms = ticks_ms()
if ticks_diff(current_log_ms,last_log_ms) > LOG_INTERVAL_MS :
last_log_ms = current_log_ms
with open(file_name,'a') as f:
f.write(f'{CURRENT_STATE},{current_log_ms},{discharge_current},{vbat}\n')
if vbat < VBAT_DISCHARGE_CUTOFF :
cutoff_check_counter += 1
else:
cutoff_check_counter = 0
if cutoff_check_counter >= 10 :
discharge_termination_ms = ticks_ms()
set_state(STATE_FINAL_RECHARGE)
sleep_ms(1000)
discharge_total_time_ms = ticks_diff(discharge_termination_ms,discharge_start_time_ms)
CAPACITY_MEASURED = (total_current / sample_counter * discharge_total_time_ms) / 3600
with open(file_name,'a') as f:
f.write(f'Capacity,{CAPACITY_MEASURED:4.0f},discharge time,{discharge_total_time_ms/(1000 * 60):5.0f} Minutes\n')
show_print(f'MeasuredCapacity{CAPACITY_MEASURED:4.0f}')
sleep(3)
def state_final_recharge():
global CHARGE_START_TIME_MS, CHARGE_START_VOLTAGE, CHARGE_TOTAL_TIME
safe_all_off()
show_print('5-second rest to final charging')
sleep(5)
show_print('Final charging ...')
CHARGE_START_VOLTAGE = read_battery_voltage()
CHARGE_START_TIME_MS = ticks_ms()
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},CHARGE_START_VOLTAGE,{CHARGE_START_VOLTAGE}\n')
# Enable charge procedure
Q_BAT.on()
Q_CHARGE.on()
sleep_ms(500)
last_vbat_check_ms = -1 * LOG_INTERVAL_MS
while CURRENT_STATE == STATE_FINAL_RECHARGE:
LED_RED.value(TP4056_CHRG.value())
LED_BLUE.value(TP4056_STDBY.value())
CHARGE_DURATION_S = ticks_diff(ticks_ms(),CHARGE_START_TIME_MS) / 1000
VBAT = read_battery_voltage()
STATE_CHRG, STATE_STDBY = read_charger_state()
if VBAT_MEASURED > VBAT_MAX_VALID:
fail('VBAT overvoltageduring charge.')
break
if MACHINE_STATE == STATE_MACHINE_RUN:
if STATE_CHRG == None and STATE_STDBY == None:
continue
elif STATE_CHRG and STATE_STDBY:
sleep_ms(500)
if STATE_CHRG and STATE_STDBY:
show_print('Charge terminateTP4056 ERROR.')
fail('Charge terminate\nTP4056 ERROR.')
elif not STATE_CHRG and STATE_STDBY:
if ticks_diff(ticks_ms(), last_vbat_check_ms) > LOG_INTERVAL_MS:
safe_all_off()
last_vbat_check_ms = ticks_ms()
sleep(5)
VBAT = read_battery_voltage()
show_print(f'Final Recharge VBAT = {VBAT:.2f} V')
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{last_vbat_check_ms},VBAT,{VBAT}\n')
if VBAT > 3.9:
set_state(STATE_REST_BEFORE_IR)
break
else:
Q_BAT.on()
Q_CHARGE.on()
sleep_ms(1000)
elif CHARGE_DURATION_S >= CHARGE_MAX_TIMEOUT_S:
fail('BT is not charged after 6 hours!')
break
elif CHARGE_DURATION_S >= CHARGE_MAX_NORISE_TIMEOUT_S:
if VBAT < (CHARGE_START_VOLTAGE + 0.1):
fail('VBAT did no rise BT is defective.')
break
else:
fail('Unknown condition.')
sleep_ms(1000)
def state_rest_before_ir():
safe_all_off()
rest_state_start_time_ms = ticks_ms()
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},,{read_battery_voltage()}\n')
while CURRENT_STATE == STATE_REST_BEFORE_IR:
show_print(f' {ticks_diff(ticks_ms(),rest_state_start_time_ms)/1000:5.0f} seconds passed!')
sleep_ms(1000)
if ticks_diff(ticks_ms(), rest_state_start_time_ms) / 1000 > REST_BEFORE_DISCHARGE_S:
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{ticks_ms()},,{read_battery_voltage()}\n')
set_state(STATE_MEASURE_IR)
break
def state_measure_ir():
safe_all_off()
global IR_OHM, LOG_RESULTS
VBAT_before_load = read_battery_voltage()
Q_BAT.on()
set_discharge_current(TARGET_DISCHARGE_CURRENT_A)
sleep_ms(500)
Q_DISCHARGE.on()
sleep_ms(IR_PULSE_MS)
VBAT_after_load = read_battery_voltage()
ir_current = read_discharge_current()
safe_all_off()
if ir_current < 0.05:
fail('IR test current too low')
show_print(f'{VBAT_before_load=},{VBAT_after_load=},{ir_current=}.')
with open(file_name, 'a') as f:
f.write(f'{CURRENT_STATE},{VBAT_before_load},{VBAT_after_load},{IR_OHM}\n')
IR_OHM = (VBAT_before_load - VBAT_after_load) / ir_current
LOG_RESULTS = True
set_state(STATE_SHOW_RESULT)
def state_show_result():
global LOG_RESULTS
cap_g = capacity_grade(CAPACITY_MEASURED / CAPACITY_NOMINAL * 100)
ir_g = ir_grade(IR_OHM * 1000)
if CURRENT_STATE == STATE_SHOW_RESULT:
if(LOG_RESULTS):
LOG_RESULTS = False
with open(file_name, 'a') as f:
f.write(f'Capacity,{CAPACITY_MEASURED},IR,{IR_OHM}\n')
f.write(f'Nominal Capacity,{CAPACITY_NOMINAL},,\n')
f.write(f'Capacity Grade,{cap_g},Internal Resistance Grade,{ir_g}\n')
show_print('Test is finishedWe have results.')
sleep(3)
show_print(f'capacity:{CAPACITY_MEASURED:4.0f}mAhIR: {IR_OHM * 1000:.0f}mΩ.')
sleep(4)
show_print(f'CG:{cap_g[:13]:<13}IRG:{ir_g}.')
sleep(4)
def state_error():
safe_all_off()
# -----------------------------------------------------
# Button handlers
# -----------------------------------------------------
def button_press_handler(pin):
pin.irq(trigger=Pin.IRQ_RISING, handler=button_release_handler)
micropython.schedule(button_press_function,pin)
def button_release_handler(pin):
micropython.schedule(button_release_function,pin)
def button_press_function(pin):
global SW1_BUTTON_STATE, SW2_BUTTON_STATE
if (pin==SW1 and SW1_BUTTON_STATE != BUTTON_PRESSED_STATE):
show_print('SW1 Pressed!')
SW1_BUTTON_STATE = BUTTON_PRESSED_STATE
if(pin==SW2 and SW2_BUTTON_STATE != BUTTON_PRESSED_STATE):
show_print('SW2 Pressed!')
SW2_BUTTON_STATE = BUTTON_PRESSED_STATE
def button_release_function(pin):
global SW1_BUTTON_STATE, SW2_BUTTON_STATE
if(pin.value() == True):
sleep_ms(20)
#My tactile switches are low quality and they have bounces.
if(pin.value() == True):
if(pin == SW1):
SW1_BUTTON_STATE = BUTTON_RELEASED_STATE
if CURRENT_STATE == STATE_IDLE and MACHINE_STATE == STATE_MACHINE_RUN:
set_state(STATE_CHECK_BATTERY)
elif CURRENT_STATE != STATE_CHARGE and CURRENT_STATE != STATE_FINAL_RECHARGE:
show_print(f'In {CURRENT_STATE} only Cancel test.')
elif MACHINE_STATE == STATE_MACHINE_RUN:
set_machine_state(STATE_MACHINE_PAUSE)
show_print('Test has been paused by user.')
elif MACHINE_STATE == STATE_MACHINE_PAUSE:
set_machine_state(STATE_MACHINE_RUN)
show_print('The test has resumed.')
elif(pin == SW2):
SW2_BUTTON_STATE = BUTTON_RELEASED_STATE
if CURRENT_STATE == STATE_ERROR:
set_state(STATE_IDLE)
set_machine_state(STATE_MACHINE_RUN)
if CURRENT_STATE == STATE_IDLE:
show_print('Ready. Press button to start.')
else:
show_print('Test has been canceled by user!')
set_state(STATE_IDLE)
set_machine_state(STATE_MACHINE_RUN)
sleep_ms(20)
pin.irq(trigger=Pin.IRQ_FALLING,handler=button_press_handler)
SW1.irq(trigger=Pin.IRQ_FALLING,handler=button_press_handler)
SW2.irq(trigger=Pin.IRQ_FALLING,handler=button_press_handler)
#At the beginning disconnects all circuits for safety
safe_all_off()
show_print('Machine run!')
sleep(2)
try:
while True:
if(MACHINE_STATE == STATE_MACHINE_RUN):
if CURRENT_STATE == STATE_IDLE:
state_idle()
elif CURRENT_STATE == STATE_CHECK_BATTERY:
state_check_battery()
elif CURRENT_STATE == STATE_DECIDE_CHARGE:
state_decide_charge()
elif CURRENT_STATE == STATE_CHARGE:
state_charge()
elif CURRENT_STATE == STATE_REST_BEFORE_DISCHARGE:
state_rest_before_discharge()
elif CURRENT_STATE == STATE_DISCHARGE:
state_discharge()
elif CURRENT_STATE == STATE_FINAL_RECHARGE:
state_final_recharge()
elif CURRENT_STATE == STATE_REST_BEFORE_IR:
state_rest_before_ir()
elif CURRENT_STATE == STATE_MEASURE_IR:
state_measure_ir()
elif CURRENT_STATE == STATE_SHOW_RESULT:
state_show_result()
elif CURRENT_STATE == STATE_ERROR:
state_error()
else:
fail('Unknown state')
sleep_ms(500)
except KeyboardInterrupt:
safe_all_off()
except Exception as e:
safe_all_off()
print(e)
sys.print_exception(e)