Fuel level gauge on an Arduino Uno
A Ford-style 73 to 10 ohm fuel sender read as a voltage divider, turned into percent and gallons through a lookup table, damped against slosh, with a low-fuel light at 1/8 tank and a fault for a broken or shorted sender wire. 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.
What you'll learn
- Sensors
- Displays
- Analog
- Choosing and reading a resistor
- How an LED lights and why it needs a resistor
- Difficulty: Intermediate
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 9
How it works
A Ford-style 73 to 10 ohm fuel sender read as a voltage divider, turned into percent and gallons through a lookup table, damped against slosh, with a low-fuel light at 1/8 tank and a fault for a broken or shorted sender wire.
Arduino Uno R3 runs the sketch below. The circuit around it: Resistor, 220 Ω, LED, amber, Slide switch, Potentiometer, Character LCD with I2C backpack. pinMode sets LOW_LIGHT_PIN as an output. analogRead measures the voltage on SENDER_PIN as a number. 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
9 parts, plus the jumper wires. They retail for about $35; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 2 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, amberIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 2 × Slide switchIn Logic Lab Kit, Spaceship Panel Kit.
- 1 × PotentiometerIn Halloween Prop Kit, Starter Projects Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
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 pin | Connects to |
|---|---|
| Resistor, 220 Ω (1) pin 1 | Arduino Uno R3 pin 5 |
| Resistor, 220 Ω (2) pin 1 | Arduino Uno R3 pin 5V |
| Character LCD with I2C backpack pin VCC | Arduino Uno R3 pin 5V |
| LED, amber pin C | Arduino Uno R3 pin GND |
| Slide switch (2) pin 3 | Arduino Uno R3 pin GND |
| Potentiometer pin 3 | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Resistor, 220 Ω (2) pin 2 | Arduino Uno R3 pin A0 |
| Slide switch (1) pin 1 | Arduino Uno R3 pin A0 |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| LED, amber pin A | Resistor, 220 Ω (1) pin 2 |
| Slide switch (2) pin 2 | Slide switch (1) pin 2 |
| Potentiometer pin 2 | Slide switch (1) pin 2 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Fuel level gauge: a resistive fuel sender read as a voltage divider on A0,
// the level in percent and gallons on a 16x2 I2C LCD with a bar, a low-fuel
// light at 1/8 tank, and a fault shown for a broken or shorted sender wire.
//
// 5 V -- 220 ohm --+-- A0
// |
// +-- sender wire (through the "wire broken" switch)
// | -- sender (float arm on a rheostat) -- GND
// +-- "chafed to ground" switch -- GND
//
// pin 5 the low-fuel light, through 220 ohms
// LCD SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND
//
// SAFETY: this is a teaching simulation. Never connect a project like this to
// a running car's safety systems (brakes, airbags, steering, ECU wiring,
// anything that can strand or hurt someone). On the bench, use a bench supply
// or a 12 V battery with a fuse. A real install needs an automotive-rated
// front end: a fuse, a regulator that survives load dump, TVS clamps, and a
// divider or level shift for anything at 12 V. A fuel sender sits in the
// tank: never work on one with power on or near open fuel or vapor.
//
// THE SENDER. A float on an arm wipes a contact along a resistance wire, so
// the sender is a resistance that changes with the level. Families differ:
// GM (older) about 0 ohm empty to 90 ohm full
// Ford (1987 on) about 73 ohm empty to 10 ohm full (empty is HIGH)
// VDO (European) about 3 ohm to 180 ohm (check which end is empty)
// This demo uses the Ford 73 to 10 ohm range. Its big advantage for a gauge:
// a dead short (0 ohm) and a broken wire (open, infinite ohms) both lie
// outside the working range, so both can be told apart from a real level.
// (On a GM 0 to 90 sender, a short to ground looks exactly like "empty".)
//
// THE DIVIDER. A0 sits between a known 220 ohm resistor up to 5 V and the
// sender down to ground, so
// V = 5 * Rs / (220 + Rs) and solving for the sender:
// Rs = 220 * V / (5 - V) = 220 * count / (1023 - count)
// 73 ohm reads about 1.25 V (count 255), 10 ohm about 0.22 V (count 44).
// With the sender shorted the current is 5 V / 220 ohm = 23 mA, and the
// resistor takes 0.11 W, so an ordinary 1/4 W resistor is fine.
//
// THE TABLE. The sender's resistance follows the arm's angle, but the fuel in
// a real tank does not: tanks have sloped floors, kick-ups and saddles. So
// ohms become percent through a small lookup table, with a straight line
// (interpolation) between its points. The table here is a demo calibration;
// for a real car, measure the sender's ohms at known fill levels and use
// your own numbers.
//
// THE FILTER. Fuel sloshes in every corner and over every bump, so the raw
// reading jumps. The sketch samples ten times a second and moves the shown
// level only 1/16 of the way to each new sample (an exponential moving
// average), which takes about 1.6 s to cover two thirds of a change and
// about 7 s to settle. Factory gauges damp far more (tens of seconds);
// this is quicker so the demo is easy to watch. At power on, and when a
// fault clears, the filter starts at the first good reading instead of
// crawling up from zero.
//
// LOW FUEL. The light comes on at 1/8 tank (2.0 of 16 gallons) and goes off
// only above 3 gallons, so fuel sloshing around the line does not flicker it.
//
// FAULTS. Over 250 ohm is an open circuit (broken wire, bad ground at the
// sender, corroded connector) and under 3 ohm is a short to ground (a chafed
// wire). Either one, held for half a second, shows a fault and blinks the
// light instead of a bogus level: a gauge that reads full on a broken wire
// can leave you stranded.
//
// In the simulator the sender is a 100 ohm pot wired as a rheostat: the knob
// is the float arm. A real arm stops at 73 and 10 ohm; the knob goes on to
// 100 ohm (past empty) and to nearly 0 ohm, which reads as a short.
//
// Press Run, then turn the sender's knob (the float arm). Flip the switches to
// break the wire or short it to ground.
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
const int SENDER_PIN = A0;
const int LOW_LIGHT_PIN = 5;
const float KNOWN_OHMS = 220.0; // the divider's top resistor
const float TANK_GALLONS = 16.0; // a demo tank
// Faults: outside anything a Ford 73 to 10 ohm sender can read.
const float OPEN_OHMS = 250.0;
const float SHORT_OHMS = 3.0;
const int FAULT_SAMPLES = 5; // 5 samples, 0.5 s, before a fault shows
// Low fuel with hysteresis: on at 1/8 tank, off above 3 gallons.
const float LOW_ON_PERCENT = 12.5;
const float LOW_OFF_PERCENT = 18.75;
// The calibration table, ohms falling from empty to full. Between two points
// the level is a straight line.
const int TABLE_SIZE = 7;
const float TABLE_OHMS[TABLE_SIZE] = { 73, 66, 57, 41, 26, 17, 10 };
const float TABLE_PERCENT[TABLE_SIZE] = { 0, 10, 25, 50, 75, 90, 100 };
const unsigned long SAMPLE_MS = 100;
const unsigned long SHOW_MS = 500;
const float FILTER = 1.0 / 16.0;
enum Fault { NONE, OPEN, SHORTED };
LiquidCrystal_I2C lcd(0x27, 16, 2);
float level = 0; // filtered level, percent
bool seeded = false; // has the filter a starting value yet?
bool lowFuel = false;
Fault fault = NONE;
int badCount = 0;
int goodCount = 0;
Fault badKind = NONE;
float lastOhms = 0;
bool blink = false;
unsigned long lastSample = 0;
unsigned long lastShow = 0;
// Bar characters: slot n has the n left-hand columns of a cell lit.
void makeBarChars() {
for (uint8_t n = 1; n <= 5; n++) {
uint8_t row = 0;
for (uint8_t c = 0; c < n; c++) row |= 0x10 >> c;
uint8_t rows[8];
for (int r = 0; r < 8; r++) rows[r] = (r == 0 || r == 7) ? 0 : row;
lcd.createChar(n, rows);
}
}
// Sender ohms from the divider. Returns a huge number for an open circuit.
float readSenderOhms() {
long sum = 0;
for (int i = 0; i < 8; i++) sum += analogRead(SENDER_PIN);
float count = sum / 8.0;
if (count >= 1022.5) return 1.0e6;
return KNOWN_OHMS * count / (1023.0 - count);
}
// Ohms to percent through the table, clamped at empty and full.
float ohmsToPercent(float ohms) {
if (ohms >= TABLE_OHMS[0]) return TABLE_PERCENT[0];
if (ohms <= TABLE_OHMS[TABLE_SIZE - 1]) return TABLE_PERCENT[TABLE_SIZE - 1];
for (int i = 1; i < TABLE_SIZE; i++) {
if (ohms >= TABLE_OHMS[i]) {
float t = (TABLE_OHMS[i - 1] - ohms) / (TABLE_OHMS[i - 1] - TABLE_OHMS[i]);
return TABLE_PERCENT[i - 1] + t * (TABLE_PERCENT[i] - TABLE_PERCENT[i - 1]);
}
}
return TABLE_PERCENT[TABLE_SIZE - 1];
}
void sample() {
float ohms = readSenderOhms();
lastOhms = ohms;
Fault now = ohms > OPEN_OHMS ? OPEN : (ohms < SHORT_OHMS ? SHORTED : NONE);
if (now != NONE) {
goodCount = 0;
if (now != badKind) badCount = 0;
badKind = now;
if (badCount < FAULT_SAMPLES) badCount++;
if (badCount >= FAULT_SAMPLES) fault = now;
return;
}
badCount = 0;
badKind = NONE;
float percent = ohmsToPercent(ohms);
if (fault != NONE) {
// Wait for a run of good samples, then start the filter fresh.
if (++goodCount < FAULT_SAMPLES) return;
fault = NONE;
seeded = false;
}
if (!seeded) {
level = percent;
seeded = true;
} else {
level += (percent - level) * FILTER;
}
if (level <= LOW_ON_PERCENT) lowFuel = true;
else if (level >= LOW_OFF_PERCENT) lowFuel = false;
}
// The top row: E, a 14-cell bar (70 steps), F.
void drawBar() {
lcd.setCursor(0, 0);
lcd.print('E');
int steps = (int)(level * 70.0 / 100.0 + 0.5);
for (int cell = 0; cell < 14; cell++) {
int lit = constrain(steps - cell * 5, 0, 5);
if (lit == 0) lcd.print(' ');
else lcd.write((uint8_t)lit);
}
lcd.print('F');
}
void show() {
blink = !blink;
if (fault != NONE || !seeded) {
const char *what = fault == SHORTED ? "SENDER SHORTED" : "SENDER OPEN";
if (fault == NONE) what = "NO SENDER";
lcd.setCursor(0, 0);
lcd.print(what);
for (int i = strlen(what); i < 16; i++) lcd.print(' ');
lcd.setCursor(0, 1);
lcd.print(fault == SHORTED ? "wire to ground? " : "check the wire ");
digitalWrite(LOW_LIGHT_PIN, blink ? HIGH : LOW);
Serial.print(what);
Serial.println(fault == SHORTED ? " FAULT: short to ground" : " FAULT: open circuit");
return;
}
int percent = (int)(level + 0.5);
int tenths = (int)(level * TANK_GALLONS / 10.0 + 0.5); // gallons x 10
drawBar();
// "100% 16.0gal LOW"
lcd.setCursor(0, 1);
if (percent < 100) lcd.print(' ');
if (percent < 10) lcd.print(' ');
lcd.print(percent);
lcd.print("% ");
if (tenths < 100) lcd.print(' ');
lcd.print(tenths / 10);
lcd.print('.');
lcd.print(tenths % 10);
lcd.print(lowFuel ? "gal LOW" : "gal ");
digitalWrite(LOW_LIGHT_PIN, lowFuel ? HIGH : LOW);
Serial.print("Fuel ");
Serial.print(percent);
Serial.print("% ");
Serial.print(tenths / 10);
Serial.print('.');
Serial.print(tenths % 10);
Serial.print(" gal sender ");
Serial.print((int)(lastOhms + 0.5));
Serial.println(lowFuel ? " ohm LOW FUEL" : " ohm OK");
}
void setup() {
pinMode(LOW_LIGHT_PIN, OUTPUT);
Serial.begin(9600);
Serial.println("Fuel gauge: Ford-style 73 ohm empty, 10 ohm full, 16 gal tank");
lcd.init();
lcd.backlight();
makeBarChars();
lcd.print("Fuel gauge");
}
void loop() {
unsigned long now = millis();
if (now - lastSample >= SAMPLE_MS) {
lastSample = now;
sample();
}
if (now - lastShow >= SHOW_MS) {
lastShow = now;
show();
}
}
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(LOW_LIGHT_PIN, OUTPUT);
Serial.begin(9600);
Serial.println("Fuel gauge: Ford-style 73 ohm empty, 10 ohm full, 16 gal tank");
lcd.init();
lcd.backlight();
makeBarChars();
lcd.print("Fuel gauge");
}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() {
unsigned long now = millis();
if (now - lastSample >= SAMPLE_MS) {
lastSample = now;
sample();
}
if (now - lastShow >= SHOW_MS) {
lastShow = now;
show();
}
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - The LED does not light, or it lights once and then dies. LED
LEDs have polarity. The longer leg (anode) goes toward the positive side. Always add a series resistor, about 220 ohms to 1 k ohm on a 5 V pin. - A blue or white LED stays dark on 3.3 V. LED
Blue and white LEDs drop about 3 V, so 3.3 V leaves almost nothing for the resistor. Use a 5 V supply, or a smaller resistor and a red or green LED. - The input reads random values in one position. Slide switch
A switch only connects or disconnects. Add a pull-up or pull-down resistor, or use INPUT_PULLUP, so the pin has a defined level when the switch is open. - The load never turns on when the switch is flipped. Slide switch
Use the center (common) leg and one outer leg. The unused outer leg goes nowhere. Check that the switch is in series with the load. - The reading jumps around or only covers part of the range. Potentiometer
Wire the two outer legs to 5 V and ground and the middle leg (the wiper) to an analog pin. Using the wrong two legs gives a dead or partial range. - Turning the knob does nothing, or the value is stuck at 0 or 1023. Potentiometer
Check that the wiper goes to an analog input (A0 to A5 on an Uno), not a digital pin. Make sure both ends are connected, since a floating end gives noise.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.