Built-in project · Arduino Uno R3

OBD-II style gauge cluster on an Arduino Uno

Three knobs stand in for a car's engine sensors, and a color TFT shows a tachometer, speed in mph and coolant temperature that turns red above 110 C, repainting only what changed. The whole build, an Arduino Uno and 4 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 $43. On Mokxi they're free.

The circuit runs here once the page has loaded.

What you'll learn

  • Displays
  • Analog
  • Reading a knob with an analog pin
  • Using the TFT display
  • Difficulty: Intermediate
  • Time: About 30 minutes
  • Board: Arduino Uno R3
  • Parts: 6

How it works

Three knobs stand in for a car's engine sensors, and a color TFT shows a tachometer, speed in mph and coolant temperature that turns red above 110 C, repainting only what changed.

Arduino Uno R3 runs the sketch below. The circuit around it: TFT display, 240x320, Potentiometer. analogRead measures the voltage on pin as a number. delay waits 50 milliseconds. Serial.begin opens the serial port at 9600 baud.

Parts list

6 parts, plus the jumper wires. They retail for about $43; 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
TFT display, 240x320 pin SCKArduino Uno R3 pin 13
TFT display, 240x320 pin MOSIArduino Uno R3 pin 11
TFT display, 240x320 pin CSArduino Uno R3 pin 10
TFT display, 240x320 pin DCArduino Uno R3 pin 9
TFT display, 240x320 pin VCCArduino Uno R3 pin 5V
TFT display, 240x320 pin RSTArduino Uno R3 pin 5V
TFT display, 240x320 pin LEDArduino Uno R3 pin 5V
Potentiometer (1) pin 3Arduino Uno R3 pin 5V
Potentiometer (2) pin 3Arduino Uno R3 pin 5V
Potentiometer (3) pin 3Arduino Uno R3 pin 5V
TFT display, 240x320 pin GNDArduino Uno R3 pin GND
Potentiometer (1) pin 1Arduino Uno R3 pin GND
Potentiometer (2) pin 1Arduino Uno R3 pin GND
Potentiometer (3) pin 1Arduino Uno R3 pin GND
Potentiometer (1) pin 2Arduino Uno R3 pin A0
Potentiometer (2) pin 2Arduino Uno R3 pin A1
Potentiometer (3) pin 2Arduino Uno R3 pin A2

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
// OBD-II style gauge cluster: a tachometer bar, a speedometer and a coolant
// temperature gauge on a 2.4 inch ILI9341 TFT, with three knobs standing in
// for the engine's sensors.
//
// Wiring, panel to board (the same as the Snake example):
//   CS   -> pin 10      SCK  -> pin 13      VCC -> 5 V    GND -> GND
//   DC   -> pin 9       MOSI -> pin 11      RST -> 5 V    LED -> 5 V
// Knobs (10 k potentiometers), each across 5 V and GND:
//   engine speed (RPM)  wiper -> A0
//   road speed          wiper -> A1
//   coolant temperature wiper -> A2
//
// 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). Build it on the bench with a
// bench supply or a 12 V battery, fused. A real vehicle install needs
// isolation or a proper automotive-rated front end: a fuse, TVS and load-dump
// protection, clamps, and a divider or regulator to bring the car's 12 to
// 14.7 V (with spikes far above that) down to the Uno's 5 V logic.
//
// # The scaling
//
// analogRead() counts 0 to 1023 for 0 to 5 V on the wiper, and map() turns
// that count into the unit each gauge shows:
//   RPM      0 to 8000, in steps of 50 (map to 0..160, then times 50)
//   speed    0 to 160 mph, with km/h worked out under it (1 mph = 1.609 km/h)
//   coolant  40 to 130 C, with F worked out under it (F = C x 9 / 5 + 32)
// A real knob's reading wanders by a count or two, so a new reading is only
// taken when it moves by 3 counts or more. Without that a gauge sitting on a
// boundary would flick between two numbers and repaint all the time.
//
// # Redrawing only what changed
//
// The Uno has no SPI peripheral in Mokxi, so every bit of every pixel is a
// pin change made by hand, and clearing the whole screen takes a few
// seconds. So the screen is cleared and labeled once in setup(), and after
// that each gauge remembers what it last drew and repaints only the
// difference:
//   - the tach segments that turned on or off, not the whole bar
//   - in a number, only the digits that changed (speed 61 to 62 repaints
//     one character); text drawn with a background color paints over the old
//     digit, so nothing is cleared first and nothing flickers
//   - the coolant bar only between its old and new ends, unless its color
//     zone changed
// A steady engine sends no pixels at all.
//
// # The color zones
//
//   tach     green under 5000 RPM, yellow to 6500, red from 6500 (an example
//            redline; every engine has its own)
//   coolant  blue COLD under 90 C (still warming up), green NORMAL 90 to
//            105 C (a typical thermostat holds about 90 to 105 C), yellow HOT
//            106 to 110 C, red OVERHEAT above 110 C
// The cooling system is pressurized, so coolant boils well above 100 C, but
// past about 110 C on most cars it is time to pull over and let it cool.
//
// # How a scan tool gets the same numbers
//
// As an example only: an OBD-II scan tool asks the engine computer for mode
// 01 PIDs over the diagnostic port. PID 0x0C is engine RPM (two bytes A and
// B, RPM = (256 x A + B) / 4), PID 0x0D is vehicle speed (one byte, 0 to 255
// km/h, so the car reports km/h and the tool converts to mph), and PID 0x05
// is coolant temperature (one byte, C = A - 40, so -40 to 215 C). Here the
// knobs stand in for those answers.
//
// Press Run, give the screen a few seconds to clear, then turn the knobs.

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>

const uint8_t TFT_CS = 10;
const uint8_t TFT_DC = 9;
const uint8_t RPM_PIN = A0;
const uint8_t SPEED_PIN = A1;
const uint8_t COOLANT_PIN = A2;

Adafruit_ILI9341 tft(TFT_CS, TFT_DC);  // RST is tied to 5 V

// Colors, RGB565.
const uint16_t BLACK = ILI9341_BLACK;
const uint16_t WHITE = ILI9341_WHITE;
const uint16_t OFF = 0x2104;     // an unlit segment, dark grey
const uint16_t HEADER = 0x18e3;  // the title strip
const uint16_t GREEN = ILI9341_GREEN;
const uint16_t YELLOW = ILI9341_YELLOW;
const uint16_t RED = ILI9341_RED;
const uint16_t BLUE = 0x041f;

// The tachometer: 32 segments of 250 RPM, 8 pixels wide with a 1 pixel gap.
const int16_t TACH_X = 16;
const int16_t TACH_Y = 40;
const int16_t TACH_H = 40;
const int16_t SEG_PITCH = 9;
const uint8_t SEGMENTS = 32;
const int RPM_PER_SEG = 250;
const int YELLOW_RPM = 5000;
const int REDLINE_RPM = 6500;

// The coolant gauge, on the right half under the tach.
const int16_t COOL_X = 176;
const int16_t COOL_W = 136;
const int16_t STATUS_Y = 172;
const int16_t BAR_Y = 202;
const int16_t BAR_H = 16;
const int COOL_MIN = 40;
const int COOL_MAX = 130;

/**
 * A number on the screen that remembers its digits, so a new value repaints
 * only the characters that differ from what is already there.
 */
struct Readout {
  int16_t x, y;
  uint8_t size, width;
  uint16_t color;
  char shown[6];

  void show(long value, uint16_t newColor) {
    char text[6];
    for (uint8_t i = 0; i < width; i++) text[i] = ' ';
    // the digits, right-aligned, last one first
    uint8_t at = width;
    do {
      text[--at] = (char)('0' + value % 10);
      value /= 10;
    } while (value > 0 && at > 0);
    bool recolor = newColor != color;
    color = newColor;
    for (uint8_t i = 0; i < width; i++) {
      if (text[i] == shown[i] && !recolor) continue;
      tft.drawChar(x + i * 6 * size, y, text[i], color, BLACK, size);
      shown[i] = text[i];
    }
  }
};

//                    x               y    size width
Readout rpmText = {TACH_X + 4 * 12, 20, 2, 4, WHITE, ""};
Readout mphText = {16, 122, 6, 3, WHITE, ""};
Readout kmhText = {16, 182, 2, 3, WHITE, ""};
Readout coolText = {COOL_X, 120, 3, 3, WHITE, ""};
Readout fText = {COOL_X, 148, 2, 3, WHITE, ""};

// What is on the screen now; -1 means nothing yet, so the first pass draws all.
int shownRpm = -1;
int shownSpeed = -1;
int shownCoolant = -1;
int8_t litSegments = 0;
int8_t shownZone = -1;
int16_t barEnd = 0;

// The last accepted reading of each knob.
int rawRpm = -100;
int rawSpeed = -100;
int rawCoolant = -100;

/** Four readings averaged, then kept only if it moved by 3 counts or more. */
int readKnob(uint8_t pin, int &kept) {
  int sum = 0;
  for (uint8_t i = 0; i < 4; i++) sum += analogRead(pin);
  int now = sum / 4;
  if (abs(now - kept) >= 3) kept = now;
  return kept;
}

uint16_t segmentColor(uint8_t i) {
  int rpm = i * RPM_PER_SEG;
  if (rpm >= REDLINE_RPM) return RED;
  if (rpm >= YELLOW_RPM) return YELLOW;
  return GREEN;
}

/** 0 cold, 1 normal, 2 hot, 3 overheat. */
int8_t coolantZone(int c) {
  if (c > 110) return 3;
  if (c > 105) return 2;
  if (c >= 90) return 1;
  return 0;
}

const uint16_t ZONE_COLOR[] = {BLUE, GREEN, YELLOW, RED};
const char *const ZONE_NAME[] = {"COLD", "NORMAL", "HOT", "OVERHEAT"};

void label(int16_t x, int16_t y, uint8_t size, const char *text) {
  tft.setTextSize(size);
  tft.setCursor(x, y);
  tft.print(text);
}

void drawFrame() {
  tft.fillScreen(BLACK);
  tft.fillRect(0, 0, 320, 16, HEADER);
  // Labels that never change are drawn with no background color: only the
  // lit pixels of each letter go out, a fraction of the character cell.
  tft.setTextColor(WHITE);
  label(6, 4, 1, "OBD-II GAUGES   knobs stand in for the sensors");

  // tach: unlit segments, ticks and the thousands under them
  label(TACH_X, 20, 2, "RPM");
  label(TACH_X + 8 * 4 * SEG_PITCH - 52, 24, 1, "x1000 RPM");
  for (uint8_t i = 0; i < SEGMENTS; i++) {
    tft.fillRect(TACH_X + i * SEG_PITCH, TACH_Y, SEG_PITCH - 1, TACH_H, OFF);
  }
  for (uint8_t k = 0; k <= 8; k++) {
    int16_t x = TACH_X + k * 4 * SEG_PITCH;
    tft.drawFastVLine(x, TACH_Y + TACH_H + 2, 4, WHITE);
    tft.setCursor(x - 2, TACH_Y + TACH_H + 8);
    tft.print(k);
  }
  tft.drawFastHLine(0, 100, 320, OFF);

  // speed
  label(16, 108, 1, "SPEED");
  label(130, 154, 2, "mph");
  label(16 + 4 * 12, 182, 2, "km/h");

  // coolant, with a tick at 110 C where the red starts
  label(COOL_X, 108, 1, "COOLANT");
  label(COOL_X + 4 * 18, 120, 3, "C");
  label(COOL_X + 4 * 12, 148, 2, "F");
  tft.fillRect(COOL_X, BAR_Y, COOL_W, BAR_H, OFF);
  int16_t at110 = COOL_X + (int16_t)map(110, COOL_MIN, COOL_MAX, 0, COOL_W);
  tft.drawFastVLine(at110, BAR_Y + BAR_H + 1, 4, WHITE);
  label(COOL_X, BAR_Y + BAR_H + 6, 1, "40");
  label(at110 - 8, BAR_Y + BAR_H + 6, 1, "110");
}

void showRpm(int rpm) {
  // Only the segments between the old and the new count change.
  int8_t lit = rpm / RPM_PER_SEG;
  while (litSegments < lit) {
    tft.fillRect(TACH_X + litSegments * SEG_PITCH, TACH_Y, SEG_PITCH - 1, TACH_H, segmentColor(litSegments));
    litSegments++;
  }
  while (litSegments > lit) {
    litSegments--;
    tft.fillRect(TACH_X + litSegments * SEG_PITCH, TACH_Y, SEG_PITCH - 1, TACH_H, OFF);
  }
  rpmText.show(rpm, rpm >= REDLINE_RPM ? RED : WHITE);
}

void showSpeed(int mph) {
  mphText.show(mph, WHITE);
  kmhText.show((mph * 1609L + 500) / 1000, WHITE);
}

void showCoolant(int c) {
  int8_t zone = coolantZone(c);
  uint16_t color = ZONE_COLOR[zone];
  coolText.show(c, color);
  fText.show(c * 9L / 5 + 32, WHITE);

  int16_t end = (int16_t)map(c, COOL_MIN, COOL_MAX, 0, COOL_W);
  if (zone != shownZone) {
    // A new color: the status box and the whole bar in it.
    tft.fillRect(COOL_X, STATUS_Y, COOL_W, 24, color);
    const char *name = ZONE_NAME[zone];
    tft.setTextColor(BLACK);
    label(COOL_X + (COOL_W - (int16_t)strlen(name) * 12) / 2, STATUS_Y + 5, 2, name);
    tft.fillRect(COOL_X, BAR_Y, end, BAR_H, color);
    if (barEnd > end) tft.fillRect(COOL_X + end, BAR_Y, barEnd - end, BAR_H, OFF);
    shownZone = zone;
  } else if (end > barEnd) {
    tft.fillRect(COOL_X + barEnd, BAR_Y, end - barEnd, BAR_H, color);
  } else if (end < barEnd) {
    tft.fillRect(COOL_X + end, BAR_Y, barEnd - end, BAR_H, OFF);
  }
  barEnd = end;
}

void setup() {
  Serial.begin(9600);
  tft.begin();
  tft.setRotation(1);  // landscape, 320 x 240
  drawFrame();
  Serial.println("Gauge cluster ready");
}

void loop() {
  int rpm = (int)map(readKnob(RPM_PIN, rawRpm), 0, 1023, 0, 160) * 50;
  int mph = (int)map(readKnob(SPEED_PIN, rawSpeed), 0, 1023, 0, 160);
  int coolant = (int)map(readKnob(COOLANT_PIN, rawCoolant), 0, 1023, COOL_MIN, COOL_MAX);

  bool changed = false;
  if (rpm != shownRpm) {
    showRpm(rpm);
    shownRpm = rpm;
    changed = true;
  }
  if (mph != shownSpeed) {
    showSpeed(mph);
    shownSpeed = mph;
    changed = true;
  }
  if (coolant != shownCoolant) {
    showCoolant(coolant);
    shownCoolant = coolant;
    changed = true;
  }

  if (changed) {
    Serial.print("rpm ");
    Serial.print(rpm);
    Serial.print("  speed ");
    Serial.print(mph);
    Serial.print(" mph ");
    Serial.print((mph * 1609L + 500) / 1000);
    Serial.print(" km/h  coolant ");
    Serial.print(coolant);
    Serial.print(" C ");
    Serial.println(ZONE_NAME[coolantZone(coolant)]);
  }
  delay(50);
}

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);
  tft.begin();
  tft.setRotation(1);  // landscape, 320 x 240
  drawFrame();
  Serial.println("Gauge cluster ready");
}

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() {
  int rpm = (int)map(readKnob(RPM_PIN, rawRpm), 0, 1023, 0, 160) * 50;
  int mph = (int)map(readKnob(SPEED_PIN, rawSpeed), 0, 1023, 0, 160);
  int coolant = (int)map(readKnob(COOLANT_PIN, rawCoolant), 0, 1023, COOL_MIN, COOL_MAX);

  bool changed = false;
  if (rpm != shownRpm) {
    showRpm(rpm);
    shownRpm = rpm;
    changed = true;
  }
  if (mph != shownSpeed) {
    showSpeed(mph);
    shownSpeed = mph;
    changed = true;
  }
  if (coolant != shownCoolant) {
    showCoolant(coolant);
    shownCoolant = coolant;
    changed = true;
  }

  if (changed) {
    Serial.print("rpm ");
    Serial.print(rpm);
    Serial.print("  speed ");
    Serial.print(mph);
    Serial.print(" mph ");
  // ... 8 more lines in the editor

Troubleshooting

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

  • The screen stays white or black. TFT display
    Check the SPI wiring: CS, DC, RST, MOSI, SCK. Power the module at 3.3 V logic, or use level shifting on 5 V boards. Call begin() and set the rotation.
  • Slow drawing or garbled colors. TFT display
    Use hardware SPI pins and avoid redrawing the whole screen. Fill only changed areas. Confirm the library matches ILI9341 and the color order.
  • 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.
  • Upload fails or nothing runs after wiring pins 0 and 1 to a module. Arduino Uno R3
    Pins 0 and 1 are the USB serial port. Move the module to other pins, or unplug it while uploading. Also check the right board is selected.
  • Pins misbehave or the board resets when you power motors or servos from it. Arduino Uno R3
    Each I/O pin gives about 20 mA, 40 mA max. Power motors and servos from their own supply and join its ground to the Uno's GND.

Change it and keep it

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