Table of Contents

Intro

If you google LiPo storage charge you will find that LiPos are best stored at a single cell voltage of 3.85V. I built my own LiPo storage charger with a digispark, 128x32 OLED, a button, two step down modules and a couple of LEDs. This article might give you some ideas to replicate my results, and build your own.

The idea is to measure the voltage of the battery, and then discharge or charge it until the voltage is at around 3.85V. As the battery voltage differs during charge/discharge we need to charge/discharg past the target voltage, then wait to let the voltage bounce back (60 seconds), and then repeat this process, until it settles within the programmed threshold.

The thresholds are as follows:

In Storage Charge Mode it will start charging if the voltage is below 3.8V and will stop charging if it goes above 3.95V. To change this behavior change the following defines in the code:

#define START_CHARGE 3.8 // will start charging if less or equal to this
#define STOP_CHARGE 3.95 // will stop charging above this. the voltage will fall back to a lower voltage

In Storage Charge Mode it will start disscharging if the voltage is above 3.89V and will stop discharging if it goes below 3.75V. To change this behavior change the following defines in the code:

#define START_DISCHARGE 3.89 // will start discharging if higher or equal to this
#define STOP_DISCHARGE 3.75 // will stop discharging below this. the voltage will bounce back up, without load

After each charge/discharge cycle it will wait for 60 seconds. To change this behavior change the following defines in the code:

#define START_TIMEOUT 1000*60 // give the battery some time to settle before doing anything

It is important that the charge current and discharge current are as small as possible (<0.25C). I’ve had the best results this way with the current implementation.

⚠️ Never leave this setup unattended.

If you fell like building this project, read this entire article, and make sure you have understood every step.

With the button on the left you can cylce between three modes:
  • idle
  • storage charge (~3.85V)
  • full charge (4.2V)

You can select the charge current with the jumpers on the left and the discharge current with the jumpers on the right, or a custom load attached to the terminal.
These LiPos arrived at 4.10V which is not optimal if you are not going to use them immediately. Note how only one LED is selected as the discharge current (~50mA)

Preparation

As my firmware for the Digispark is pretty big it will not fit into the flash memory with the default configuration. This means we need to prepare the attin85 on the module first.

Sketch uses 6582 bytes (97%) of program storage space. Maximum is 6780 bytes.
Global variables use 227 bytes (44%) of dynamic memory, leaving 285 bytes for local variables. Maximum is 512 bytes.

If you have an older or bigger firmware preinstalled on the attiny85, you will get something like this:

> Device has firmware version 1.6
> Available space for user applications: 6012 bytes
> Suggested sleep time between sending pages: 8ms
> Whole page count: 94  page size: 64
> Erase function sleep duration: 752ms
> Program file is 570 bytes too big for the bootloader!

You will need an Arduino and use it as an ISP programmer. See the following links for details:

I built my self an Arduino ISP programmer with a CROduino3, which is not described here.

Important or you will get stuck

You will have to check you have the latest micronucleus boot loader, the t85_aggressiveversion.

Flash this bootloader FIRST.

Then, flash the firmware, followed by changing the RESET pin to a GPIO. The reset pin is required to be a GPIO for this setup to work, as all 6 pins are used (scl, sda, voltage, button, charge, discharge).

Flash Bootloader

Get the micronucleus firmware from: https://github.com/micronucleus/micronucleus

And flash it: (check the serial port (-P), aswell as the path to the bootloader (-U flash:w:...) match your system (use absolute path)!)

# example, change for your system
avrdude -c arduino -p t85 -b 115200 -P /dev/cu.usbserial-1430 -U flash:w:/Users/paul/Downloads/micronucleus-2.04/firmware/releases/t85_default.hex  -U lfuse:w:0xe1:m -U efuse:w:0xfe:m -U hfuse:w:0xdd:m

Result should be something like this:

avrdude: AVR device initialized and ready to accept instructions

Reading | ################################################## | 100% 0.00s
[...]
Writing | ################################################## | 100% 0.00s

avrdude: 8138 bytes of flash written
avrdude: verifying flash memory against /Users/paul/Downloads/micronucleus-2.04/firmware/releases/t85_aggressive.hex:
avrdude: load data flash data from input file /Users/paul/Downloads/micronucleus-2.04/firmware/releases/t85_aggressive.hex:
avrdude: input file /Users/paul/Downloads/micronucleus-2.04/firmware/releases/t85_aggressive.hex auto detected as Intel Hex
avrdude: input file /Users/paul/Downloads/micronucleus-2.04/firmware/releases/t85_aggressive.hex contains 8138 bytes
avrdude: reading on-chip flash data:

Reading | ################################################## | 100% 0.00s
[...]
Writing | ################################################## | 100% 0.00s
[...]
Reading | ################################################## | 100% 0.00s
[...]
Writing | ################################################## | 100% 0.00s
[...]
Reading | ################################################## | 100% 0.00s
[...]
Writing | ################################################## | 100% 0.00s
[...]
Reading | ################################################## | 100% 0.00s

avrdude: verifying ...
avrdude: 1 bytes of hfuse verified

avrdude: safemode: Fuses OK (E:FE, H:DD, L:E1)

avrdude done.  Thank you.

Flash the Firmware

Use the Arduino IDE. I used the following URL in the board manager: https://raw.githubusercontent.com/ArminJo/DigistumpArduino/master/package_digistump_index.json

Comma , separated if you already have an URL there. Restart Arduino and install “Digistump AVR Borads”.

Select the board as follows:

Select the correct board.
Select the aggressive boot loader for maximum space.

Then, upload the code:

Running Digispark Uploader...
Plug in device now... (will timeout in 60 seconds)
> Please plug in the device ... 
> Press CTRL+C to terminate the program.
> Device is found!
connecting: 16% complete
[...]
> Starting the user app ...
running: 100% complete
>> Micronucleus done. Thank you!

Change RESET Pin to GPIO

Before you do the following step, make sure you have flashed the t85_aggressive bootloader and a working firmware.

To convert the reset pin to a GPIO set the respective fuse bits with this command:

avrdude -P /dev/cu.usbserial-1430 -b 115200 -p attiny85 -c arduino  -U hfuse:w:0x5F:m

The Schematics + PCB

Schematics

KiCad Sources and Gerbers: https://p3dt.net/pcb/2020/11/29/digispark-lipo-storage.html If you want to order these PCBS without any guarantee of working correctly: https://aisler.net/p/IIEZRPAG.

Front

Back

The 3D Printed Case

See:

The Code

There are some lines of code I’m not proud of. Especially the voltage measuring. I noticed I get different results on different USB power supplies (the input voltage changes) so I had to use some trial and error and compared the results with a multimeter:

  // Shady stuff:
  // Experiments: Measured voltage:
  // multimeter/analogRead = factor
  // 4,09/870 = 0,004701149425
  // 3.14/668 = 0,004700598802

  // smoothen the voltage change with a low pass filter
  voltage =  voltage * 0.9 + 0.1 * analogRead(VBAT_ANALOG) * 0.0047;
  // also apply correction: measured:  3.99 (attiny) vs 4.15 (multimeter)
  float correctedVoltage = voltage * (3.99 / 4.15);

Everything else should work as is. Below is the full sketch. No explanations.


#include "ssd1306.h" // https://github.com/lexus2k/ssd1306

// Sketch uses 6582 bytes (97%) of program storage space. Maximum is 6780 bytes. (t85_aggressive bootloader)

// 0 is SDA
#define EN_CHARGE 1
// 2 is SCL
#define PIN_FULL_CHARGE 3
#define EN_LOAD 4
#define VBAT 5
// analog pin numbers differ: https://digistump.com/wiki/digispark/tutorials/basics
#define VBAT_ANALOG 0 // pin 5 is analog pin 0

#define START_CHARGE 3.8 // will start charging if less or equal to this
#define STOP_CHARGE 3.95 // will stop charging above this. the voltage will fall back to a lower voltage

#define START_DISCHARGE 3.89 // will start discharging if higher or equal to this
#define STOP_DISCHARGE 3.75 // will stop discharging below this. the voltage will bounce back up, without load

#define START_TIMEOUT 1000*60 // give the battery some time to settle before doing anything
#define BTN_TIMEOUT 1000*4
#define BTN_LOW_THRESHOLD 0.14

boolean isCharging = false;
boolean isDisCharging = false;
long since = 0;
const char* digits[] = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"};
float voltage = 0.0;
#define MODE_IDLE 0
#define MODE_STORAGE 1
#define MODE_FULL_CHARGE 2
uint8_t mode = MODE_IDLE;

void setup() {
  pinMode(VBAT, INPUT);
  pinMode(PIN_FULL_CHARGE, INPUT);

  pinMode(EN_LOAD, OUTPUT);
  pinMode(EN_CHARGE, OUTPUT);

  digitalWrite(EN_LOAD, 0);
  digitalWrite(EN_CHARGE, 0);

  ssd1306_128x32_i2c_init();

  ssd1306_fillScreen(0x00);
  since = millis();
  voltage = analogRead(VBAT_ANALOG) * 0.0047;
}

void loop() {

  // check if we need to toggle charge mode
  // PIN_FULL_CHARGE (pin 3) is connected to a USB line and wiggles around 3V
  // analogRead with manual comparison to 0.2V solves this. also uses less flash memory
  // because we don't use an additional function (digialRead)
  if (analogRead(PIN_FULL_CHARGE) * 0.0047 < BTN_LOW_THRESHOLD && (millis() - since) > BTN_TIMEOUT) {
    mode++;
    mode = mode % 3;
    since = millis();
  }

  // Voltage estimation
  // multimeter vs analogRead = factor
  // 4,09/870 = 0,004701149425
  // 3.14/668 = 0,004700598802

  // smoothen the voltage change with a low pass filter
  voltage =  voltage * 0.9 + 0.1 * analogRead(VBAT_ANALOG) * 0.0047;
  
  // Step 1: to correct your voltage on a given powersupply first set
  // float correctedVoltage = voltage * 1.0; 
  // then measure with a multimeter and compare
  // with the voltage from the lipo meter
  
  // Step 2: adjust it by the measured factor
  float correctedVoltage = voltage * ( 3.50 / 3.64);

  // convert float into a string.... :/ (with not much memory)
  uint8_t d0 = (uint8_t)(correctedVoltage);
  uint8_t d1 = (uint8_t)((int)(correctedVoltage * 10) % 10);
  uint8_t d2 = (uint8_t)((int)(correctedVoltage * 100) % 10);

  ssd1306_setFixedFont(ssd1306xled_font8x16);
  ssd1306_printFixed(0, 0, digits[d0], STYLE_BOLD);
  ssd1306_printFixed(8, 0, ",", STYLE_BOLD);
  ssd1306_printFixed(16, 0, digits[d1], STYLE_BOLD);
  ssd1306_printFixed(24, 0, digits[d2], STYLE_BOLD);
  ssd1306_printFixed(32, 0, "V", STYLE_BOLD);

  bool isWaiting =  (millis() - since) < (long)START_TIMEOUT;

  switch (mode) {
    case MODE_IDLE:
      isCharging = false;
      isDisCharging = false;
      break;
    case MODE_STORAGE:
      // if we are not charging or not discharging...
      if (!isCharging && !isDisCharging && !isWaiting) {
        // ...figure out what to do
        if (correctedVoltage <= START_CHARGE) {
          isCharging = true;              // ENABLE charge
          since = millis();
        } else if (correctedVoltage >= START_DISCHARGE) {
          isDisCharging = true;           // ENABLE discharge
          since = millis();
        }
      } else if (isCharging) {
        // ... stop charging at some point
        if (correctedVoltage > STOP_CHARGE) {
          isCharging = false;             // DISABLE charge
          since = millis();
        }
      } else if (isDisCharging) {
        // ... stop discharging at some point
        if (correctedVoltage < STOP_DISCHARGE) {
          isDisCharging = false;          // DISABLE discharge
          since = millis();
        }
      }
      break;
    case MODE_FULL_CHARGE:
      isCharging = true;
      isDisCharging = false;
      break;
  }


  if (isCharging) {
    digitalWrite(EN_LOAD, LOW);
    digitalWrite(EN_CHARGE, HIGH);
    ssd1306_printFixed(0, 16, mode == MODE_STORAGE ? "Storage chg" : "Full charge ", STYLE_BOLD);
  }

  if (isDisCharging) {
    digitalWrite(EN_CHARGE, LOW);
    digitalWrite(EN_LOAD, HIGH);
    ssd1306_printFixed(0, 16, "Discharging ", STYLE_BOLD);
  }

  if (!isCharging && !isDisCharging) {
    digitalWrite(EN_CHARGE, LOW);
    digitalWrite(EN_LOAD, LOW);
    ssd1306_printFixed(0, 16, mode == MODE_IDLE
                       ? "Idle       "
                       : isWaiting
                       ? "Waiting    "
                       : "Storage OK ", STYLE_BOLD);
  }

  // print the elapsed time, since last state change:
  long elapsed = (millis() - since) / 1000;
  uint8_t s = elapsed % 60;
  uint8_t m = (elapsed / 60) % 60;
  uint8_t h = (elapsed / 3600);

  ssd1306_printFixed(64, 0, digits[(h / 10) % 10], STYLE_BOLD);
  ssd1306_printFixed(72, 0, digits[h % 10], STYLE_BOLD);
  ssd1306_printFixed(80, 0, ":", STYLE_BOLD);
  ssd1306_printFixed(88, 0, digits[(m / 10) % 10], STYLE_BOLD);
  ssd1306_printFixed(96, 0, digits[m % 10], STYLE_BOLD);
  ssd1306_printFixed(104, 0, ":", STYLE_BOLD);
  ssd1306_printFixed(112, 0, digits[(s / 10) % 10], STYLE_BOLD);
  ssd1306_printFixed(120, 0, digits[s % 10], STYLE_BOLD);

}