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] datalines 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
- 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
| Qty | Part | Notes |
|---|---|---|
| 1 | ESP32 dev board (classic ESP32 / WROOM-32) | The S3 and C3 also have TWAI; adjust the pins. |
| 1 | SN65HVD230 CAN transceiver board | 3.3 V transceiver, so no level shifting is needed. |
| 1 | OBD-II male connector with flying leads (or an OBD-to-DB9 cable) | Only pins 4/5, 6 and 14 are used. |
| 1 | Laptop + USB cable | Powers the sniffer and shows the output. |
| Optional | A second ESP32 + transceiver | For a bench bus to test on before touching a car. |
Wiring
| From | To |
|---|---|
| ESP32 GPIO 5 | Transceiver CTX (D) |
| ESP32 GPIO 4 | Transceiver CRX (R) |
| ESP32 3V3 / GND | Transceiver 3V3 / GND |
| Transceiver CANH | OBD-II pin 6 |
| Transceiver CANL | OBD-II pin 14 |
| Transceiver GND | OBD-II pin 5 (signal ground) |
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
- 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 withLISTEN_ONLY = falseso it acknowledges the frames, and confirm the7DFlines appear. - Set listen-only back to
trueand remove the termination resistor. - 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.
- 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.
- Optional OBD-II requests: set
LISTEN_ONLY = falseandOBD_POLL = trueto request RPM (PID 0x0C) and speed (PID 0x0D). This sends standard diagnostic requests to ID 0x7DF — do it only when stationary.
Testing checklist
- Bench: node A's
7DFrequests 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
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.