Skip to content

OBD-II & Automotive CAN

OBD-II (On-Board Diagnostics, second generation) is the most widely deployed CAN bus application on the planet — every passenger vehicle sold in the US since 1996 and in the EU since 2001 carries an OBD-II port. Under the hood, OBD-II is just CAN with a set of standardized message IDs and data formats defined by ISO 15765-4 and SAE J1979. The RS485 CAN HAT speaks the same electrical protocol, which means a Raspberry Pi can participate directly on a vehicle’s diagnostic CAN bus.

This guide covers two complementary use cases:

  1. Scan tool — the Pi reads live data from a vehicle (RPM, speed, coolant temperature, diagnostic trouble codes).
  2. ECU emulator — the Pi responds to OBD-II requests, simulating an engine control unit for bench testing scan tools and dashboards.

Both modes use the same wiring and interface setup described in the CAN Bus Usage guide. The difference is entirely in the CAN message content.

Every OBD-II-compliant vehicle exposes a standardized 16-pin diagnostic link connector (DLC) defined by SAE J1962. It is usually located under the dashboard on the driver’s side. Of the 16 pins, only a handful are relevant for CAN bus communication:

PinSignalDescription
4Chassis GroundSignal ground reference
5Signal GroundCAN and protocol ground
6CAN High (CAN-H)ISO 15765-4, dominant-high line
14CAN Low (CAN-L)ISO 15765-4, dominant-low line
16Battery PositivePermanent +12V from vehicle battery

Pins 6 and 14 carry the CAN differential pair. Pin 5 provides the signal ground reference. The remaining pins serve legacy protocols (ISO 9141, J1850) that predate CAN-based OBD-II and are not used here.

The connection between the RS485 CAN HAT and a vehicle OBD-II port requires exactly three wires:

HAT TerminalOBD-II PinSignal
HPin 6CAN High
LPin 14CAN Low
GND (Pi ground)Pin 5Signal Ground

You can solder wires directly into an OBD-II breakout connector, or use a pre-made OBD-II pigtail cable with bare wire ends. Keep leads short and twisted where possible — CAN is a differential bus and benefits from balanced line impedance.

A common concern is whether the SN65HVD230 transceiver on the RS485 CAN HAT is compatible with automotive CAN buses. The short answer: yes.

The SN65HVD230 is an ISO 11898-2 compliant CAN transceiver designed for 3.3V operation. ISO 11898-2 is the same physical layer standard that automotive CAN uses. The differential voltage levels on the bus are defined by the standard (dominant: CANH ≈ 3.5V, CANL ≈ 1.5V; recessive: both ≈ 2.5V), and these levels are independent of the transceiver’s supply voltage. A 3.3V transceiver and a 5V transceiver on the same bus produce and interpret identical differential signals — that is the entire point of a differential bus standard.

The misconception that automotive CAN requires a “5V transceiver” likely stems from the fact that many automotive CAN controllers (like those inside ECUs) run from a 5V supply. But the transceiver’s job is to convert between the controller’s logic levels and the bus’s differential levels, and the bus levels are defined by the standard, not by any individual transceiver’s VCC.

OBD-II over CAN uses one of two bitrates:

  • 500 kbps — the standard rate for most post-2008 vehicles (ISO 15765-4)
  • 250 kbps — used by some older vehicles and certain European manufacturers

There is no way to know which rate a vehicle uses without trying. The most reliable approach is to bring up the interface at 500 kbps and check for traffic:

  1. Bring up the interface at 500 kbps.

    Terminal window
    sudo ip link set can0 up type can bitrate 500000
  2. Listen for traffic. Vehicles with active systems (ABS, traction control, HVAC) generate periodic CAN traffic even with the engine off, as long as the ignition is on.

    Terminal window
    candump can0
  3. Check for errors. If the bitrate is wrong, the MCP2515 will report framing errors instead of valid frames. Check with:

    Terminal window
    ip -details -statistics link show can0

    Look at the bus-error and restarts counters. If they are climbing rapidly, the bitrate is wrong.

  4. Try 250 kbps if 500 didn’t work.

    Terminal window
    sudo ip link set can0 down
    sudo ip link set can0 up type can bitrate 250000
    candump can0

Once you see clean frames in candump, you have the correct bitrate.

OBD-II uses a simple request-response model over CAN. A diagnostic tool sends a request frame, and the vehicle’s ECU replies with a response frame.

There are two addressing modes:

  • Functional addressing — the request is sent to CAN ID 0x7DF, which is a broadcast address. Every ECU on the diagnostic bus receives it and any ECU that supports the requested parameter will respond.
  • Physical addressing — the request is sent to a specific ECU using CAN IDs 0x7E0 through 0x7E7. This is used when you want to talk to a particular module (engine, transmission, ABS, etc.).

Response frames come back on CAN IDs 0x7E8 through 0x7EF (the request ID plus 8). The primary engine ECU typically responds on 0x7E8.

An OBD-II request frame is always 8 bytes. The first byte is the number of additional data bytes, the second byte is the mode (service), and the third byte (when applicable) is the PID (parameter ID).

Byte: [0] [1] [2] [3..7]
Len Mode PID Padding (0x55 or 0x00)

For example, to request engine RPM (Mode 0x01, PID 0x0C):

02 01 0C 00 00 00 00 00

The 02 means “2 bytes of data follow” (the mode and PID).

The ECU responds with a frame where the mode byte has 0x40 added to it:

Byte: [0] [1] [2] [3..N] [N+1..7]
Len Mode+40 PID Value Padding

For engine RPM, the response looks like:

04 41 0C 1A F8 00 00 00

Here 41 confirms it is a response to Mode 0x01, 0C echoes the PID, and 1A F8 is the RPM value. RPM is encoded as (A * 256 + B) / 4, so (0x1A * 256 + 0xF8) / 4 = 1726 RPM.

PIDNameFormulaUnit
0x00Supported PIDs (01-20)Bitmask
0x05Coolant TemperatureA - 40°C
0x0CEngine RPM(A×256 + B) / 4rpm
0x0DVehicle SpeedAkm/h
0x0FIntake Air TemperatureA - 40°C
0x11Throttle PositionA × 100 / 255%
0x1FRun Time Since StartA×256 + Bseconds
0x2FFuel Tank LevelA × 100 / 255%

PID 0x00 is special — it returns a 4-byte bitmask indicating which of PIDs 0x01 through 0x20 are supported by the ECU. This is the standard way to discover what a vehicle supports before requesting specific data.

In scan tool mode, the Pi sends OBD-II request frames and decodes the responses. This is the classic “read your car’s data” use case.

The fastest way to verify connectivity is with cansend and candump. Open two terminals:

Terminal 1 — listen for responses:

Terminal window
candump can0,7E8:7F0

The filter 7E8:7F0 passes CAN IDs 0x7E8 through 0x7EF (all standard OBD-II response IDs).

Terminal 2 — request engine RPM:

Terminal window
cansend can0 7DF#02010C0000000000

In the first terminal, you should see a response like:

can0 7E8 [8] 04 41 0C 1A F8 00 00 00

Decode manually: bytes 3-4 are 0x1A and 0xF8. RPM = (26 × 256 + 248) / 4 = 1726 RPM.

For real-time monitoring of changing values, cansniffer highlights bytes that change between frames:

Terminal window
cansniffer can0 -c

This is particularly useful for reverse-engineering proprietary CAN messages beyond the standard OBD-II PIDs.

This approach uses python-can to construct OBD-II requests directly. It gives full control over the CAN framing and is the right choice when you need to work with non-standard PIDs or proprietary extensions.

import can
import struct
bus = can.interface.Bus(channel='can0', bustype='socketcan')
def request_pid(pid, mode=0x01):
"""Send an OBD-II request and return the raw response bytes."""
msg = can.Message(
arbitration_id=0x7DF,
data=[0x02, mode, pid, 0x00, 0x00, 0x00, 0x00, 0x00],
is_extended_id=False,
)
bus.send(msg)
# Wait for response on 0x7E8-0x7EF
while True:
resp = bus.recv(timeout=2.0)
if resp is None:
return None
if 0x7E8 <= resp.arbitration_id <= 0x7EF:
return resp.data
# Engine RPM (PID 0x0C)
data = request_pid(0x0C)
if data:
rpm = (data[3] * 256 + data[4]) / 4
print(f"RPM: {rpm:.0f}")
# Coolant temperature (PID 0x05)
data = request_pid(0x05)
if data:
temp_c = data[3] - 40
print(f"Coolant: {temp_c} °C")
# Vehicle speed (PID 0x0D)
data = request_pid(0x0D)
if data:
speed = data[3]
print(f"Speed: {speed} km/h")
bus.shutdown()

In emulator mode, the Pi listens for incoming OBD-II requests and responds with simulated data. This is invaluable for bench-testing scan tools, OBD-II dashboards, and data loggers without needing access to a vehicle.

The following script responds to standard Mode 0x01 PID requests on the functional address (0x7DF) and the primary physical address (0x7E0), replying on 0x7E8:

import can
import time
import math
bus = can.interface.Bus(channel='can0', bustype='socketcan')
def simulated_values():
"""Generate plausible engine data that varies over time."""
t = time.time()
rpm = 800 + 400 * math.sin(t * 0.5) # Idle oscillation
speed = max(0, 60 + 30 * math.sin(t * 0.2)) # Gentle cruising
coolant = 85 + 5 * math.sin(t * 0.05) # Warm engine
throttle = 15 + 10 * math.sin(t * 0.3) # Light throttle
intake_temp = 25 + 3 * math.sin(t * 0.1) # Ambient drift
return rpm, speed, coolant, throttle, intake_temp
def build_response(pid):
"""Build an OBD-II response for a given Mode 0x01 PID."""
rpm, speed, coolant, throttle, intake_temp = simulated_values()
if pid == 0x00:
# Supported PIDs bitmask: 0x05, 0x0C, 0x0D, 0x0F, 0x11
# Bit positions (from PID 0x01): 05=bit27, 0C=bit20, 0D=bit19, 0F=bit17, 11=bit15
bitmask = 0x08198000
b = bitmask.to_bytes(4, 'big')
return [0x06, 0x41, 0x00, b[0], b[1], b[2], b[3], 0x00]
elif pid == 0x05:
val = int(coolant + 40)
return [0x03, 0x41, 0x05, val, 0x00, 0x00, 0x00, 0x00]
elif pid == 0x0C:
encoded = int(rpm * 4)
a = (encoded >> 8) & 0xFF
b = encoded & 0xFF
return [0x04, 0x41, 0x0C, a, b, 0x00, 0x00, 0x00]
elif pid == 0x0D:
return [0x03, 0x41, 0x0D, int(speed), 0x00, 0x00, 0x00, 0x00]
elif pid == 0x0F:
val = int(intake_temp + 40)
return [0x03, 0x41, 0x0F, val, 0x00, 0x00, 0x00, 0x00]
elif pid == 0x11:
val = int(throttle * 255 / 100)
return [0x03, 0x41, 0x11, val, 0x00, 0x00, 0x00, 0x00]
return None
print("ECU emulator running — listening for OBD-II requests...")
while True:
msg = bus.recv(timeout=1.0)
if msg is None:
continue
if msg.arbitration_id not in (0x7DF, 0x7E0):
continue
if msg.data[1] != 0x01:
continue
pid = msg.data[2]
response_data = build_response(pid)
if response_data:
resp = can.Message(
arbitration_id=0x7E8,
data=response_data,
is_extended_id=False,
)
bus.send(resp)

You can test the emulator entirely in software using a virtual CAN interface. The vcan kernel module creates a loopback CAN bus that requires no physical hardware:

Terminal window
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0

Change channel='can0' to channel='vcan0' in the emulator script, then open a second terminal and issue requests:

Terminal window
# In terminal 1: run the emulator on vcan0
python3 ecu_emulator.py
# In terminal 2: request RPM
cansend vcan0 7DF#02010C0000000000
candump vcan0,7E8:7FF

This is an excellent way to develop and debug OBD-II applications without a vehicle or even the CAN HAT itself.

Some OBD-II responses exceed the 8-byte CAN frame limit — most notably the Vehicle Identification Number (VIN, Mode 0x09 PID 0x02) and diagnostic trouble code lists (Mode 0x03). These use ISO 15765-2 (ISO-TP), a transport protocol that segments long messages across multiple CAN frames.

The can-isotp kernel module provides ISO-TP support at the socket level:

Terminal window
sudo modprobe can-isotp

Once loaded, you can use isotpsend and isotprecv from can-utils to handle multi-frame transfers:

Terminal window
# Receive ISO-TP messages from ECU (tx=7E0, rx=7E8)
isotprecv -s 7E0 -d 7E8 can0
# Send an ISO-TP request (in another terminal)
echo "09 02" | isotpsend -s 7E0 -d 7E8 can0

OBD-II Mode 0x03 retrieves stored diagnostic trouble codes from the ECU. These are the codes that cause the “check engine” light to illuminate.

Send a Mode 0x03 request (no PID needed):

Terminal window
cansend can0 7DF#0103000000000000

The response contains pairs of DTC bytes. Each 2-byte pair encodes one trouble code:

Byte pair: [High nibble] [Low 3 nibbles]
↓ ↓
Category + digit Remaining digits

The high 2 bits of the first byte determine the category:

BitsCategoryPrefix
00PowertrainP0xxx
01ChassisC0xxx
10BodyB0xxx
11NetworkU0xxx

For example, the bytes 01 07 decode as:

  • High 2 bits of 0x01 = 00P (Powertrain)
  • Remaining: 0 1 0 7P0107 (Manifold Absolute Pressure sensor circuit low)

Mode 0x04 clears stored DTCs and resets the check engine light:

Terminal window
cansend can0 7DF#0104000000000000

candump

Displays received CAN frames in real time. Supports filtering by ID and masking. Part of can-utils.

Terminal window
candump can0,7E8:7F0

cansend

Sends a single CAN frame. The format is ID#data with bytes separated by dots or concatenated as hex.

Terminal window
cansend can0 7DF#02010C0000000000

cansniffer

Shows real-time changes in CAN traffic. Highlights bytes that differ between consecutive frames — useful for identifying which bytes correspond to changing sensor values.

Terminal window
cansniffer can0 -c

isotpsend / isotprecv

Send and receive ISO-TP (multi-frame) messages. Required for VIN requests, DTC reads, and any response longer than 7 data bytes. Requires the can-isotp kernel module.

Terminal window
isotprecv -s 7E0 -d 7E8 can0

python-can

Python library for SocketCAN. Provides a clean interface for sending and receiving CAN frames, bus management, and message filtering. Install with pip3 install python-can.

python-obd

High-level Python library that handles OBD-II PID encoding, decoding, and unit conversion. Best suited for applications that use an ELM327 adapter or need a quick path to reading vehicle data. Install with pip3 install obd.