PRJ-005 Planned

CAN Bus Sniffer

A low-cost CAN bus analyzer for reading and decoding automotive OBD-II data.

Every modern car is a network: dozens of ECUs exchanging thousands of CAN messages per second. This project builds a pocket-sized CAN bus sniffer from an ESP32 and a ₹150 transceiver board. It plugs into the OBD-II port, listens without transmitting, and prints every frame to your laptop in the same format as Linux candump — ready to filter, log and decode.

What you'll build

  • Receives every frame at 500 kbit/s and prints ID [length] data lines over USB.
  • Listen-only mode by default: the sniffer never transmits — not even the acknowledge bit — so it cannot disturb the car.
  • Bus statistics every 5 seconds: frames per interval, controller state, missed frames and error counters.
  • An optional OBD-II mode that asks the engine ECU for RPM and speed, and decodes the replies.

How it works

Vehicle bus CAN_H / CAN_L 500 kbit/s SN65HVD230 differential → 3.3 V logic ESP32 TWAI listen-only controller USB serial candump-style log on laptop
The ESP32 already contains a CAN controller (Espressif calls it TWAI); it only needs a transceiver to reach the bus.
  • Controller vs transceiver: the ESP32's TWAI controller handles the protocol — framing, CRC, arbitration and error counting. The SN65HVD230 transceiver converts its 3.3 V logic signals to and from the differential CANH/CANL voltages on the bus.
  • Why listen-only? A normal CAN node acknowledges every frame it receives correctly. In listen-only mode the controller stays completely silent, which is the safe way to observe a vehicle network.
  • Hardware filter: the sketch accepts all IDs. The TWAI acceptance filter can narrow this later, so the ESP32 only sees the messages you care about.

New to CAN? Read Understanding CAN Bus Communication first — it explains frames, IDs, arbitration and why the bus needs 120 Ω terminations.

Parts list

QtyPartNotes
1ESP32 dev board (classic ESP32 / WROOM-32)The S3 and C3 also have TWAI; adjust the pins.
1SN65HVD230 CAN transceiver board3.3 V transceiver, so no level shifting is needed.
1OBD-II male connector with flying leads (or an OBD-to-DB9 cable)Only pins 4/5, 6 and 14 are used.
1Laptop + USB cablePowers the sniffer and shows the output.
OptionalA second ESP32 + transceiverFor a bench bus to test on before touching a car.

Wiring

ESP32 DevKit 3V3 GND GPIO5 TX GPIO4 RX SN65HVD230 3V3 GND CTX CRX CANH CANL GND OBD-II plug pin 6 CAN_H pin 14 CAN_L pin 5 SGND Twist CANH/CANL together. Remove the module’s 120 Ω resistor when plugging into a car (the vehicle bus is already terminated). Power the ESP32 from the laptop’s USB, not from the car.
Three wires to the car: CAN high, CAN low and signal ground. The sniffer is powered from the laptop, not from the car.
FromTo
ESP32 GPIO 5Transceiver CTX (D)
ESP32 GPIO 4Transceiver CRX (R)
ESP32 3V3 / GNDTransceiver 3V3 / GND
Transceiver CANHOBD-II pin 6
Transceiver CANLOBD-II pin 14
Transceiver GNDOBD-II pin 5 (signal ground)
Remove the 120 Ω resistor for in-car use. Most SN65HVD230 boards include a termination resistor (often marked R2 or 121). A car's bus is already terminated at both ends; a third terminator lowers the bus impedance. Lift or desolder it when sniffing a vehicle — keep it for a two-node bench bus.

The code

This uses the TWAI driver built into the Espressif esp32 board package — no extra libraries. Upload, then open the Serial Monitor at 115200 baud.

/*  EFury Labs — PRJ-005 CAN Bus Sniffer
    ESP32 (built-in TWAI CAN controller) + SN65HVD230 3.3 V CAN transceiver.
    No extra libraries: the TWAI driver is part of the ESP32 board package.
    Output looks like Linux candump:   (12.345)  can0  7E8   [8]  04 41 0C 1A F8 00 00 00   */

#include "driver/twai.h"

const gpio_num_t CAN_TX = GPIO_NUM_5;      // → transceiver CTX / D
const gpio_num_t CAN_RX = GPIO_NUM_4;      // ← transceiver CRX / R

// true  = listen only: never transmits, never ACKs. Use this on a real vehicle.
// false = normal mode: needed on a 2-node bench bus and for OBD-II requests below.
const bool LISTEN_ONLY = true;
const bool OBD_POLL    = false;            // send RPM / speed requests (needs LISTEN_ONLY = false)

unsigned long frames = 0, lastStats = 0, lastPoll = 0;
uint8_t nextPid = 0x0C;

void printFrame(const twai_message_t& m) {
  char line[80];
  int n = snprintf(line, sizeof line, "(%9.3f)  can0  ", millis() / 1000.0);
  n += m.extd ? snprintf(line + n, sizeof line - n, "%08lX", (unsigned long)m.identifier)
              : snprintf(line + n, sizeof line - n, "%03lX", (unsigned long)m.identifier);
  n += snprintf(line + n, sizeof line - n, "   [%d] ", m.data_length_code);
  Serial.print(line);
  if (m.rtr) { Serial.println(" remote request"); return; }
  for (int i = 0; i < m.data_length_code; i++) {
    snprintf(line, sizeof line, " %02X", m.data[i]);
    Serial.print(line);
  }
  Serial.println();
}

void decodeObd(const twai_message_t& m) {   // replies come from 0x7E8…0x7EF
  if (m.identifier < 0x7E8 || m.identifier > 0x7EF || m.data[1] != 0x41) return;
  if (m.data[2] == 0x0C) Serial.printf("   → engine speed %u rpm\n", (m.data[3] * 256u + m.data[4]) / 4u);
  if (m.data[2] == 0x0D) Serial.printf("   → vehicle speed %u km/h\n", m.data[3]);
}

void requestPid(uint8_t pid) {              // functional request to all ECUs: ID 0x7DF
  twai_message_t q = {};
  q.identifier = 0x7DF;
  q.data_length_code = 8;
  uint8_t d[8] = { 0x02, 0x01, pid, 0x55, 0x55, 0x55, 0x55, 0x55 };   // 2 bytes: mode 01, PID
  memcpy(q.data, d, 8);
  twai_transmit(&q, pdMS_TO_TICKS(20));
}

void setup() {
  Serial.begin(115200);
  delay(300);

  twai_general_config_t g = TWAI_GENERAL_CONFIG_DEFAULT(CAN_TX, CAN_RX,
      LISTEN_ONLY ? TWAI_MODE_LISTEN_ONLY : TWAI_MODE_NORMAL);
  g.rx_queue_len = 64;                                   // buffer bursts
  twai_timing_config_t t = TWAI_TIMING_CONFIG_500KBITS(); // most cars' powertrain / OBD bus
  twai_filter_config_t f = TWAI_FILTER_CONFIG_ACCEPT_ALL();

  if (twai_driver_install(&g, &t, &f) != ESP_OK || twai_start() != ESP_OK) {
    Serial.println("CAN driver failed to start — check pins and board package.");
    while (true) delay(1000);
  }
  Serial.println(LISTEN_ONLY ? "Sniffing (listen-only) at 500 kbit/s…" : "CAN normal mode at 500 kbit/s…");
}

void loop() {
  twai_message_t m;
  while (twai_receive(&m, pdMS_TO_TICKS(10)) == ESP_OK) {
    frames++;
    printFrame(m);
    decodeObd(m);
  }

  if (OBD_POLL && !LISTEN_ONLY && millis() - lastPoll >= 500) {
    lastPoll = millis();
    requestPid(nextPid);
    nextPid = (nextPid == 0x0C) ? 0x0D : 0x0C;           // alternate RPM / speed
  }

  if (millis() - lastStats >= 5000) {                    // health line every 5 s
    lastStats = millis();
    twai_status_info_t s;
    twai_get_status_info(&s);
    const char* st[] = { "stopped", "running", "BUS-OFF", "recovering" };
    Serial.printf("-- %lu frames/5s | state %s | rx missed %lu | bus errors %lu | TEC %lu REC %lu --\n",
                  frames, st[s.state], (unsigned long)s.rx_missed_count, (unsigned long)s.bus_error_count,
                  (unsigned long)s.tx_error_counter, (unsigned long)s.rx_error_counter);
    frames = 0;
  }
}

On the two-node bench setup below, the output looks like this. In a car the IDs and data depend entirely on the vehicle.

(   12.345)  can0  7DF   [8]  02 01 0C 55 55 55 55 55
(   12.845)  can0  7DF   [8]  02 01 0D 55 55 55 55 55
-- 10 frames/5s | state running | rx missed 0 | bus errors 0 | TEC 0 REC 0 --

Build steps

  1. Bench test first. Build two nodes (two ESP32s + two transceivers, both with their 120 Ω resistors), and connect CANH–CANH and CANL–CANL. Flash node A as a sender (LISTEN_ONLY = false, OBD_POLL = true), which sends an OBD request every 0.5 s. Flash node B as the sniffer with LISTEN_ONLY = false so it acknowledges the frames, and confirm the 7DF lines appear.
  2. Set listen-only back to true and remove the termination resistor.
  3. In the car (engine off, ignition on): find the OBD-II port under the dashboard, plug in, and watch the output. Thousands of frames per 5 s is normal on a powertrain bus.
  4. Log it by capturing the serial output to a file (for example with a terminal program), then sort and diff it while you press pedals or switches to see which IDs change.
  5. Optional OBD-II requests: set LISTEN_ONLY = false and OBD_POLL = true to request RPM (PID 0x0C) and speed (PID 0x0D). This sends standard diagnostic requests to ID 0x7DF — do it only when stationary.
Stay safe: read-only sniffing is low risk, but never transmit arbitrary frames on a vehicle bus, never experiment while driving, and keep wiring away from the pedals. If the car shows any warning, unplug immediately. Not all vehicles expose CAN on the OBD port at 500 kbit/s — some use 250 kbit/s, and some gateways block raw traffic.

Testing checklist

  • Bench: node A's 7DF requests appear on the sniffer with the correct data.
  • Bench: switch the sniffer to listen-only. Nothing acknowledges node A any more, so its TEC climbs and it may go error-passive. That proves the sniffer is really silent.
  • Car: the state stays running and bus errors stay at 0.
  • Car: the frame count changes when the ignition goes from accessory to on.
  • Wrong bit rate (try 250 kbit/s): nothing is received or errors climb — confirming the setting matters.

Results

Build log

Status: planned. Bench test captures, a short anonymised vehicle log, photos and the video will be added after the build.

Ideas for version 2

  • SLCAN / Lawicel protocol over USB, so the sniffer works with SavvyCAN and python-can.
  • microSD logging for drives without a laptop.
  • DBC decoding on the laptop with Python cantools.
  • Wi-Fi live view using the web-server approach from the weather station.
← All projects