Built-in project · Arduino Uno R3

Battery and alternator monitor on an Arduino Uno

A car’s 12 V system through a protected divider on A0: rested charge, the lowest voltage of each start, and whether the alternator is charging, on a 16x2 I2C LCD with lights and a buzzer. The whole build, an Arduino Uno and 7 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Intermediate About 30 minutes to build and run. Runs in your browser. Free, and no account needed.

The parts retail for about $35. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Sensors
  • Displays
  • Analog
  • School
  • Making sound with a buzzer
  • Difficulty: Intermediate
  • Time: About 30 minutes
  • Board: Arduino Uno R3
  • Parts: 8

How it works

A car’s 12 V system through a protected divider on A0: rested charge, the lowest voltage of each start, and whether the alternator is charging, on a 16x2 I2C LCD with lights and a buzzer.

Arduino Uno R3 runs the sketch below. The circuit around it: Traffic light module, Buzzer, Bench power supply, Character LCD with I2C backpack, Resistor, 33k Ω, Resistor, 10k Ω. pinMode sets GREEN_PIN as an output. analogRead measures the voltage on SENSE_PIN as a number. delay waits 20 milliseconds. Serial.begin opens the serial port at 9600 baud. millis reads the time since power-up, so the sketch can time things without stopping.

Parts list

8 parts, plus the jumper wires and a bench instrument. The parts retail for about $35; on Mokxi they're free, and so are the instruments.

Wiring table

Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.

Part and pinConnects to
Traffic light module pin GNDArduino Uno R3 pin GND
Buzzer pin 2Arduino Uno R3 pin GND
Buzzer pin 1Arduino Uno R3 pin 8
Traffic light module pin RArduino Uno R3 pin 4
Traffic light module pin YArduino Uno R3 pin 3
Traffic light module pin GArduino Uno R3 pin 2
Character LCD with I2C backpack pin VCCArduino Uno R3 pin 5V
Bench power supply pin V-Arduino Uno R3 pin GND
Character LCD with I2C backpack pin GNDArduino Uno R3 pin GND
Resistor, 10k Ω pin 2Arduino Uno R3 pin GND
Zener diode pin AArduino Uno R3 pin GND
Resistor, 33k Ω pin 2Arduino Uno R3 pin A0
Resistor, 10k Ω pin 1Arduino Uno R3 pin A0
Zener diode pin CArduino Uno R3 pin A0
Character LCD with I2C backpack pin SDAArduino Uno R3 pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 pin A5
Resistor, 33k Ω pin 1Bench power supply pin V+

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Battery and alternator monitor: a car's 12 V system on A0 through a
// resistor divider, with a 16x2 I2C LCD, a traffic light module and a buzzer
// telling you what the voltage means.
//
// SAFETY. This is a teaching simulation. Never connect a project like this to
// a running car's safety systems (brakes, airbags, steering, ECU wiring, or
// anything that can strand or hurt someone). Try it on a bench supply or a
// 12 V battery on the bench, with a fuse in the plus lead. A real vehicle
// install needs a proper automotive front end: an inline fuse right at the
// battery, a TVS diode for load dump, and the clamp below.
//
//   A0      the divider's middle: 33 k from the 12 V plus, 10 k to ground,
//           and a 5.1 V Zener (1N4733A) from A0 to ground, band to A0
//   2, 3, 4 the traffic light module's G, Y and R
//   8       the active buzzer's plus leg, its other leg on GND
//   LCD     SDA -> A4    SCL -> A5    VCC -> 5 V    GND -> GND
//   The 12 V minus goes to the Uno's GND: one ground for both.
//
// THE DIVIDER. A0 reads 0 to 5 V, and a car's system runs 12 to 15 V. 10 k out
// of 33 k + 10 k is 0.2326 of the input, so 14.4 V reads as 3.35 V and the
// full scale, 5 V at A0, is 21.5 V at the battery. That leaves room above the
// 15 V an overcharging alternator makes, which is the point: a divider sized
// for exactly 15 V would clip the very fault you want to see.
//
// WHY THE 33 k AND THE ZENER. A car's 12 V line is not clean. If the battery
// lead comes off while the alternator is charging hard, the "load dump" can
// spike to tens of volts for a few hundred milliseconds, and a jump start
// done backwards puts -12 V on everything. With a plain divider, 60 V in
// would put 14 V on A0 and kill the pin. Here the Zener catches A0 at about
// 5 V, and the 33 k is the series resistor that limits the current it has
// to swallow: (60 - 5) / 33 k is under 2 mA. Backwards, the Zener conducts
// forwards and holds A0 near -0.7 V, with 12 V / 33 k = 0.36 mA through it.
// The 43 k total also takes only 0.3 mA from the battery, which a parked car
// does not notice. Thevenin resistance at A0 is 33 k parallel 10 k = 7.7 k,
// under the 10 k the ATmega328P's ADC wants for an accurate reading.
//
// ADC RESOLUTION AND CALIBRATION. 1023 counts over 5 V is 4.9 mV at A0, which
// is 21 mV at the battery: enough to tell 12.4 from 12.6. Real resistors are
// 1 % or 5 % parts and a real Uno's 5 V is rarely exactly 5.00 V, so the
// reading can be off by a tenth of a volt or more. To calibrate: measure the
// battery with a good multimeter, read the same moment off the serial
// monitor, and set CAL to meter / reading. The simulated parts are exact, so
// CAL is 1.000 here.
//
// WHAT THE NUMBERS MEAN (a flooded or AGM lead-acid battery, about 25 C):
//   engine off, rested a few hours:
//     12.6 V or more  about 100 %       12.4 V  about 75 %
//     12.2 V  about 50 %                12.0 V  about 25 %
//     below 11.8 V    discharged: charge it before it sulfates
//   cranking: a healthy battery dips to about 9.5 to 10.5 V while the
//     starter turns. The lowest voltage during a start is recorded; below
//     9.6 V the battery is weak (or cold, or the cables are poor).
//   engine running:
//     13.8 to 14.7 V  the alternator is charging, good
//     13.2 to 13.8 V  charging low     14.7 to 15.0 V  charging high
//     below 13.2 V    not charging: the battery is carrying the car
//     above 15.0 V    overvoltage: a failed regulator, it boils batteries
// The percent table is a simplification: a battery that has just been
// charged or driven reads high for hours (surface charge).
//
// ENGINE ON OR OFF, FROM THE VOLTAGE ALONE. A dip below 11.0 V that comes
// back within a few seconds is a start, so the engine is running afterwards.
// So is anything at 13.4 V or more: only a charger or an alternator makes
// that. A stopped engine and a dead alternator look the same on the wire, so
// after a minute below 13.2 V the monitor decides the engine was switched off
// and goes back to reading the battery. A real install would also read the
// ignition line through a second divider and know for sure.
//
// HYSTERESIS. Every threshold is entered at its number and left only 0.1 V
// past it, so a voltage sitting right on a line does not flicker the screen
// or chatter the buzzer.
//
// Press Run, then step the bench supply's voltage: 12.6, 12.0, a quick dip to
// 10.0 (a start), 14.2, 12.8, 15.4.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

const int SENSE_PIN = A0;
const int GREEN_PIN = 2;
const int YELLOW_PIN = 3;
const int RED_PIN = 4;
const int BUZZER_PIN = 8;

// The divider: (R1 + R2) / R2 turns the volts at A0 back into battery volts.
const float R1 = 33000.0;
const float R2 = 10000.0;
const float CAL = 1.000;  // meter reading / monitor reading, see above

const float HYST = 0.10;            // volts past a line before leaving it
const float CRANK_START = 11.0;     // below this, from rest, a start begins
const float CRANK_END = 11.5;       // back above this, the start is over
const float CRANK_WEAK = 9.6;       // a start that dips below this: weak battery
const float RUN_DETECT = 13.4;      // only a charging system makes this much
const unsigned long CRANK_MAX_MS = 8000;  // longer than this is not a start
const unsigned long OFF_AFTER_MS = 60000; // under 13.2 V this long: engine off

// Engine off: the bands from discharged to healthy.
const float OFF_EDGES[] = {11.8, 12.4};
const char *const OFF_TEXT[] = {"DISCHARGED!", "LOW", "HEALTHY"};
// Engine running: not charging, low, OK, high, overvoltage.
const float RUN_EDGES[] = {13.2, 13.8, 14.7, 15.0};
const char *const RUN_TEXT[] = {"NOT CHARGING!", "CHARGE LOW", "CHARGING OK", "CHARGE HIGH", "OVERVOLTAGE!"};

enum Mode { OFF, CRANKING, RUNNING };

LiquidCrystal_I2C lcd(0x27, 16, 2);
Mode mode = OFF;
int band = -1;                 // the current band in this mode, -1 for none yet
float crankMin = 99.0;         // the lowest voltage of the start in progress
float lastCrank = 0.0;         // the lowest voltage of the last start, 0 for none
unsigned long crankAt = 0;     // when the start began
unsigned long lowSince = 0;    // when the running voltage fell under 13.2 V
unsigned long lastReport = 0;
bool beep = false;

// One reading: eight conversions averaged, scaled up by the divider.
float readVolts() {
  long sum = 0;
  for (int i = 0; i < 8; i++) sum += analogRead(SENSE_PIN);
  float atPin = sum / 8.0 * 5.0 / 1023.0;
  return atPin * (R1 + R2) / R2 * CAL;
}

// The band a voltage is in, with hysteresis: a band is entered at its lower
// edge, and left downwards only HYST below that edge.
int bandOf(float v, const float *edges, int n, int current) {
  int raw = 0;
  while (raw < n && v >= edges[raw]) raw++;
  if (current < 0 || raw > current) return raw;
  int b = current;
  while (b > raw && v < edges[b - 1] - HYST) b--;
  return b;
}

// Lead-acid state of charge, rested: 11.8 V is 0 %, 12.6 V is 100 %, in a
// straight line between (25 % for every 0.2 V).
int percentOf(float v) {
  int p = (int)((v - 11.8) / 0.8 * 100.0 + 0.5);
  return constrain(p, 0, 100);
}

void lights(bool green, bool yellow, bool red) {
  digitalWrite(GREEN_PIN, green ? HIGH : LOW);
  digitalWrite(YELLOW_PIN, yellow ? HIGH : LOW);
  digitalWrite(RED_PIN, red ? HIGH : LOW);
}

void setup() {
  pinMode(GREEN_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  Serial.begin(9600);
  Serial.println("Battery and alternator monitor");
  lcd.init();
  lcd.backlight();
  lcd.print("12 V monitor");
}

// Watch every reading for the start of a crank, its lowest point and its end.
void track(float v, unsigned long now) {
  if (mode != CRANKING) {
    if (mode == OFF && v < CRANK_START && band >= 1) {
      // A drop from a resting battery: the starter is turning.
      mode = CRANKING;
      crankMin = v;
      crankAt = now;
    }
    return;
  }
  if (v < crankMin) crankMin = v;
  if (v >= CRANK_END) {
    // The starter has let go: the engine caught.
    lastCrank = crankMin;
    Serial.print("Start: lowest ");
    Serial.print(crankMin, 2);
    Serial.println(crankMin < CRANK_WEAK ? " V, WEAK BATTERY" : " V, healthy");
    mode = RUNNING;
    band = -1;
    lowSince = now;
  } else if (now - crankAt > CRANK_MAX_MS) {
    // Too long to be a start: a flat battery, not a crank.
    mode = OFF;
    band = -1;
  }
}

void report(float v, unsigned long now) {
  int percent = -1;
  const char *text;
  if (mode == OFF && v >= RUN_DETECT) {
    mode = RUNNING;  // a charger or an alternator: the engine is running
    band = -1;
  }
  if (mode == RUNNING) {
    band = bandOf(v, RUN_EDGES, 4, band);
    if (band > 0) lowSince = now;
    if (band == 0 && now - lowSince > OFF_AFTER_MS) {
      mode = OFF;  // a minute without charging: the key is off
      band = -1;
    }
  }
  if (mode == OFF) {
    band = bandOf(v, OFF_EDGES, 2, band);
    text = OFF_TEXT[band];
    if (band > 0) percent = percentOf(v);
    lights(band == 2, band == 1, band == 0);
  } else if (mode == RUNNING) {
    text = RUN_TEXT[band];
    lights(band == 2, band == 1 || band == 3, band == 0 || band == 4);
  } else {
    text = "CRANKING";
    lights(false, true, false);
  }
  bool alarm = (mode == OFF && band == 0) || (mode == RUNNING && (band == 0 || band == 4));
  beep = alarm && !beep;
  digitalWrite(BUZZER_PIN, beep ? HIGH : LOW);

  // Top line: volts, the mode, and the lowest voltage of the last start.
  lcd.setCursor(0, 0);
  lcd.print(v, 2);
  lcd.print(mode == OFF ? "V OFF " : mode == RUNNING ? "V RUN " : "V CRK ");
  if (lastCrank > 0) {
    lcd.print("c");
    lcd.print(lastCrank, 1);
  }
  lcd.print("     ");
  lcd.setCursor(0, 1);
  lcd.print(text);
  if (percent >= 0) {
    lcd.print(" ");
    lcd.print(percent);
    lcd.print("%");
  }
  lcd.print("        ");

  Serial.print(v, 2);
  Serial.print(" V  ");
  Serial.print(mode == OFF ? "engine off" : mode == RUNNING ? "running" : "cranking");
  Serial.print("  ");
  Serial.print(text);
  if (percent >= 0) {
    Serial.print(" ");
    Serial.print(percent);
    Serial.print("%");
  }
  Serial.println();
}

void loop() {
  // Sample every 20 ms so a start's lowest point is not missed, and report
  // twice a second.
  unsigned long now = millis();
  float v = readVolts();
  track(v, now);
  if (now - lastReport >= 500) {
    lastReport = now;
    report(v, now);
  }
  delay(20);
}

Code walkthrough

The key functions of the sketch, and what each one does.

setup sketch.ino

Runs once when the board starts. It gets the pins and libraries ready.

void setup() {
  pinMode(GREEN_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(RED_PIN, OUTPUT);
  pinMode(BUZZER_PIN, OUTPUT);
  Serial.begin(9600);
  Serial.println("Battery and alternator monitor");
  lcd.init();
  lcd.backlight();
  lcd.print("12 V monitor");
}

loop sketch.ino

Runs over and over for as long as the board has power. This is where the project does its work.

void loop() {
  // Sample every 20 ms so a start's lowest point is not missed, and report
  // twice a second.
  unsigned long now = millis();
  float v = readVolts();
  track(v, now);
  if (now - lastReport >= 500) {
    lastReport = now;
    report(v, now);
  }
  delay(20);
}

Troubleshooting

The usual mistakes with this circuit, and the fix for each.

  • One color never lights, or all three light together. Traffic light module
    Check each wire goes to its own pin labeled R, Y, G, and that GND is connected. A pin driven high lights its LED. Resistors are already on the board, so do not add more.
  • Lights seem dim or the wrong color comes on in a sequence. Traffic light module
    Confirm the pins in your code match the wires, and that the module gets the right supply voltage. Keep each state's delay long enough to see, and turn the old color off before the next.
  • The buzzer makes a constant tone, or you cannot change its pitch. Buzzer
    An active buzzer has its own oscillator, so it only beeps on and off. A passive buzzer needs tone() to set pitch. Check which type you have.
  • The buzzer is silent or very quiet. Buzzer
    Check polarity: the plus mark goes to the pin or supply. For loud buzzers, drive them through a transistor instead of straight from a pin.
  • The output voltage looks wrong or collapses under load because the current limit is set too low and the supply is in constant current mode (CC). Bench power supply
    Set the voltage first, then set the current limit above what the circuit needs. Watch the CV and CC lamps. CV means the voltage is held, CC means the limit is working.
  • Too much voltage burns parts that expect 3.3 V or 5 V. Bench power supply
    Set the voltage with the output off and nothing connected, check it, then connect. Start the current limit low for a new circuit, like 100 mA, and raise it if needed.
  • The screen stays blank or shows only a row of blocks. Character LCD with I2C backpack
    The I2C address is usually 0x27 or 0x3F. Run an I2C scanner to find yours. Then turn the contrast trimmer on the backpack slowly until text appears.
  • The backlight is on but there is no text, or text is garbled. Character LCD with I2C backpack
    Wire SDA and SCL to the right pins (A4 and A5 on an Uno), power it from 5 V, and call lcd.init() and the backlight call in setup.

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.