Built-in project · Arduino Uno R3

CAN bus dash reader on an Arduino Uno

An ECU Uno sends OBD-II style CAN frames over a serial link, and a dash Uno filters them by ID and shows engine RPM, speed and coolant temperature on an LCD. The whole build, an Arduino Uno and 2 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.

Advanced About 25 minutes to build and run. Runs in your browser. Free, and no account needed.

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

The circuit runs here once the page has loaded.

What you'll learn

  • Multi-board
  • Displays
  • Controllers
  • Reading a knob with an analog pin
  • Difficulty: Advanced
  • Time: About 25 minutes
  • Board: Arduino Uno R3
  • Parts: 5

How it works

An ECU Uno sends OBD-II style CAN frames over a serial link, and a dash Uno filters them by ID and shows engine RPM, speed and coolant temperature on an LCD.

Arduino Uno R3 runs the sketch below. The circuit around it: Character LCD with I2C backpack, Potentiometer. analogRead measures the voltage on THROTTLE 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

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

Kits that can build it

These 3 starter kits have every part in the circuit. Each kit's page lists what else it can build.

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
Arduino Uno R3 (2) pin GNDArduino Uno R3 (1) pin GND
Arduino Uno R3 (2) pin 9Arduino Uno R3 (1) pin 8
Character LCD with I2C backpack pin VCCArduino Uno R3 (1) pin 5V
Character LCD with I2C backpack pin GNDArduino Uno R3 (1) pin GND
Character LCD with I2C backpack pin SDAArduino Uno R3 (1) pin A4
Character LCD with I2C backpack pin SCLArduino Uno R3 (1) pin A5
Potentiometer pin 3Arduino Uno R3 (2) pin 5V
Potentiometer pin 1Arduino Uno R3 (2) pin GND
Potentiometer pin 2Arduino Uno R3 (2) pin A0

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
// CAN bus dash reader, the dash side: reads OBD-II style CAN frames, keeps
// only the engine ECU's replies, decodes RPM, speed and coolant temperature,
// and shows them on a 16x2 I2C LCD.
//
// SAFETY. This is a teaching simulation. Never connect a project like this to
// a running car's safety systems (brakes, airbags, steering, ECU wiring, or
// anything that can strand or hurt someone). Build it on the bench from a bench
// supply or a fused 12 V battery, and for any real vehicle install use a proper
// automotive-rated front end: isolation, clamps, TVS load-dump protection and
// a fuse. The car's 12 V never goes straight into a 5 V pin.
//
//   pin 8   the link in: from the ECU Uno's pin 9
//   GND     to the ECU Uno's GND
//   LCD     SDA -> A4    SCL -> A5    VCC -> 5 V    GND -> GND
//
// On a real car the frames come off CAN through a transceiver (MCP2551,
// TJA1050) and a controller (MCP2515 on SPI) that checks each frame's CRC.
// Mokxi has no CAN controller part, so here they arrive as SLCAN text over a
// serial link (see the ECU sketch for the format). The decoding is the same.
//
// What the dash does with each frame:
//   1. Checks it is well formed: "t", 3 hex digits of ID, a DLC of 0 to 8,
//      and exactly that many bytes. Anything else is corrupt and dropped,
//      as a CAN controller drops a frame with a bad CRC.
//   2. Filters by ID: only 0x7E8, the engine ECU's reply (an example ID).
//      A real bus carries dozens of IDs; a dash ignores all the others.
//   3. Checks it is a mode 01 reply (second byte 0x41) and decodes the PID:
//        0x0C  RPM     = (256 * A + B) / 4
//        0x0D  speed   = A km/h
//        0x05  coolant = A - 40 degC
//
// A warmed-up engine runs its coolant at about 90 to 105 degC. Over 110 degC
// the dash shows HOT. If no engine frame comes for 2 seconds it shows
// "no ECU data", as a real gauge cluster flags a lost bus.
//
// You can also type a frame into this board's serial monitor, for example
// t7E8804410C1AF8000000 for 1726 RPM, and the dash decodes it the same way.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <SoftwareSerial.h>

SoftwareSerial link(8, 9);      // RX on pin 8 from the ECU; pin 9 not used
LiquidCrystal_I2C lcd(0x27, 16, 2);

const uint16_t ENGINE_ID = 0x7E8;
const int HOT_C = 110;

unsigned int rpm = 0;
unsigned int speedKmh = 0;
int coolantC = 0;
bool haveRpm = false, haveSpeed = false, haveCoolant = false;

unsigned long lastFrame = 0;    // when the last engine frame came in
unsigned long lastShow = 0;
unsigned long ignored = 0;
unsigned long corrupt = 0;

char linkLine[32];
uint8_t linkLen = 0;
char typedLine[32];
uint8_t typedLen = 0;

// One hex digit's value, or -1 when it is not one.
int hexDigit(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  if (c >= 'A' && c <= 'F') return c - 'A' + 10;
  if (c >= 'a' && c <= 'f') return c - 'a' + 10;
  return -1;
}

// Reads one SLCAN line into an ID, a DLC and the data. False when corrupt.
bool parseFrame(const char *s, uint8_t len, uint16_t &id, uint8_t &dlc, uint8_t *data) {
  if (len < 5 || s[0] != 't') return false;
  id = 0;
  for (uint8_t i = 1; i <= 3; i++) {
    int d = hexDigit(s[i]);
    if (d < 0) return false;
    id = (id << 4) | d;
  }
  if (id > 0x7FF) return false;  // 11 bits at most
  int n = hexDigit(s[4]);
  if (n < 0 || n > 8) return false;
  dlc = n;
  if (len != 5 + 2 * dlc) return false;
  for (uint8_t i = 0; i < dlc; i++) {
    int hi = hexDigit(s[5 + 2 * i]);
    int lo = hexDigit(s[6 + 2 * i]);
    if (hi < 0 || lo < 0) return false;
    data[i] = (hi << 4) | lo;
  }
  return true;
}

// Filter, check and decode one line. `typed` frames are reported one by one.
void handleLine(const char *s, uint8_t len, bool typed) {
  uint16_t id;
  uint8_t dlc;
  uint8_t data[8];
  if (!parseFrame(s, len, id, dlc, data)) {
    corrupt++;
    Serial.print("dropped a corrupt frame: ");
    Serial.println(s);
    return;
  }
  if (id != ENGINE_ID) {
    ignored++;
    Serial.print("ignored ID 0x");
    Serial.println(id, HEX);
    return;
  }
  // A mode 01 reply: length byte, 0x41, the PID, then A (and B).
  if (dlc < 4 || data[1] != 0x41 || data[0] < 3 || data[0] + 1 > dlc) {
    ignored++;
    Serial.println("ignored: not a mode 01 reply");
    return;
  }
  uint8_t pid = data[2], a = data[3], b = data[4];
  if (pid == 0x0C && data[0] >= 4) {
    rpm = ((unsigned int)a * 256 + b) / 4;
    haveRpm = true;
    if (typed) { Serial.print("PID 0C: "); Serial.print(rpm); Serial.println(" rpm"); }
  } else if (pid == 0x0D) {
    speedKmh = a;
    haveSpeed = true;
    if (typed) { Serial.print("PID 0D: "); Serial.print(speedKmh); Serial.println(" km/h"); }
  } else if (pid == 0x05) {
    coolantC = (int)a - 40;
    haveCoolant = true;
    if (typed) { Serial.print("PID 05: "); Serial.print(coolantC); Serial.println(" C"); }
  } else {
    ignored++;
    Serial.print("ignored PID 0x");
    Serial.println(pid, HEX);
    return;
  }
  lastFrame = millis();
}

// Gather characters into a line; a carriage return or newline ends it.
void feed(char c, char *line, uint8_t &len, bool typed) {
  if (c == '\r' || c == '\n') {
    if (len > 0) {
      line[len] = 0;
      handleLine(line, len, typed);
    }
    len = 0;
  } else if (len < 31) {
    line[len++] = c;
  }
}

// Read whatever the link has brought in so far. The link holds only 64
// characters, about three frames, and redrawing the LCD over I2C takes a
// good fraction of a second, so the dash keeps reading the link between
// characters. A real MCP2515 holds just two frames, so a real dash has to
// keep up too (it uses the chip's INT pin to know when one is waiting).
void pollLink() {
  while (link.available()) feed(link.read(), linkLine, linkLen, false);
}

// One LCD row, a character at a time, reading the link in between.
void lcdRow(uint8_t r, const char *text) {
  lcd.setCursor(0, r);
  for (uint8_t i = 0; text[i]; i++) {
    lcd.write(text[i]);
    pollLink();
  }
}

void show() {
  char row[17];
  bool fresh = lastFrame != 0 && millis() - lastFrame < 2000;
  if (!fresh) {
    lcdRow(0, "CAN dash        ");
    lcdRow(1, "no ECU data     ");
    Serial.println("no ECU data");
    return;
  }
  snprintf(row, sizeof row, "%5urpm %3ukm/h", haveRpm ? rpm : 0, haveSpeed ? speedKmh : 0);
  lcdRow(0, row);
  snprintf(row, sizeof row, "Cool %4d C %s", haveCoolant ? coolantC : 0,
           haveCoolant && coolantC > HOT_C ? "HOT" : "   ");
  lcdRow(1, row);

  Serial.print("RPM ");
  Serial.print(rpm);
  Serial.print("  speed ");
  Serial.print(speedKmh);
  Serial.print(" km/h  coolant ");
  Serial.print(coolantC);
  Serial.print(" C");
  if (haveCoolant && coolantC > HOT_C) Serial.print("  HOT");
  Serial.println();
}

void setup() {
  Serial.begin(9600);
  link.begin(9600);
  lcd.init();
  lcd.backlight();
  lcd.print("CAN dash reader");
  Serial.println("Dash: listening for ID 0x7E8 on pin 8");
}

void loop() {
  pollLink();
  while (Serial.available()) feed(Serial.read(), typedLine, typedLen, true);
  if (millis() - lastShow >= 500) {
    lastShow = millis();
    show();
  }
}
sketch.ino · Arduino Uno R3
// CAN bus dash reader, the ECU side: a pretend engine computer that sends
// OBD-II style CAN frames for engine speed, road speed and coolant temperature.
//
// SAFETY. This is a teaching simulation. Never connect a project like this to
// a running car's safety systems (brakes, airbags, steering, ECU wiring, or
// anything that can strand or hurt someone). Build it on the bench from a bench
// supply or a fused 12 V battery, and for any real vehicle install use a proper
// automotive-rated front end: isolation, clamps, TVS load-dump protection and
// a fuse. The car's 12 V never goes straight into a 5 V pin.
//
//   pin 9   the link out: to the dash Uno's pin 8
//   GND     to the dash Uno's GND (a link needs a shared ground)
//   A0      the throttle knob's wiper; its ends on 5 V and GND
//
// A real car carries these frames on CAN: two wires, CANH and CANL, driven as
// a difference by a transceiver (MCP2551, TJA1050) with a 120 ohm terminator
// at each end of the bus, usually 500 kbit/s on OBD-II, fed by a controller
// (MCP2515 over SPI) that adds the CRC and handles arbitration. Mokxi has no
// CAN controller part, so this sketch sends the same frames as text over a
// plain serial link at 9600 baud, in the SLCAN format real USB-to-CAN
// adapters use:
//
//   t7E8804410C1AF8000000
//   t      a standard frame with an 11-bit ID
//   7E8    the ID: 0x7E8 is the engine ECU's OBD-II reply (an example ID)
//   8      the DLC: 8 data bytes
//   04 41 0C 1A F8 00 00 00
//          04 bytes follow, 41 = reply to mode 01, PID 0C (engine RPM),
//          A = 0x1A, B = 0xF8, and padding to fill the 8 bytes
//
// A real tester asks first (on ID 0x7DF) and the ECU answers. To keep this
// one-way, the ECU sends its answers on its own, a few times a second.
//
//   PID 0x0C  engine RPM    = (256 * A + B) / 4
//   PID 0x0D  vehicle speed = A km/h
//   PID 0x05  coolant       = A - 40 degC
//
// Turn the knob for more RPM. The speed sweeps 0 to 120 km/h and back on its
// own, and the coolant warms from 20 degC to a steady 90 degC, the way an
// engine with a thermostat does after a cold start. Once a second the ECU also
// sends a frame from another module (ID 0x3E9, made up for this example), so
// the dash has something on the bus to filter out, as on a real car.

#include <SoftwareSerial.h>

SoftwareSerial link(8, 9);      // TX on pin 9; pin 8 is not used here

const int THROTTLE = A0;

unsigned int speedKmh = 0;      // 0 to 120 and back
int speedStep = 1;
int coolantC = 20;              // warms to 90
unsigned int rpm = 800;

unsigned long lastSend = 0;
unsigned long lastWarm = 0;
unsigned long lastReport = 0;
uint8_t next = 0;               // which PID goes next: 0, 1 or 2
uint8_t toShow = 0;             // how many more frames to print

// One standard CAN frame in SLCAN text: "t", 3 hex digits of ID, the DLC,
// then two hex digits a byte, ended with a carriage return.
void sendFrame(uint16_t id, const uint8_t *data, uint8_t dlc, bool show) {
  char line[24];
  const char *hex = "0123456789ABCDEF";
  uint8_t n = 0;
  line[n++] = 't';
  line[n++] = hex[(id >> 8) & 0x7];
  line[n++] = hex[(id >> 4) & 0xF];
  line[n++] = hex[id & 0xF];
  line[n++] = hex[dlc];
  for (uint8_t i = 0; i < dlc; i++) {
    line[n++] = hex[data[i] >> 4];
    line[n++] = hex[data[i] & 0xF];
  }
  line[n] = 0;
  link.print(line);
  link.write('\r');
  if (show) {
    Serial.print("sent ");
    Serial.println(line);
  }
}

// A mode 01 reply from the engine ECU: 0x7E8, 8 bytes, padded with zeros.
void sendPid(uint8_t pid, uint8_t a, uint8_t b, uint8_t count, bool show) {
  uint8_t data[8] = {0, 0, 0, 0, 0, 0, 0, 0};
  data[0] = 2 + count;          // bytes that follow: 41, the PID, the data
  data[1] = 0x41;               // 0x40 + mode 01: "here is your answer"
  data[2] = pid;
  data[3] = a;
  if (count > 1) data[4] = b;
  sendFrame(0x7E8, data, 8, show);
}

void setup() {
  Serial.begin(9600);
  link.begin(9600);
  Serial.println("ECU: sending OBD-II style frames on pin 9");
}

void loop() {
  // Every quarter second: the coolant warms a degree (up to 90 degC), the
  // road speed moves 2 km/h along its 0 to 120 km/h sweep, and the knob,
  // which is the throttle, sets the RPM: idle 800, fully open 6000.
  if (millis() - lastWarm >= 250) {
    lastWarm = millis();
    if (coolantC < 90) coolantC++;
    speedKmh += speedStep * 2;
    if (speedKmh >= 120) speedStep = -1;
    if (speedKmh == 0) speedStep = 1;
    rpm = 800 + (unsigned long)analogRead(THROTTLE) * 5200 / 1023;
  }

  // Once a second: print the next three frames and what they carry, and send
  // a frame from another module on the same bus (an example ID).
  if (millis() - lastReport >= 1000) {
    lastReport = millis();
    toShow = 3;
    uint8_t body[2] = {0x01, 0x00};
    sendFrame(0x3E9, body, 2, true);
    Serial.print("ECU: ");
    Serial.print(rpm);
    Serial.print(" rpm, ");
    Serial.print(speedKmh);
    Serial.print(" km/h, coolant ");
    Serial.print(coolantC);
    Serial.println(" C");
  }

  // One frame every 80 ms, the three PIDs in turn. Spacing them out gives the
  // dash time to read each one, as a real bus leaves gaps between frames.
  if (millis() - lastSend >= 80) {
    lastSend = millis();
    bool show = toShow > 0;
    if (show) toShow--;
    if (next == 0) {
      unsigned int raw = rpm * 4;   // RPM = (256 * A + B) / 4
      sendPid(0x0C, raw >> 8, raw & 0xFF, 2, show);
    } else if (next == 1) {
      sendPid(0x0D, speedKmh, 0, 1, show);
    } else {
      sendPid(0x05, coolantC + 40, 0, 1, show);
    }
    next = (next + 1) % 3;
  }
}

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);
  link.begin(9600);
  lcd.init();
  lcd.backlight();
  lcd.print("CAN dash reader");
  Serial.println("Dash: listening for ID 0x7E8 on pin 8");
}

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() {
  pollLink();
  while (Serial.available()) feed(Serial.read(), typedLine, typedLen, true);
  if (millis() - lastShow >= 500) {
    lastShow = millis();
    show();
  }
}

Troubleshooting

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

  • 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 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.