Wheel speed and odometer on an Arduino Uno
A Hall sensor counts a magnet on the wheel on an interrupt: speed in mph and km/h from the pulse period and the tire size on a 16x2 LCD, with a trip meter and an odometer kept in wear-leveled EEPROM through a power cut. The whole build, an Arduino Uno and 5 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 $34. On Mokxi they're free.
What you'll learn
- Sensors
- Displays
- Reading a button
- Using the Clock
- Difficulty: Intermediate
- Time: About 30 minutes
- Board: Arduino Uno R3
- Parts: 7
How it works
A Hall sensor counts a magnet on the wheel on an interrupt: speed in mph and km/h from the pulse period and the tire size on a 16x2 LCD, with a trip meter and an odometer kept in wear-leveled EEPROM through a power cut.
Arduino Uno R3 runs the sketch below. The circuit around it: Clock, Slide switch, Hall sensor, A3144, Pushbutton, Character LCD with I2C backpack. pinMode sets PULSE_PIN as an input with its pull-up on. digitalRead reads whether BUTTON_PIN is HIGH or LOW. 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
7 parts, plus the jumper wires. They retail for about $34; on Mokxi they're free.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Half breadboard
- 1 × ClockIn Logic Lab Kit.
- 1 × Slide switchIn Logic Lab Kit, Spaceship Panel Kit.
- 1 × Hall sensor, A3144
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab 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 |
|---|---|
| Pushbutton pin 1b | Arduino Uno R3 pin 4 |
| Slide switch pin 2 | Arduino Uno R3 pin 2 |
| Hall sensor, A3144 pin VCC | Arduino Uno R3 pin 5V |
| Character LCD with I2C backpack pin VCC | Arduino Uno R3 pin 5V |
| Hall sensor, A3144 pin GND | Arduino Uno R3 pin GND |
| Pushbutton pin 2b | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin GND | Arduino Uno R3 pin GND |
| Character LCD with I2C backpack pin SDA | Arduino Uno R3 pin A4 |
| Character LCD with I2C backpack pin SCL | Arduino Uno R3 pin A5 |
| Slide switch pin 1 | Clock pin OUT |
| Hall sensor, A3144 pin OUT | Slide switch pin 3 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Wheel speed and odometer: a Hall sensor counts a magnet going past on a
// wheel, the speed and the distance go on a 16x2 I2C LCD, and a trip meter and
// a total odometer are kept in EEPROM so a power cut does not lose them.
//
// pin 2 the wheel pulse (INT0) from the slide switch's middle leg:
// left = the bench test signal, right = the A3144
// A3144 VCC -> 5 V GND -> GND OUT -> 10 k pull-up to 5 V
// pin 4 the trip button to GND (hold it a second to zero the trip)
// LCD SDA -> A4 SCL -> A5 VCC -> 5 V GND -> GND (address 0x27)
//
// 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), and never tap a car's own ABS
// wheel speed sensors. Try it on the bench with a bench supply or a fused
// 12 V battery. A real install needs a proper 12 V to 5 V regulator with a
// fuse and TVS / load-dump protection in front of it, and a sensor cable kept
// away from the ignition. Real vehicle odometers are regulated: in the US,
// altering or resetting one is a federal crime (the odometer law, 49 U.S.C.
// chapter 327). This is a gauge for bikes, karts, trailers and shop rigs.
//
// THE SENSOR. An A3144 Hall switch pulls OUT low while the south pole of a
// magnet is within about 10 mm of its face. Glue one magnet to the wheel
// (or the brake disc, or the driveshaft) and mount the sensor so the magnet
// sweeps past it: one falling edge per turn. OUT is an open collector, so it
// needs a pull-up; the 10 k gives a sharper edge on a long cable than the
// Uno's own 20 to 50 k. A reed switch works too, but it bounces, which is
// what the 5 ms lockout in the interrupt is for.
//
// THE TIRE. Circumference is the distance one turn rolls. Read it off the
// sidewall: 205/55R16 means 205 mm wide, a sidewall 55 % of that tall
// (112.75 mm), on a 16 inch rim (406.4 mm). So
// diameter = 406.4 + 2 x 112.75 = 631.9 mm
// circumference = pi x 631.9 = 1985.2 mm, about 1.985 m a turn
// Put the wrong size in and every reading is off by the same percentage.
// A 205/65R16 is 672.9 mm across, 6.5 % bigger: fitted to a car set up for
// 205/55R16 it rolls 6.5 % further per turn, so at a true 60 mph the gauge
// says 56.3 mph and the odometer under-counts every mile. (A real tire under
// load rolls a little smaller than this sum; a GPS check is the final word.)
//
// SPEED FROM THE PERIOD. The interrupt stamps each pulse with micros(). The
// speed is distance over time between pulses, and a handy fact keeps it
// simple: micrometers per microsecond are meters per second. Then
// mph = m/s x 2.23694 (3600 s an hour / 1609.344 m a mile)
// km/h = m/s x 3.6
// At 60 mph a 1.985 m wheel turns 13.51 times a second, one pulse every
// 74.0 ms. With no pulse for 2 s the wheel counts as stopped (under about
// 2.2 mph a single magnet is too slow to time; more magnets help).
//
// THE EEPROM. The ATmega328P's EEPROM is rated for 100,000 writes per cell.
// Saving the odometer to one address every 0.1 mile would wear that address
// out in 10,000 miles. So this sketch:
// 1. writes rarely: every 0.1 mile on the move, and once when the wheel
// stops, so parking and switching off loses nothing;
// 2. spreads the wear: 32 slots of 16 bytes in a ring, each save going in
// the next slot. Each cell then sees one write every 3.2 miles, about
// 320,000 miles of wear life (stop-and-go driving adds some writes).
// Each slot holds a sequence number, the total and the trip in meters, and a
// check byte. At start-up the sketch reads all 32 and takes the newest whose
// check byte matches. If the power dies halfway through a write, that slot
// fails its check and the one before it is used: at most 0.1 mile is lost.
// (The Uno's brown-out detector, set to 2.7 V, also stops the chip before a
// sagging supply can scramble a write.)
//
// Things to try. Press Run: the bench signal is 13.51 Hz, a 205/55R16 wheel at
// 60 mph. Change its frequency (6.756 Hz is 30 mph). Press the Uno's reset
// button: that is a power cut, and the trip and the odometer come back from
// EEPROM. Hold the button: the trip goes to zero, the odometer does not.
// Slide the switch to the Hall sensor and drag the magnet past it.
#include <Wire.h>
#include <EEPROM.h>
#include <LiquidCrystal_I2C.h>
const byte PULSE_PIN = 2; // INT0
const byte BUTTON_PIN = 4;
// The tire, as printed on the sidewall: 205/55R16.
const float TIRE_WIDTH_MM = 205;
const float TIRE_ASPECT = 55; // sidewall height, % of the width
const float RIM_INCHES = 16;
const float TIRE_DIAMETER_MM = RIM_INCHES * 25.4 + 2 * TIRE_WIDTH_MM * TIRE_ASPECT / 100;
// Pulses per wheel turn: the magnets on the wheel. On a driveshaft it is the
// magnets times the axle ratio (for example 1 x 3.73).
const float PULSES_PER_TURN = 1;
// The distance one pulse stands for, in micrometers: 1,985,172 here.
const unsigned long UM_PER_PULSE = (unsigned long)(PI * TIRE_DIAMETER_MM * 1000 / PULSES_PER_TURN + 0.5);
const float METERS_PER_MILE = 1609.344;
const float MPS_TO_MPH = 3600 / METERS_PER_MILE; // 2.23694
const unsigned long MIN_PERIOD_US = 5000; // closer pulses are bounce or noise
const unsigned long STOPPED_US = 2000000; // no pulse for 2 s: stopped
const unsigned long SAVE_EVERY_M = 161; // about 0.1 mile
// The EEPROM ring.
const byte SLOTS = 32;
const byte SLOT_BYTES = 16;
struct Record {
unsigned long seq; // goes up by one each save; the newest wins
unsigned long totalM; // the odometer, in meters
unsigned long tripM; // the trip, in meters
byte check; // catches a slot left half written
};
LiquidCrystal_I2C lcd(0x27, 16, 2);
// Written by the interrupt, read by loop(): volatile, and copied with
// interrupts off so a pulse cannot land halfway through the copy.
volatile unsigned long pulseCount = 0;
volatile unsigned long lastPulseUs = 0;
void onPulse() {
unsigned long now = micros();
if (pulseCount > 0 && now - lastPulseUs < MIN_PERIOD_US) return;
lastPulseUs = now;
pulseCount++;
}
unsigned long totalM = 0, tripM = 0; // whole meters
unsigned long totalUm = 0, tripUm = 0; // the part meter not yet counted
unsigned long seq = 0;
byte slot = SLOTS - 1; // the slot last written
unsigned long savedTotalM = 0, savedTripM = 0;
unsigned long countSeen = 0;
unsigned long windowCount = 0, windowUs = 0; // the pulse count and time last update
bool haveWindow = false;
float mps = 0;
unsigned long lastShow = 0, lastPrint = 0, pressedAt = 0;
bool tripDone = false;
byte checkOf(const Record &r) {
const byte *b = (const byte *)&r;
byte sum = 0xA5;
for (byte i = 0; i < sizeof(Record) - 1; i++) sum += b[i];
return sum;
}
void loadOdometer() {
bool found = false;
for (byte i = 0; i < SLOTS; i++) {
Record r;
EEPROM.get(i * SLOT_BYTES, r);
if (r.seq == 0xFFFFFFFFUL || r.check != checkOf(r)) continue; // blank or torn
if (!found || r.seq > seq) {
found = true;
seq = r.seq;
slot = i;
totalM = r.totalM;
tripM = r.tripM;
}
}
savedTotalM = totalM;
savedTripM = tripM;
Serial.print(F("odometer: "));
if (found) {
Serial.print(F("slot "));
Serial.print(slot);
} else {
Serial.print(F("blank EEPROM"));
}
Serial.print(F(" trip "));
Serial.print(tripM / METERS_PER_MILE, 3);
Serial.print(F(" mi odo "));
Serial.print(totalM / METERS_PER_MILE, 3);
Serial.println(F(" mi"));
}
void saveOdometer() {
Record r;
r.seq = ++seq;
r.totalM = totalM;
r.tripM = tripM;
r.check = checkOf(r);
slot = (slot + 1) % SLOTS;
EEPROM.put(slot * SLOT_BYTES, r);
savedTotalM = totalM;
savedTripM = tripM;
Serial.print(F("saved slot "));
Serial.print(slot);
Serial.print(F(": odo "));
Serial.print(totalM / METERS_PER_MILE, 3);
Serial.println(F(" mi"));
}
// Adds the pulses that came in since last time to both distances.
void addDistance(unsigned long pulses) {
unsigned long um = pulses * UM_PER_PULSE; // fine for a few hundred pulses
totalUm += um;
tripUm += um;
totalM += totalUm / 1000000UL;
totalUm %= 1000000UL;
tripM += tripUm / 1000000UL;
tripUm %= 1000000UL;
}
void show() {
char text[9];
float mph = mps * MPS_TO_MPH;
lcd.setCursor(0, 0);
lcd.print(dtostrf(mph, 5, 1, text));
lcd.print(F(" mph "));
lcd.print(dtostrf(mps * 3.6, 3, 0, text));
lcd.print(F("kmh"));
lcd.setCursor(0, 1);
lcd.print('T');
lcd.print(dtostrf(tripM / METERS_PER_MILE, 6, 2, text));
lcd.print(F(" O"));
lcd.print(dtostrf(totalM / METERS_PER_MILE, 7, 1, text));
}
void setup() {
Serial.begin(9600);
pinMode(PULSE_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Counting starts first, so no turn is missed while the rest starts up.
attachInterrupt(digitalPinToInterrupt(PULSE_PIN), onPulse, FALLING);
lcd.init();
lcd.backlight();
Serial.println(F("Wheel speed and odometer, 205/55R16"));
loadOdometer();
show();
}
void loop() {
unsigned long now = micros();
// Hold the button a second to zero the trip.
if (digitalRead(BUTTON_PIN) == LOW) {
if (pressedAt == 0) pressedAt = millis();
if (!tripDone && millis() - pressedAt >= 1000) {
tripM = 0;
tripUm = 0;
Serial.println(F("trip reset"));
saveOdometer();
show();
tripDone = true; // once per press: wait for the button to come back up
}
} else {
pressedAt = 0;
tripDone = false;
}
if (millis() - lastShow < 500) return;
lastShow = millis();
noInterrupts();
unsigned long count = pulseCount;
unsigned long last = lastPulseUs;
interrupts();
// Distance: every pulse is one more turn rolled.
addDistance(count - countSeen);
countSeen = count;
// Speed: the pulses since the last update over the time they took, edge to
// edge. No new pulse: the speed can only be as fast as one turn in the time
// since the last one, so it falls as the wheel slows, and 0 after 2 s.
bool moving = count > 0 && now - last < STOPPED_US;
if (!moving) {
mps = 0;
haveWindow = false;
} else if (haveWindow && count != windowCount) {
mps = (float)UM_PER_PULSE * (count - windowCount) / (last - windowUs);
} else if (haveWindow) {
float slowest = (float)UM_PER_PULSE / (now - last);
if (slowest < mps) mps = slowest;
}
if (moving) {
windowCount = count;
windowUs = last;
haveWindow = true;
}
// Save every 0.1 mile on the move, and once when the wheel stops.
bool changed = totalM != savedTotalM || tripM != savedTripM;
if (totalM - savedTotalM >= SAVE_EVERY_M || (!moving && changed)) saveOdometer();
show();
if (millis() - lastPrint < 1000) return;
lastPrint = millis();
Serial.print(F("speed "));
Serial.print(mps * MPS_TO_MPH, 1);
Serial.print(F(" mph "));
Serial.print(mps * 3.6, 1);
Serial.print(F(" km/h trip "));
Serial.print(tripM / METERS_PER_MILE, 3);
Serial.print(F(" mi odo "));
Serial.print(totalM / METERS_PER_MILE, 3);
Serial.println(F(" mi"));
}
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() {
Serial.begin(9600);
pinMode(PULSE_PIN, INPUT_PULLUP);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// Counting starts first, so no turn is missed while the rest starts up.
attachInterrupt(digitalPinToInterrupt(PULSE_PIN), onPulse, FALLING);
lcd.init();
lcd.backlight();
Serial.println(F("Wheel speed and odometer, 205/55R16"));
loadOdometer();
show();
}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 = micros();
// Hold the button a second to zero the trip.
if (digitalRead(BUTTON_PIN) == LOW) {
if (pressedAt == 0) pressedAt = millis();
if (!tripDone && millis() - pressedAt >= 1000) {
tripM = 0;
tripUm = 0;
Serial.println(F("trip reset"));
saveOdometer();
show();
tripDone = true; // once per press: wait for the button to come back up
}
} else {
pressedAt = 0;
tripDone = false;
}
if (millis() - lastShow < 500) return;
lastShow = millis();
noInterrupts();
unsigned long count = pulseCount;
unsigned long last = lastPulseUs;
interrupts();
// Distance: every pulse is one more turn rolled.
// ... 40 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The circuit does not behave as expected at the set frequency. Clock
Pick a frequency slow enough to watch, such as 1 Hz, when learning. Fast clocks look like steady signals. Make sure the clock output and your circuit share a ground. - A counter or flip-flop skips steps. Clock
Check that the clock edge matches the chip, rising or falling. Slow, clean edges are best. Do not connect a mechanical switch as a clock without debouncing it. - 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. - OUT never changes, or floats. Hall sensor
The A3144 has an open-collector output. Add a pull-up of about 10 k ohms from OUT to VCC. VCC may be 4.5 V to 24 V. The output goes LOW near a magnet. - The sensor ignores the magnet. Hall sensor
Bring the south pole to the marked face of the sensor. A north pole does nothing. If it still fails, flip the magnet or the sensor. - The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.