Built-in project · Arduino Uno R3

GPS speedometer with a trip odometer on an Arduino Uno

A NEO-6M and NeoGPS show speed, heading and satellites on an OLED, and a trip and total distance saved in EEPROM survive a reset. The whole build, an Arduino Uno and 3 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 35 minutes to build and run. Runs in your browser. Free, and no account needed.

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

The circuit runs here once the page has loaded.

What you'll learn

  • Reading a GPS module with NeoGPS
  • Adding up small distance steps into an odometer
  • Saving values in EEPROM with wear leveling
  • Drawing text on an OLED over I2C
  • Ignoring noise with a minimum speed
  • Difficulty: Intermediate
  • Time: About 35 minutes
  • Board: Arduino Uno R3
  • Parts: 7

How it works

The NEO-6M GPS sends a position, speed and heading once a second. The sketch reads them with the NeoGPS library through pins 4 and 3. The GPS works out speed and heading itself, so the sketch does no math for them. Heading shows as --- below 2 km/h.

Each second, the sketch measures the distance from the last position and adds it to a trip and a total. Steps under 2 km/h are ignored, so GPS jitter does not make a parked car travel. The numbers appear on the 128x64 OLED over I2C on A4 and A5.

Every 100 m the totals are saved to EEPROM, so they survive a reset. Hold the button on pin 2 for one second to zero the trip. The total keeps counting.

Parts list

7 parts, plus the jumper wires. They retail for about $40; on Mokxi they're free.

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
GPS module, NEO-6M pin TXArduino Uno R3 pin 4
GPS module, NEO-6M pin RXArduino Uno R3 pin 3
Pushbutton pin 1bArduino Uno R3 pin 2
OLED display, 128x64 pin SDAArduino Uno R3 pin A4
OLED display, 128x64 pin SCLArduino Uno R3 pin A5
GPS module, NEO-6M pin VCC5 V
GPS module, NEO-6M pin GNDGround
Pushbutton pin 2bGround
OLED display, 128x64 pin GNDGround
OLED display, 128x64 pin VCC5 V

The note on the canvas

Word for word, as it sits beside the circuit in the editor.

Try it
1. Press Run. The GPS is driving east at 36 km/h: the OLED shows the speed, HDG 090 E and the satellites.
2. Watch TRIP and ODO count up, 10 m every second.
3. Press the Uno's reset button: both distances come back from the EEPROM.
4. Hold the button until TRIP goes back to 0.00. ODO keeps counting.
5. Click the GPS to lose the fix, or change its speed and course in its settings.

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
// GPS speedometer with a trip odometer: a NEO-6M read with NeoGPS shows the
// speed, the heading and the satellites on a 128x64 OLED, and adds up the
// distance covered into a trip and a total that survive a power cut.
//
//   NEO-6M   TX -> pin 4   RX -> pin 3   VCC -> 5V   GND -> GND
//   OLED     SDA -> A4     SCL -> A5     VCC -> 5V   GND -> GND
//   button   pin 2 to GND (hold it for a second to zero the trip)
//
// SPEED AND HEADING come from the GPS itself: the module works them out
// from the Doppler shift of the satellites' signals and sends them once a
// second in its RMC sentence, so no maths is needed here. Below 2 km/h a
// parked receiver's heading wanders, so it is shown as --- then.
//
// THE DISTANCE is the sum of the steps between one second's position and
// the next. Steps taken under 2 km/h are not counted: standing still, a
// real GPS position jitters by a few meters, and adding that up would make
// the trip grow in a parked car.
//
// THE ODOMETER lives in the EEPROM, written with EEPROMWearLevel. EEPROM
// cells last about 100,000 writes, so a value saved at the same address
// every 100 m would wear one out in 10,000 km; the library writes each new
// value to the next free bytes of a stretch instead, so the wear is spread
// over many cells. Saving every 100 m means a power cut loses at most 100 m.
//
// THE QUIET TIME. The NEO-6M sends RMC first and five more sentences after
// it, about half a second of text a second, and SoftwareSerial holds only
// 64 characters. So each fix waits until the module has gone quiet before
// the OLED is drawn and the line printed: doing that while the rest of the
// burst arrived would overflow the buffer and lose sentences. The OLED is
// drawn once with its labels, and after that only the numbers are written,
// padded to a fixed width, so an update is quick.
//
// Things to try. Change the GPS module's speed and course in its settings,
// then watch the trip count up. Press reset: the trip and the total are
// still there. Hold the button: the trip goes back to zero, the total does
// not. Click the GPS to lose the fix.

#include <Wire.h>
#include <SoftwareSerial.h>  // GPSport.h then puts the GPS on pins 4 (RX) and 3 (TX)
#include <NMEAGPS.h>
#include <GPSport.h>
#include <EEPROMWearLevel.h>
#include "SSD1306Ascii.h"
#include "SSD1306AsciiWire.h"

const byte BUTTON_PIN = 2;
const float MIN_SPEED_KMH = 2.0;    // slower than this counts as standing still
const float SAVE_EVERY_KM = 0.1;    // write the odometer to EEPROM every 100 m
const unsigned long QUIET_MS = 20;  // the GPS has finished its second's sentences

// EEPROMWearLevel's layout: two values, each in its own stretch of EEPROM.
const byte EEPROM_LAYOUT_VERSION = 1;
const int INDEX_TRIP = 0;
const int INDEX_TOTAL = 1;

NMEAGPS gps;
gps_fix fix;
SSD1306AsciiWire oled;

NeoGPS::Location_t lastPlace;
bool havePlace = false;
float tripKm = 0;
float totalKm = 0;
float savedTotalKm = 0;
unsigned long pressedAt = 0;
unsigned long lastCharAt = 0;
bool fixWaiting = false;

void saveOdometer() {
  EEPROMwl.put(INDEX_TRIP, tripKm);
  EEPROMwl.put(INDEX_TOTAL, totalKm);
  savedTotalKm = totalKm;
}

const char *compassPoint(int degrees) {
  static const char *const POINTS[] = {"N ", "NE", "E ", "SE", "S ", "SW", "W ", "NW"};
  return POINTS[((degrees * 2 + 45) / 90) % 8];
}

// Text at a column (in pixels) and a row (in 8-pixel pages).
void at(uint8_t col, uint8_t row, const char *text) {
  oled.setCursor(col, row);
  oled.print(text);
}

void drawLabels() {
  oled.clear();
  at(0, 0, "SAT");
  at(48, 0, "HDG");
  at(92, 3, "km/h");
  at(0, 6, "TRIP");
  at(0, 7, "ODO");
  at(96, 6, "km");
  at(96, 7, "km");
}

void drawDistances() {
  char text[9];
  at(36, 6, dtostrf(tripKm, 8, 2, text));
  at(36, 7, dtostrf(totalKm, 8, 1, text));
}

void draw(bool hasFix, float kmh, int heading) {
  char text[9];
  oled.setCursor(24, 0);
  oled.print(fix.valid.satellites ? fix.satellites : 0);
  oled.print(' ');
  oled.setCursor(72, 0);
  if (heading >= 0) {
    if (heading < 100) oled.print('0');
    if (heading < 10) oled.print('0');
    oled.print(heading);
    oled.print(' ');
    oled.print(compassPoint(heading));
  } else {
    oled.print(F("---   "));
  }

  // The speed twice the size, in rows 2 and 3.
  oled.set2X();
  if (hasFix) {
    at(0, 2, dtostrf(kmh, 7, 1, text));
  } else {
    at(0, 2, "   --.-");
  }
  oled.set1X();
  at(0, 4, hasFix ? "                 " : "waiting for a fix");
  drawDistances();
}

void setup() {
  Serial.begin(9600);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  gpsPort.begin(9600);

  Wire.begin();
  Wire.setClock(400000L);  // as SSD1306Ascii's examples run the OLED
  oled.begin(&Adafruit128x64, 0x3C);
  oled.setFont(Adafruit5x7);

  EEPROMwl.begin(EEPROM_LAYOUT_VERSION, 2);
  EEPROMwl.get(INDEX_TRIP, tripKm);  // left at 0 on a new board
  EEPROMwl.get(INDEX_TOTAL, totalKm);
  savedTotalKm = totalKm;

  drawLabels();
  draw(false, 0, -1);
  Serial.print(F("GPS speedometer: trip "));
  Serial.print(tripKm, 3);
  Serial.print(F(" km, total "));
  Serial.print(totalKm, 3);
  Serial.println(F(" km"));
}

void loop() {
  // Hold the button a second to zero the trip.
  if (digitalRead(BUTTON_PIN) == LOW) {
    if (pressedAt == 0) pressedAt = millis();
    if (pressedAt != 1 && millis() - pressedAt >= 1000) {
      tripKm = 0;
      saveOdometer();
      drawDistances();
      Serial.println(F("trip reset"));
      pressedAt = 1;  // wait for the button to come back up
    }
  } else {
    pressedAt = 0;
  }

  // NeoGPS hands over one whole fix a second, once the RMC sentence is in;
  // it is used when the rest of the second's sentences have arrived.
  if (gpsPort.available()) lastCharAt = millis();
  if (gps.available(gpsPort)) {
    fix = gps.read();
    fixWaiting = true;
  }
  if (!fixWaiting || millis() - lastCharAt < QUIET_MS) return;
  fixWaiting = false;

  if (!fix.valid.location || !fix.valid.speed) {
    havePlace = false;
    draw(false, 0, -1);
    Serial.println(F("no fix"));
    return;
  }

  float kmh = fix.speed_kph();
  bool moving = kmh >= MIN_SPEED_KMH;
  if (havePlace && moving) {
    float stepKm = fix.location.DistanceKm(lastPlace);
    tripKm += stepKm;
    totalKm += stepKm;
    if (totalKm - savedTotalKm >= SAVE_EVERY_KM) saveOdometer();
  }
  lastPlace = fix.location;
  havePlace = true;

  int heading = (moving && fix.valid.heading) ? (int)(fix.heading() + 0.5) % 360 : -1;
  draw(true, kmh, heading);

  Serial.print(F("speed "));
  Serial.print(kmh, 1);
  Serial.print(F(" km/h  heading "));
  if (heading >= 0) {
    Serial.print(heading);
  } else {
    Serial.print(F("---"));
  }
  Serial.print(F("  sats "));
  Serial.print(fix.satellites);
  Serial.print(F("  trip "));
  Serial.print(tripKm, 3);
  Serial.print(F(" km  total "));
  Serial.print(totalKm, 3);
  Serial.println(F(" km"));
}

Code walkthrough

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

saveOdometer sketch.ino

Writes the trip and the total to EEPROM with EEPROMwl.put. Plain EEPROM cells wear out after about 100,000 writes, so this library spreads the writes over many cells. loop calls it each time the total grows by SAVE_EVERY_KM, 100 m, so a power cut loses at most that much.

void saveOdometer() {
  EEPROMwl.put(INDEX_TRIP, tripKm);
  EEPROMwl.put(INDEX_TOTAL, totalKm);
  savedTotalKm = totalKm;
}

draw sketch.ino

Updates the screen. The labels are drawn once by drawLabels, so this only writes the numbers: satellites, heading with a compass point, and the speed at double size. Without a fix it shows --.- and waiting for a fix. Padding the numbers to a fixed width keeps updates quick.

void draw(bool hasFix, float kmh, int heading) {
  char text[9];
  oled.setCursor(24, 0);
  oled.print(fix.valid.satellites ? fix.satellites : 0);
  oled.print(' ');
  oled.setCursor(72, 0);
  if (heading >= 0) {
    if (heading < 100) oled.print('0');
    if (heading < 10) oled.print('0');
    oled.print(heading);
    oled.print(' ');
    oled.print(compassPoint(heading));
  } else {
    oled.print(F("---   "));
  }

  // The speed twice the size, in rows 2 and 3.
  oled.set2X();
  if (hasFix) {
    at(0, 2, dtostrf(kmh, 7, 1, text));
  } else {
    at(0, 2, "   --.-");
  }
  oled.set1X();
  at(0, 4, hasFix ? "                 " : "waiting for a fix");
  drawDistances();
}

loop sketch.ino

Handles the one-second hold on the button, then waits for a whole fix from NeoGPS. It waits until the GPS has been quiet for QUIET_MS, so drawing never overflows the serial buffer. Then it adds the step from the last position to the trip and total, draws, and prints a line.

void loop() {
  // Hold the button a second to zero the trip.
  if (digitalRead(BUTTON_PIN) == LOW) {
    if (pressedAt == 0) pressedAt = millis();
    if (pressedAt != 1 && millis() - pressedAt >= 1000) {
      tripKm = 0;
      saveOdometer();
      drawDistances();
      Serial.println(F("trip reset"));
      pressedAt = 1;  // wait for the button to come back up
    }
  } else {
    pressedAt = 0;
  }

  // NeoGPS hands over one whole fix a second, once the RMC sentence is in;
  // it is used when the rest of the second's sentences have arrived.
  if (gpsPort.available()) lastCharAt = millis();
  if (gps.available(gpsPort)) {
    fix = gps.read();
    fixWaiting = true;
  }
  if (!fixWaiting || millis() - lastCharAt < QUIET_MS) return;
  fixWaiting = false;

  if (!fix.valid.location || !fix.valid.speed) {
    havePlace = false;
    draw(false, 0, -1);
  // ... 34 more lines in the editor

Troubleshooting

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

  • The screen says waiting for a fix. This project
    The sketch needs a valid location and speed. In the simulator, click the GPS module to check that it has a fix.
  • Nothing shows from the GPS. This project
    The module's TX goes to pin 4 and its RX to pin 3. Swapping them gives no data.
  • The trip does not reset when you press the button. This project
    Hold the button for a full second. A quick tap does nothing.
  • Garbage text or no data from the module. GPS module
    The default is 9600 baud. Connect module TX to the board RX, and RX to TX. Set the serial speed to match. Use SoftwareSerial pins on an Uno.
  • No fix is ever reported. GPS module
    A real NEO-6M needs a clear sky view and up to several minutes for a cold start. Indoors it usually never fixes. Keep it powered so it stays warm.
  • 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.
  • The OLED stays blank. OLED display
    Check SDA and SCL are not swapped, and power it with 3.3 V or 5 V as its board allows. Most use address 0x3C, some 0x3D. Call display.begin() with the matching address.

Questions and answers

Where do the speed and heading come from?

From the GPS module itself, in its RMC sentence once a second: the sketch reads fix.speed_kph() and fix.heading() from NeoGPS. Below 2 km/h the heading shows --- because a parked receiver's heading wanders.

How does it measure distance?

It adds the distance between each second's position and the last with NeoGPS's DistanceKm(). Steps slower than 2 km/h are not counted, so GPS jitter does not grow the trip while parked. At 36 km/h that is 10 m a second.

Why EEPROMWearLevel instead of EEPROM.put?

An ATmega328P EEPROM cell is rated for about 100,000 writes, so saving every 100 m to one address would wear it out in about 10,000 km. EEPROMWearLevel writes each new value to the next free bytes of a stretch, spreading the wear. A power cut loses at most the last 100 m.

Why does it wait before drawing the screen?

The NEO-6M sends RMC and five more sentences, about half a second of text, and SoftwareSerial buffers only 64 characters. The sketch waits until the module has been quiet for 20 ms before drawing, so no sentence is lost.

Change it and keep it

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