Skip to content

OBD-II Vehicle Diagnostics

CAN & CANopen Studio can talk to a car's diagnostic port: read live parameters, trouble codes and the VIN over SAE J1979, through either an ELM327 adapter or one of the CAN adapters the studio already supports.


Why diagnostics sit beside the CAN stack, not inside it

Everywhere else in the studio, an adapter yields raw frames. The CANopen layer, the trace, the plotter and the bridge all consume can.Message objects, and that is the contract open_can_bus() honours.

An ELM327 cannot honour that contract. It is not a transparent bridge: it runs its own protocol autodetection and its own ISO-TP handling, and answers in ASCII hexadecimal terminated by a > prompt. Plugging one into SUPPORTED_INTERFACES would break every consumer downstream of it.

So diagnostics have their own abstraction, DiagnosticInterface, which answers one question: given a request, which ECUs replied and what did they say. Two implementations sit behind it and callers cannot tell them apart.

ElmDiagnosticInterface NativeCanDiagnosticInterface
Hardware Any ELM327 β€” USB, Bluetooth SPP, Bluetooth LE, Wi-Fi SLCAN, PCAN, Kvaser, Vector, IXXAT, gs_usb, SocketCAN, virtual
Protocol detection Done by the adapter You set the bitrate and addressing
ISO-TP Adapter handles flow control; the studio reassembles Fully implemented in the studio
Wiring Plugs into the OBD-II port Needs CAN-H/CAN-L on pins 6 and 14
Speed Limited by the serial link Full bus speed

Both expose the same high-level operations, so everything above them is written once.


Quick start

Choose the adapter in the top bar β€” an ELM327 (USB/SPP, Wi-Fi or Bluetooth LE) or a CAN adapter β€” and press Connect. On an ELM327 the studio then reads the VIN, discovers which parameters the vehicle implements, and resolves a vehicle profile at once; on a CAN adapter, press πŸ” Identify vehicle in the 🩺 OBD-II Diagnostics tab when you want it to start talking to the vehicle.

connect(interface="elm327_serial", channel="/dev/ttyUSB0")  # or elm327_ble, elm327_tcp
obd_connect()  # runs on that link
obd_list_supported_pids()
obd_read_pid("engine_speed")
obd_read_dtcs(kind="all")
from canopen_studio.diag.elm327 import ElmDiagnosticInterface, SerialElmTransport
from canopen_studio.diag.profiles import ProfileLibrary, ProfileResolver

with ElmDiagnosticInterface(SerialElmTransport("/dev/ttyUSB0")) as link:
    obd = link.j1979()
    identity = obd.identify()

    match = ProfileResolver(ProfileLibrary().load()).resolve(identity)
    obd.table = match.profile.table

    print(identity.vin, "β†’", match.profile.name)
    for reading in obd.scan():
        print(reading)

Connecting an adapter

The studio talks through one link, chosen in the top bar: a CAN adapter or an ELM327. What each can carry differs, and the studio says so rather than leaving a tab silently empty:

Link Frame trace CANopen OBD-II
CAN adapter (SLCAN, PCAN, Kvaser, gs_usb, SocketCAN, UDP, virtual) βœ… βœ… βœ… ISO-TP over the bus
ELM327 (USB / SPP, Wi-Fi, Bluetooth LE) β€” β€” βœ…

An ELM327 runs its own protocol detection and ISO-TP and answers in ASCII: it passes no raw frames on, so the trace and CANopen need a CAN adapter. There is no second Connect button for diagnostics; the 🩺 OBD-II Diagnostics tab shows which link it runs on.

Under the top bar, a connection chain shows two segments separately β€” studio ⟷ adapter and adapter ⟷ vehicle (or CAN bus) β€” because they fail separately. An ELM327 can be reachable over Bluetooth while the vehicle does not answer (ignition off), and a single "Connected" would hide exactly that. Grey is not yet connected or not queried, orange in progress, green working, red failed; on a CAN link the far segment shows whether frames are arriving.

In Python, the same model is canopen_studio.link.Link:

from canopen_studio.link import Link

link = Link("elm327_ble", "")  # or Link("slcan", "/dev/ttyUSB0", 500000)
link.open()
session, _ = link.diagnostic_session()
print(session.j1979().identify().vin)
link.close()

USB

The common case. An ELM327 appears as a serial port:

SerialElmTransport("/dev/ttyUSB0")  # Linux
SerialElmTransport("COM4")  # Windows

38400 baud suits genuine chips and most clones. A board that stays silent may be wired for 9600.

Bluetooth (classic SPP)

Supported, and no different from USB once the operating system has bound a port.

# Linux: pair, then bind a serial port to the adapter
sudo rfcomm bind 0 00:11:22:33:44:55
SerialElmTransport("/dev/rfcomm0")

On Windows, pairing creates an outgoing COMx port; use that.

Wi-Fi and TCP

Wi-Fi clones listen on port 35000, and so does the test emulator:

TcpElmTransport("192.168.0.10", 35000)

Bluetooth Low Energy

Adapters sold as "Bluetooth 4.0" or "iPhone compatible" are BLE: they expose a GATT service rather than a serial port, so there is nothing to pair or bind. They need the optional bleak dependency:

pip install 'canopen-studio[ble]'   # or: uv sync --extra ble
from canopen_studio.diag.elm327 import BleElmTransport, scan_ble_adapters

scan_ble_adapters()  # [("AA:BB:CC:DD:EE:FF", "OBDII"), ...]
BleElmTransport()  # the first adapter that looks like one
BleElmTransport("OBDII")  # a fragment of the advertised name
BleElmTransport("AA:BB:CC:DD:EE:FF")  # an address (a UUID on macOS)

Every vendor picked its own characteristics. The transport recognises the common layouts β€” FFF0/FFF1/FFF2, the HM-10 FFE0/FFE1, Vgate's 18F0, and the Nordic UART service β€” and otherwise accepts a custom service with exactly one notify and one write characteristic. When none fits, the error lists what the device offers; pass notify_uuid and write_uuid to name them yourself.

For a first contact, just obd-ble-check talks to the adapter alone β€” AT commands the chip answers itself, so nothing reaches the vehicle and the ignition can stay off. With --vehicle it adds read-only requests: identification and a count of trouble codes.

A BLE peripheral serves one central at a time: close any phone app still connected to the adapter first. In the studio, choose ELM327 β€” Bluetooth LE in the top bar; leave the channel on (first adapter found) or type an address or a name fragment. Over MCP, connect(interface="elm327_ble", channel=""), then obd_connect().

Native CAN

No ELM327 at all β€” ISO-TP straight over an adapter the studio already drives:

from canopen_studio.diag.native import NativeCanDiagnosticInterface

link = NativeCanDiagnosticInterface.open_bus("socketcan", "can0", 500000)

In the GUI, a native session borrows the connected bus rather than opening a second one. The capture loop owns the only reader, so it hands frames to the session through a queue β€” the same arrangement the CAN-to-network bridge already uses. A diagnostic session therefore never steals frames from the trace or the plotter, and disconnecting the bus ends the session with it.

Only the CAN protocols are supported

This backend implements ISO 15765-4. If an ELM327 autodetects K-line (ISO 9141-2, ISO 14230-4) or J1850, the session says which protocol it found and stops, rather than mis-parsing a differently framed reply into plausible-looking values.


Counterfeit adapters

Counterfeit ELM327 boards are the norm rather than the exception, and the usual tell is that ATI reports a version the firmware does not live up to β€” a v1.4-era die flashed to answer "ELM327 v2.1".

The studio reads the version but never trusts it. Each capability is probed by sending the command and watching for the ? that means "unknown command". Every probe is a no-op that leaves the adapter in its default state.

print(link.capabilities)
# ELM327 v2.1 (probable clone)

for note in link.capabilities.notes:
    print(note)
# reports v2.1 but does not implement can_receive_filter β€” treating it as v1.2
# no CAN receive filter: responses are filtered in software instead

A clone is not rejected. It is used for what it can actually do, and the session says so.


Reading parameters

Which PIDs a vehicle implements is never assumed. The vehicle declares its own capabilities through bitmask PIDs β€” 0x00 covers 0x01–0x20, 0x20 covers 0x21–0x40 β€” and the last bit of each block says whether there is another. The walk ends where the vehicle says it ends.

supported = obd.supported_pids()
print(len(supported), "parameters")
print(supported.supported_by(0x0C))  # which ECU answers engine speed

Support is tracked per ECU, because the engine controller, the transmission and a hybrid controller each answer a functional request with their own bitmask.

obd.read_pid(0x0C)  # by identifier
obd.read("engine_speed")  # by name, from the active profile
obd.scan()  # everything the vehicle supports, once

Two specific wrong answers are guarded against. A reply echoing a different PID is discarded rather than decoded under this PID's definition β€” on a shared bus a late answer to an earlier request is still in flight. And a reply too short for its formula falls back to raw bytes, because knowing the ECU answered is worth more than a clean failure.

Watching and recording

OBD-II has no subscription: a value is only ever the answer to a request, so watching one change means asking again and again. PidPoller does that on a thread of its own, and CsvRecorder writes every answer down:

from canopen_studio.diag.j1979 import CsvRecorder, PidPoller

with CsvRecorder("drive.csv") as recorder:
    poller = PidPoller(obd, [0x0C, 0x0D, 0x05], recorder=recorder)
    poller.run(duration=60)

print(poller.stats.as_dict())  # requests/s and how often each value was refreshed

An ELM327 answers one request at a time, and every polled PID shares that budget: the refresh interval of each value is the time a whole cycle takes, and it grows with the number of PIDs. A v1.5 clone over Bluetooth LE was measured at six to eleven requests per second, depending on the vehicle's state and how many ECUs answer each request β€” three PIDs refreshed every 0.3 to 0.5 s β€” and USB and native CAN adapters do better. The poller measures the rate it achieves rather than promising one, and every row carries its own timestamp, so anything computed from a recording can state the sampling it rests on.

The file has one row per reading β€” timestamp (UTC, milliseconds), elapsed_s, ecu, pid, name, value, unit β€” which keeps two ECUs answering the same PID apart and never invents a value for a PID that was not sampled at that instant. A PID that fails is counted and skipped; a lost link ends the run and is kept in poller.error.

In the studio, select parameters in the table (none selected means all of them), then β–Ά Live to watch them refresh, or ⏺ Record… to also write them to a CSV file. The status line shows the measured rate. While polling runs it owns the session, so other requests wait until it is stopped.


Trouble codes

Three modes return codes in the same ISO 15031-6 format:

Kind Mode Meaning
stored 0x03 Confirmed fault, malfunction indicator lamp commanded on
pending 0x07 Seen once, awaiting confirmation on a second drive cycle
permanent 0x0A Confirmed fault that only the vehicle may clear
for code in obd.read_all_dtcs():
    print(code)  # P0143 (Powertrain) β€” O2 sensor circuit low voltage

A code's letter comes from its top two bits β€” Powertrain, Chassis, Body, U for network β€” and the digit after it says whether the standard or the manufacturer gives it meaning. P1xxx is manufacturer-specific; P2xxx and P3xxx depend on the system, so the studio reports them as ambiguous rather than claiming either way.

Freeze frame

When an ECU stores a code it also keeps a snapshot of the conditions at that moment β€” engine speed, load, coolant temperature, fuel trims. That snapshot is mode 02, and it answers the question a code alone cannot: what was the engine doing when the lamp came on?

for frame in obd.read_freeze_frames():
    print(frame.dtc, hex(frame.source))
    for value in frame.values:
        print(" ", value)

Mode 02 mirrors mode 01 with a frame number after the PID, so the captured values decode with the live-data definitions of the active profile. An ECU whose trigger code is 0000 holds no frame and is left out: on a vehicle with no fault the list is empty. In the studio, ❄ Freeze Frame in the trouble-code panel.


Emissions readiness

A vehicle does not test its emissions systems all the time. Each on-board monitor β€” catalyst, evaporative system, oxygen sensors, EGR, and on a diesel the particulate filter and NOx aftertreatment β€” runs when its own conditions are met, and reports whether it has completed. An inspection reads exactly this: a lamp that is off proves little if the monitors that would light it have not run yet.

readiness = obd.read_readiness()  # since the codes were cleared (PID 01)
readiness.all_complete  # lamp off and every monitor run
[m.label for m in readiness.incomplete]  # ['Evaporative system']

obd.read_readiness(this_cycle=True)  # the current drive cycle (PID 41)

Bytes C and D of the status name different monitors on a spark-ignition and a compression-ignition engine, so they are decoded in code rather than by a bit table in the profile. When several ECUs answer, their reports are combined: the lamp is on if any ECU commands it, and a monitor is complete only if every ECU implementing it says so.

all_complete is the strict reading, not a verdict. Inspection regimes differ β€” some accept one or two incomplete monitors depending on the model year β€” so the studio reports what has not run and leaves the judgement to the rules that apply. Clearing trouble codes resets every monitor to incomplete, which is why it sits behind the write gates. In the studio, βœ… Readiness in the trouble-code panel.


Vehicle identification

The VIN comes from mode 09 PID 02. It is twenty bytes, so it always arrives segmented β€” which is why both backends reassemble ISO-TP before anything tries to parse it.

identity = obd.identify()
print(identity.vin)  # 1HGBH41JXMN109186
print(identity.vin_info.region)  # North America
print(identity.vin_info.model_year)  # 1991
print(identity.vin_info.model_year_is_ambiguous)  # True
print(identity.vin_info.alternate_model_year)  # 2021

The model year is genuinely ambiguous

Its code repeats on a thirty-year cycle, so M is both 1991 and 2021. The studio infers using the North American convention β€” a letter in position seven marks a vehicle of 2010 or later β€” and flags the result as inferred, offering the other candidate alongside. Profile resolution considers both.

A partial reassembly comes back as None, never as a short VIN nobody notices.


Vehicle profiles

A profile is a data file that says how to decode PIDs and what trouble codes mean. It inherits: generic J1979 describes what every compliant car has, a make adds what that manufacturer does, a model and year add what that car does. Each layer states only its own difference.

id: example_make
name: Example Make
extends: j1979_base

match:
  wmi: [ZZZ]
  years: {from: 2015, to: 2025}
  required_pids: ["01:5B"]     # the vehicle must support these
  forbidden_pids: ["01:5E"]    # and must NOT support these

pids:
  "01:A0":
    name: battery_temperature
    description: Traction battery temperature
    bytes: 2
    unit: "Β°C"
    formula: (256 * A + B) / 10 - 40
    min: -40
    max: 120

dtcs:
  P1234: Traction battery contactor fault

Formulas address data bytes as A, B, C… β€” the notation of the standard's own worked examples. Five decoding shapes are available: formula, values (an enumeration on the first byte), bits (named flags addressed as A.0), ascii, and raw bytes when none is declared.

A profile is data, never code

Formulas are interpreted, never executed. The expression is parsed to a syntax tree, every node is checked against a whitelist, and evaluation walks that tree β€” nothing is compiled and eval is never called. A profile cannot reach the filesystem, the network or the interpreter however it is written. Files are read with yaml.safe_load.

Where profiles live

Bundled profiles ship inside the package. Your own go anywhere you point the library:

library = ProfileLibrary(directories=["~/.canopen-studio/profiles"]).load()

A local profile reusing a bundled id replaces it. One bad file is recorded in library.errors and skipped, rather than taking the generic fallback down with it.

How a profile is chosen

Four stages, and the last one always succeeds:

  1. VIN β€” the manufacturer identifier and the model year must both fit. A profile claiming another manufacturer is not a weak match, it is the wrong profile.
  2. Supported-PID fingerprint β€” for a vehicle that will not give up its VIN. Two cars of the same model and year implement the same PIDs. A profile whose manufacturer rules contradict a known VIN is dropped here too, so a matching PID set alone cannot pick the wrong car.
  3. Manual selection β€” offered when neither automatic stage found anything. A profile chosen explicitly wins over all of the above.
  4. Generic SAE J1979 β€” the fallback.
match = ProfileResolver(library).resolve(identity)
print(match.stage)  # vin | fingerprint | manual | fallback
print(match.reasons)  # ('manufacturer 1HG', 'model year 2018')
print(match.profile.lineage)  # ('j1979_base', 'acme', 'acme_model_2018')

Resolution cannot fail, on purpose. A wrong-but-specific profile would decode a manufacturer PID into a plausible-looking wrong number, which is worse than decoding fewer parameters correctly.


Importing definitions you already have

Torque Pro CSV

Torque's custom-PID exports are the files people actually have, and the formats line up: Torque equations already address bytes as A, B, C. Only bit extraction needs translating, from {A:3} to bit(A, 3).

from canopen_studio.diag.profiles.importers import import_torque_csv

report = import_torque_csv("my_car_pids.csv", profile_id="my_car")
print(report)  # 42 PID(s) imported, 3 skipped
for reason in report.skipped:
    print(reason)
library.add(report.profile)

DBC databases

uv pip install 'canopen-studio[dbc]'

A DBC describes signals broadcast on a bus; a PID is requested and answered. The two overlap in exactly one place β€” a database modelling an OBD-II response as a multiplexed message, which is how real OBD-II DBCs are written.

from canopen_studio.diag.profiles.importers import import_dbc

report = import_dbc("OBD2.dbc", profile_id="from_dbc")

The file is read as self-describing: the service comes from the multiplexer chain above the PID signal, and the first data byte is the one after the PID echo wherever the database puts it. A database that models the ISO-TP length byte and one that does not both import correctly.

An ordinary broadcast DBC imports nothing and says why β€” that is the honest outcome, not a failure.

Both importers refuse rather than approximate

An equation that will not parse, a bit-packed or little-endian signal, a malformed PID β€” each is skipped with a reason. An imported PID that decodes to a plausible wrong number is worse than one that is missing, because nothing downstream can tell them apart.


Safety

Read-only is the default, and it is enforced in three independent places.

Reading a PID asks an ECU a question. Writing to one changes a car that people drive, and some of those changes cannot be undone from a laptop.

Gate What it is
Kill switch CANOPEN_STUDIO_DIAG_WRITE must be set. Off by default, like CANOPEN_STUDIO_A2A.
Per-profile whitelist A manufacturer service is allowed only if the active profile names it in write_whitelist β€” a reviewed data file, not an argument passed once.
Per-call confirmation confirm=True for that one call. It cannot be defaulted on and never persists.
Agent switch CANOPEN_STUDIO_MCP_DIAG_WRITE, required in addition for a write arriving through MCP.

A refused write transmits nothing.

The UDS write services are not implemented

WriteDataByIdentifier (0x2E), RoutineControl (0x31) and InputOutputControlByIdentifier (0x2F) are refused even with all three gates open, because this release has no request path for them. The gate exists so that adding one is a deliberate change rather than an accident of what happens not to be written yet.

Clearing trouble codes

Implemented, since reading and clearing codes is what a diagnostic tool is for β€” and still gated.

CANOPEN_STUDIO_DIAG_WRITE=1 uv run canopen-studio
from canopen_studio.diag import WriteGate, clear_trouble_codes

clear_trouble_codes(link, WriteGate(match.profile), confirm=True)

Clearing costs more than the codes

Mode 04 erases the stored codes, the freeze frame and the readiness monitors. The vehicle needs a full drive cycle to rebuild those, and an emissions test taken before that will fail. Permanent codes are unaffected β€” only the vehicle can clear them.

Letting an agent write

obd_clear_dtcs is the one MCP tool that changes the vehicle. It is registered always β€” so that a refusal is a message an agent can reason about rather than a missing tool β€” and shut unless both switches are set:

CANOPEN_STUDIO_DIAG_WRITE=1 CANOPEN_STUDIO_MCP_DIAG_WRITE=1 uv run canopen-mcp

The two are separate on purpose. Enabling writes so that a person can clear codes from the GUI must not, by itself, hand that capability to whatever model is connected to the server. With only one set, the call transmits nothing and the refusal names the missing one.

Once enabled, the capability is stated everywhere it could matter:

  • on the server's console at startup, so nobody discovers it by watching a model use it;
  • in the tool's own description, which is what a model reads before deciding to call it;
  • in the result of every trouble-code read;
  • in obd_status(), under writes.warning.
WARNING: CANOPEN_STUDIO_MCP_DIAG_WRITE is set β€” an AI agent connected to this server can
clear diagnostic trouble codes on the connected vehicle. Clearing also erases the
readiness monitors, which need a full drive cycle to rebuild and without which an
emissions test fails.

What this actually permits

A model that decides to "reset the fault and try again" will erase the readiness monitors on somebody's car. On a vehicle due for an emissions inspection, that costs its owner the appointment. The tool's description tells the model to ask first, and confirm=True is still required on every call β€” but neither is a substitute for deciding whether you want this on at all.

The UDS write services remain unavailable through MCP, because they remain unimplemented everywhere.


MCP tool reference

These join the existing MCP server β€” same process, same port, same guards.

Tool Purpose
obd_connect(profile) Open a session on the current link, identify the vehicle, resolve a profile. transport=… still opens an adapter of its own, for compatibility
obd_disconnect() Close the session
obd_status() Link, active profile and write posture
obd_list_supported_pids(mode) What the vehicle declares, per ECU
obd_read_pid(pid, mode) Read by identifier, key or name
obd_sample_pids(pids, duration_s) Poll live data for up to 30 s; per-PID min/max/mean/last and the measured rate
obd_read_dtcs(kind) stored, pending, permanent or all
obd_read_freeze_frame(frame) The values each ECU captured when it stored a code
obd_read_readiness(this_cycle) Which emissions monitors have run, and which have not
obd_read_vin() The VIN, decoded
obd_identify_vehicle() Everything the vehicle says about itself
obd_list_profiles() Profiles available for a manual choice
obd_clear_dtcs(confirm) Destructive. Clears codes and readiness monitors; needs both write switches

Testing without a vehicle

Two layers, for two different jobs.

The in-repository fake (tests/elm327_fake.py) carries the everyday suite. It speaks the real protocol, honours the echo, linefeed and header settings, frames responses as ISO-TP, and can be told to misbehave β€” report a firmware version it does not live up to, answer a status word, truncate a reply. It is fast and needs nothing installed.

Ircama's ELM327-emulator is the reality check: third-party software simulating the ECUs of a real car, driven over a real socket.

uv run --with ELM327-emulator pytest -m emulator -v

Why it is not a dev dependency

ELM327-emulator is licensed CC-BY-NC-SA-4.0 β€” non-commercial and non-OSI. Pulling it into the default dev group of a GPL project would impose that on everyone running the suite. Nothing is redistributed, since it runs as a separate process, so installing it is a choice each person makes. Without it those tests skip with that instruction.

The emulator also exposes an SLCAN interface, which lets the same emulated ECUs be reached through the native backend:

python3 -m elm -c /dev/pts/N -s car     # emulate a CANable running SLCAN
sudo slcand -o -s6 /dev/pts/N can0 && sudo ip link set can0 up

That path needs slcand and a SocketCAN interface, so it is documented rather than automated in the suite.