Engine hour meter on an Arduino Uno
Counts the hours an engine runs from its 12 V feed through a protected divider, keeps the total and the hours since service in wear-leveled EEPROM, and shows both on a 16x2 I2C LCD with a service light and beep at 250 h. The whole build, an Arduino Uno and 9 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 40 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
- Sound
- Making sound with a buzzer
- Choosing and reading a resistor
- Difficulty: Intermediate
- Time: About 40 minutes
- Board: Arduino Uno R3
- Parts: 10
How it works
Counts the hours an engine runs from its 12 V feed through a protected divider, keeps the total and the hours since service in wear-leveled EEPROM, and shows both on a 16x2 I2C LCD with a service light and beep at 250 h.
Arduino Uno R3 runs the sketch below. The circuit around it: Buzzer, Bench power supply, Character LCD with I2C backpack, Resistor, 33k Ω, Resistor, 10k Ω, Zener diode. pinMode sets BUTTON_PIN as an input with its pull-up on. digitalWrite drives BUZZER_PIN HIGH. digitalRead reads whether BUTTON_PIN is HIGH or LOW. analogRead measures the voltage on SENSE_PIN as a number. delay waits 20 milliseconds.
Parts list
10 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.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × BuzzerIn Starter Kit, Rocket Flight Computer Kit.
- 1 × Bench power supplyIn Pinball Table Kit.
- 1 × Character LCD with I2C backpackIn Claw Machine Kit, Vending Machine Kit, Rhythm Game Kit.
- 1 × Resistor, 33k ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × Resistor, 10k ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × Zener diode
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 1 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, redIn Starter Kit, Halloween Prop Kit, Logic Lab 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 |
|---|---|
| Buzzer pin 2 | Arduino Uno R3 pin GND |
| Buzzer pin 1 | Arduino Uno R3 pin 8 |
| Pushbutton pin 1a | Arduino Uno R3 pin 2 |
| Character LCD with I2C backpack pin VCC | Arduino Uno R3 pin 5V |
| Bench power supply pin V- | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Resistor, 10k Ω pin 2 | Arduino Uno R3 pin GND |
| Zener diode pin A | Arduino Uno R3 pin GND |
| Pushbutton pin 2a | Arduino Uno R3 pin GND |
| LED, red pin C | Arduino Uno R3 pin GND |
| Resistor, 33k Ω pin 2 | Arduino Uno R3 pin A0 |
| Resistor, 10k Ω pin 1 | Arduino Uno R3 pin A0 |
| Zener diode pin C | Arduino Uno R3 pin A0 |
| Resistor, 220 Ω pin 1 | Arduino Uno R3 pin A1 |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Resistor, 33k Ω pin 1 | Bench power supply pin V+ |
| LED, red pin A | Resistor, 220 Ω pin 2 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Engine hour meter: counts the time an engine runs, keeps the total and the
// hours since the last service in EEPROM, shows both on a 16x2 I2C LCD, and
// lights a service light and beeps when the service interval comes round.
// For tractors, generators, boats, forklifts and shop equipment, where hours
// matter more than miles.
//
// A0 the sense divider's middle: 33 k from the switched 12 V feed,
// 10 k to ground, and a 5.1 V Zener (1N4733A) from A0 to ground,
// band to A0
// 2 the service reset button to GND (hold it 3 s)
// A1 the service light: 220 R then a red LED to GND
// 8 the active buzzer's plus leg, its other leg on GND
// LCD SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND (address 0x27)
// The 12 V minus goes to the Uno's GND: one ground for both.
//
// SAFETY. This is a teaching simulation. Never connect a project like this to
// a running vehicle'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 install
// needs a proper automotive front end: an inline fuse where the sense wire
// taps the feed, a TVS diode for load dump, and the clamp below.
//
// IS THE ENGINE RUNNING? The sense wire is on a switched feed (one that is
// live only with the key on). Key on and engine stopped, that feed is the
// battery: about 12.6 V when full. With the engine running the alternator
// lifts it to 13.8 to 14.7 V. So:
// 13.3 V or more for 2 s running: the meter counts
// under 13.0 V for 2 s stopped (key on, engine off, or a stall)
// under 6 V key off: stopped at once, and saved at once
// The 2 s debounce keeps a short sag (a big load switching on) or a spike
// from starting or stopping the count, and the two thresholds 0.3 V apart are
// hysteresis, so a voltage sitting right on the line cannot flicker. The 2 s
// it takes to agree the engine started and the 2 s to agree it stopped
// roughly cancel out. A cranking dip (9 to 10 V) is not running, so a start
// counts from when the alternator charges. Simplified: a tired alternator
// that only makes 13.2 V would not count; set RUN_ON lower for one.
//
// THE OTHER WAY: an oil pressure switch. Most engines have one for the oil
// light. It closes to ground when there is no oil pressure (engine stopped)
// and opens once the engine pumps oil. Wired to a pin with INPUT_PULLUP, the
// pin is HIGH while running: no divider needed, and it works on engines with
// no alternator. Debounce it the same way. Do not share the dash light's
// wire with a second load without checking the switch's rating.
//
// THE DIVIDER. 10 k of 33 k + 10 k is 0.2326 of the input, so 14.4 V reads as
// 3.35 V on A0 and 5 V on A0 is 21.5 V at the feed. The Zener clamps A0 near
// 5 V during load dump (tens of volts for a few hundred ms) and near -0.7 V
// with jump leads on backwards; the 33 k limits its current to under 2 mA
// at 60 V.
//
// TIME KEEPING WITH millis(). millis() counts milliseconds since reset and
// wraps round after 49.7 days. "now - lastTick" is still right across the
// wrap, because unsigned subtraction wraps too. Each pass adds the elapsed
// milliseconds while running and moves whole seconds out into the counters,
// so no time is lost to rounding however fast or slow loop() goes.
//
// THE EEPROM AND ITS WEAR. The ATmega328P's EEPROM is rated for about
// 100,000 writes per cell. Saving every minute to one address would wear it
// out in 100,000 minutes: 1,667 engine hours, which a farm tractor does in a
// couple of years. So:
// * it saves every 6 minutes of running (0.1 h), and when the engine stops
// * each save goes to the NEXT of 64 slots of 16 bytes, round and round,
// so each cell is written once every 64 saves: 6.4 running hours apart,
// about 640,000 hours before any cell reaches 100,000 writes
// * every slot carries a sequence number; at start-up the slot with the
// highest one that passes its checksum is the newest
// * a power cut in the middle of a save leaves a slot whose checksum fails,
// and the slot before it is still good: the worst case loses 6 minutes
//
// POWER-LOSS SAFETY. Here the Uno has its own 5 V and the sense wire tells it
// the key went off, so it saves at once. If you power the Uno from the same
// switched feed, it dies when the key turns off, so give it a hold-up: a
// diode into a 4700 uF capacitor ahead of the regulator. At about 50 mA that
// cap takes 0.0047 F x 5 V / 0.05 A = 0.47 s to fall from 12 V to the 7 V
// the regulator needs, and a 16 byte save takes about 55 ms (3.4 ms a byte).
// Sense the feed BEFORE the diode, so the Uno sees it drop while it still has
// power.
//
// THE SERVICE INTERVAL. SERVICE_HOURS is 250, a common oil change interval on
// small engines; your engine maker's manual has the real one. At 250 h the
// service light comes on and the buzzer beeps three times, and again at every
// start while it is due. Hold the button 3 s after the service: the "since
// service" count goes back to zero and a long beep says so. A short press
// does nothing, so it cannot be reset by accident.
//
// SETTING IT. Fitting the meter to an engine that already has hours? Type in
// the serial monitor:
// H 1234.5 sets the total to 1234.5 h
// S 12.25 sets the hours since service to 12.25 h
// (to 0.01 h, which is 36 s).
//
// Press Run, then set the supply to 14.2 V (running), 12.6 V (stopped) and
// 0 V (key off). Type S 249.99 and run it for a minute to see the service due.
#include <Wire.h>
#include <EEPROM.h>
#include <LiquidCrystal_I2C.h>
const int SENSE_PIN = A0;
const int BUTTON_PIN = 2;
const int SERVICE_LED_PIN = A1;
const int BUZZER_PIN = 8;
// The divider: (R1 + R2) / R2 turns the volts at A0 back into feed volts.
const float R1 = 33000.0;
const float R2 = 10000.0;
const float RUN_ON = 13.3; // the alternator is charging: running
const float RUN_OFF = 13.0; // under this, the engine has stopped
const float KEY_OFF = 6.0; // under this, the switched feed is off
const unsigned long DEBOUNCE_MS = 2000;
const unsigned long SERVICE_HOURS = 250; // follow your engine's manual
const unsigned long SERVICE_SECONDS = SERVICE_HOURS * 3600UL;
const unsigned long SAVE_EVERY_S = 360; // 6 minutes, 0.1 h
const unsigned long HOLD_MS = 3000; // the service reset press
// One save: 16 bytes, 64 of them fill the Uno's 1024 byte EEPROM.
struct Record {
unsigned long seq; // counts up with every save
unsigned long totalS; // the engine's total running seconds
unsigned long serviceS; // running seconds since the last service
byte check; // see checksum()
byte spare[3];
};
const int SLOT_SIZE = sizeof(Record);
const int SLOTS = 1024 / SLOT_SIZE;
LiquidCrystal_I2C lcd(0x27, 16, 2);
unsigned long totalS = 0;
unsigned long serviceS = 0;
unsigned long seq = 0; // the newest record's number
int slot = -1; // where the newest record is, -1 for none
unsigned long savedTotalS = 0; // totalS at the last save
bool running = false;
unsigned long changeSince = 0; // when the reading first disagreed with running
unsigned long lastTick = 0;
unsigned long carryMs = 0; // running milliseconds not yet a whole second
bool due = false;
int beepsLeft = 0; // half beeps: on, off, on, off...
unsigned long beepAt = 0;
unsigned long beepLen = 150;
bool buttonDown = false;
unsigned long pressedAt = 0;
bool pressDone = false;
unsigned long lastReport = 0;
char line[16];
byte lineLen = 0;
// The sum of the record's first 12 bytes plus a constant: an all-0xFF blank
// slot never passes. (A CRC would catch more; this catches a torn write.)
byte checksum(const Record &r) {
const byte *p = (const byte *)&r;
byte sum = 0x5A;
for (int i = 0; i < 12; i++) sum += p[i];
return sum;
}
// Find the newest good record, if there is one.
void load() {
for (int i = 0; i < SLOTS; i++) {
Record r;
EEPROM.get(i * SLOT_SIZE, r);
if (r.check != checksum(r)) continue;
if (slot < 0 || r.seq > seq) {
slot = i;
seq = r.seq;
totalS = r.totalS;
serviceS = r.serviceS;
}
}
savedTotalS = totalS;
}
// Write the counters to the next slot round.
void save(const char *why) {
Record r;
seq++;
slot = (slot + 1) % SLOTS;
r.seq = seq;
r.totalS = totalS;
r.serviceS = serviceS;
r.check = checksum(r);
r.spare[0] = r.spare[1] = r.spare[2] = 0;
EEPROM.put(slot * SLOT_SIZE, r);
savedTotalS = totalS;
Serial.print(why);
Serial.print(", saved to slot ");
Serial.println(slot);
}
float readVolts() {
long sum = 0;
for (int i = 0; i < 8; i++) sum += analogRead(SENSE_PIN);
return sum / 8.0 * 5.0 / 1023.0 * (R1 + R2) / R2;
}
void beep(int times, unsigned long len) {
beepsLeft = times * 2;
beepLen = len;
beepAt = millis();
digitalWrite(BUZZER_PIN, HIGH);
}
// Seconds as hours to 0.1 h, rounded down so it never claims time not run.
void printTenths(Print &out, unsigned long s) {
unsigned long tenths = s / 360;
out.print(tenths / 10);
out.print('.');
out.print(tenths % 10);
}
void checkDue() {
bool now = serviceS >= SERVICE_SECONDS;
if (now && !due) {
Serial.println("SERVICE DUE");
beep(3, 150);
}
due = now;
digitalWrite(SERVICE_LED_PIN, due ? HIGH : LOW);
}
void setup() {
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(SERVICE_LED_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
Serial.begin(9600);
Serial.println("Engine hour meter");
lcd.init();
lcd.backlight();
load();
if (slot < 0) {
Serial.println("blank EEPROM, starting from 0");
} else {
Serial.print("loaded slot ");
Serial.print(slot);
Serial.print(": total ");
Serial.print(totalS);
Serial.print(" s, service ");
Serial.print(serviceS);
Serial.println(" s");
}
due = serviceS >= SERVICE_SECONDS; // no beep until the engine starts
digitalWrite(SERVICE_LED_PIN, due ? HIGH : LOW);
lastTick = millis();
}
// The engine's state from the sense voltage, debounced.
void sense(float v, unsigned long now) {
if (running && v < KEY_OFF) {
running = false;
Serial.println("key off");
save("stopped");
return;
}
bool looksRunning = running ? v >= RUN_OFF : v >= RUN_ON;
if (looksRunning == running) {
changeSince = now;
return;
}
if (now - changeSince < DEBOUNCE_MS) return;
running = looksRunning;
changeSince = now;
if (running) {
Serial.println("engine running");
if (due) beep(3, 150);
} else {
save("engine stopped");
}
}
// Add the time since the last pass while running.
void tick(unsigned long now) {
unsigned long dt = now - lastTick;
lastTick = now;
if (!running) return;
carryMs += dt;
while (carryMs >= 1000) {
carryMs -= 1000;
totalS++;
serviceS++;
}
if (totalS - savedTotalS >= SAVE_EVERY_S) save("6 minutes");
checkDue();
}
// Hold the button 3 s to zero the service count.
void button(unsigned long now) {
bool down = digitalRead(BUTTON_PIN) == LOW;
if (down && !buttonDown) {
pressedAt = now;
pressDone = false;
}
buttonDown = down;
if (down && !pressDone && now - pressedAt >= HOLD_MS) {
pressDone = true;
serviceS = 0;
Serial.println("service reset");
save("service reset");
checkDue();
beep(1, 800);
}
}
// Steps the beeps along without delay(). It reads millis() itself rather
// than the loop's "now", because beep() may have stamped beepAt later in the
// same pass, and an unsigned "now - beepAt" would then wrap to a huge number.
void buzzer() {
unsigned long now = millis();
if (beepsLeft == 0 || now - beepAt < beepLen) return;
beepAt = now;
beepsLeft--;
digitalWrite(BUZZER_PIN, beepsLeft > 0 && beepsLeft % 2 == 0 ? HIGH : LOW);
}
// Hours typed as "H 1234.5" or "S 12.25", to seconds, to 0.01 h.
long parseHours(const char *s) {
long hundredths = 0;
int decimals = -1;
for (; *s; s++) {
if (*s == '.' && decimals < 0) {
decimals = 0;
} else if (*s >= '0' && *s <= '9') {
if (decimals >= 2) continue;
hundredths = hundredths * 10 + (*s - '0');
if (decimals >= 0) decimals++;
} else if (*s != ' ') {
return -1;
}
}
if (decimals < 0) decimals = 0;
for (; decimals < 2; decimals++) hundredths *= 10;
return hundredths * 36;
}
void command() {
while (Serial.available() > 0) {
char c = Serial.read();
if (c != '\n' && c != '\r') {
if (lineLen < sizeof(line) - 1) line[lineLen++] = c;
continue;
}
if (lineLen == 0) continue;
line[lineLen] = 0;
lineLen = 0;
char which = line[0] & ~0x20; // upper case
long s = parseHours(line + 1);
if ((which != 'H' && which != 'S') || s < 0) {
Serial.println("type H 1234.5 for the total or S 12.25 for since service");
continue;
}
if (which == 'H') totalS = s;
else serviceS = s;
save(which == 'H' ? "total set" : "service set");
checkDue();
}
}
void show(float v) {
lcd.setCursor(0, 0);
lcd.print("Total ");
printTenths(lcd, totalS);
lcd.print(" h");
lcd.print(running ? " RUN " : " ");
lcd.setCursor(0, 1);
if (due) lcd.print("SVC DUE ");
else lcd.print("Service ");
printTenths(lcd, serviceS);
lcd.print(" h ");
Serial.print("total ");
printTenths(Serial, totalS);
Serial.print(" h (");
Serial.print(totalS / 3600);
Serial.print(" h ");
Serial.print(totalS / 60 % 60);
Serial.print(" min) service ");
printTenths(Serial, serviceS);
Serial.print(" h of ");
Serial.print(SERVICE_HOURS);
Serial.print(due ? " DUE " : " ");
Serial.print(v, 1);
Serial.println(running ? " V running" : " V stopped");
}
void loop() {
unsigned long now = millis();
float v = readVolts();
sense(v, now);
tick(now);
button(now);
buzzer();
command();
if (now - lastReport >= 1000) {
lastReport = now;
show(v);
}
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(BUTTON_PIN, INPUT_PULLUP);
pinMode(SERVICE_LED_PIN, OUTPUT);
pinMode(BUZZER_PIN, OUTPUT);
Serial.begin(9600);
Serial.println("Engine hour meter");
lcd.init();
lcd.backlight();
load();
if (slot < 0) {
Serial.println("blank EEPROM, starting from 0");
} else {
Serial.print("loaded slot ");
Serial.print(slot);
Serial.print(": total ");
Serial.print(totalS);
Serial.print(" s, service ");
Serial.print(serviceS);
Serial.println(" s");
}
due = serviceS >= SERVICE_SECONDS; // no beep until the engine starts
digitalWrite(SERVICE_LED_PIN, due ? HIGH : LOW);
lastTick = millis();
}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();
float v = readVolts();
sense(v, now);
tick(now);
button(now);
buzzer();
command();
if (now - lastReport >= 1000) {
lastReport = now;
show(v);
}
delay(20);
}Troubleshooting
The usual mistakes with this circuit, and the fix for each.
- 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. - 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.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.