Why automate CAN log decoding

GUI tools are useful for inspection. Automation is useful when you need repeatable analysis.

A Python decode pipeline helps when you need to:

  • decode many CAN logs
  • compare DBC versions
  • export signal data for plots
  • check unknown frame IDs
  • run validation in CI
  • summarize test runs
  • build quick diagnostics around a DBC file

Two common Python libraries are python-can for CAN message I/O and log handling, and cantools for DBC parsing and message decoding.

Install the tools

python -m pip install python-can cantools pandas

For simple scripts, pandas is optional. It is convenient for CSV export and analysis.

Load a DBC file

import cantools

db = cantools.database.load_file("vehicle.dbc")

print(f"Loaded {len(db.messages)} messages")
for message in db.messages[:5]:
    print(hex(message.frame_id), message.name)

If this fails, fix the DBC before debugging logs. A parser error usually means the script is not yet at the data-analysis stage.

Decode one frame manually

Start with one known message:

import cantools

db = cantools.database.load_file("vehicle.dbc")
message = db.get_message_by_frame_id(0x321)

payload = bytes.fromhex("68 13 FF FF 40 1F 00 00")
decoded = message.decode(payload, decode_choices=False)

print(decoded)

This proves your DBC and signal math work for one payload.

Decode a candump-style log

Many candump -L logs look like:

(1716381000.123456) can0 321#6813FFFF401F0000

A small parser:

import re
import cantools
import pandas as pd

LINE = re.compile(
    r"^\((?P<ts>[0-9.]+)\)\s+(?P<iface>\S+)\s+"
    r"(?P<canid>[0-9A-Fa-f]+)#(?P<data>[0-9A-Fa-f]*)"
)

db = cantools.database.load_file("vehicle.dbc")
rows = []
unknown_ids = {}
decode_errors = []

with open("capture.log", "r", encoding="utf-8") as handle:
    for line_number, line in enumerate(handle, start=1):
        match = LINE.match(line.strip())
        if not match:
            continue

        timestamp = float(match.group("ts"))
        frame_id = int(match.group("canid"), 16)
        data = bytes.fromhex(match.group("data"))

        try:
            message = db.get_message_by_frame_id(frame_id)
        except KeyError:
            unknown_ids[frame_id] = unknown_ids.get(frame_id, 0) + 1
            continue

        try:
            decoded = message.decode(data, decode_choices=False)
        except Exception as exc:
            decode_errors.append((line_number, frame_id, str(exc)))
            continue

        for signal_name, value in decoded.items():
            rows.append(
                {
                    "timestamp": timestamp,
                    "frame_id": f"0x{frame_id:X}",
                    "message": message.name,
                    "signal": signal_name,
                    "value": value,
                }
            )

pd.DataFrame(rows).to_csv("decoded-signals.csv", index=False)

print("unknown IDs:", {hex(k): v for k, v in sorted(unknown_ids.items())})
print("decode errors:", decode_errors[:10])

This script is intentionally explicit. In real validation work, silent failures are expensive.

Keep timestamps

Do not throw away timestamps. Without timestamps you cannot calculate:

  • signal rate
  • missing-message gaps
  • startup order
  • event timing
  • latency between cause and effect
  • replay alignment

If the log has absolute timestamps, preserve them. If the log has relative timestamps, document the start time separately.

Handle unknown IDs properly

Unknown IDs are not automatically a failure. They can mean:

  • the log contains frames outside the DBC scope
  • the DBC is incomplete
  • the wrong DBC version was used
  • the vehicle variant is different
  • the frame uses extended ID but was parsed incorrectly

Report unknown IDs with counts. Do not simply ignore them.

for frame_id, count in sorted(unknown_ids.items(), key=lambda item: item[1], reverse=True):
    print(f"0x{frame_id:X}: {count}")

If a high-frequency ID is unknown, it is worth investigating.

Export wide signal tables when needed

Long format is useful for storage:

timestamp,frame_id,message,signal,value

Wide format is useful for plots:

timestamp,EngineSpeed,VehicleSpeed,BatteryVoltage

Convert with pandas:

df = pd.DataFrame(rows)
wide = df.pivot_table(
    index="timestamp",
    columns="signal",
    values="value",
    aggfunc="last",
)
wide.to_csv("decoded-wide.csv")

For asynchronous CAN signals, do not assume all values update at the same timestamp. If you forward-fill values, document that choice.

Plot a signal quickly

import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv("decoded-signals.csv")
engine = df[df["signal"] == "EngineSpeed"]

plt.plot(engine["timestamp"], engine["value"])
plt.xlabel("time")
plt.ylabel("EngineSpeed")
plt.grid(True)
plt.show()

A plot catches problems faster than a table when scale, byte order, or signedness is wrong.

Decode BLF or ASC logs

python-can includes log readers for several formats depending on installation and format support.

The general pattern:

import can
import cantools

db = cantools.database.load_file("vehicle.dbc")

reader = can.LogReader("capture.asc")
for msg in reader:
    if msg.is_error_frame or msg.is_remote_frame:
        continue
    try:
        decoded = db.decode_message(msg.arbitration_id, msg.data, decode_choices=False)
    except Exception:
        continue
    print(msg.timestamp, hex(msg.arbitration_id), decoded)

Always test the reader on a small known file first. Log formats differ in metadata, channel naming, timestamps, and CAN FD handling.

Use DBC Utility before writing code

Before building automation, open the DBC in a viewer and inspect the messages you care about. Check:

  • frame ID
  • message length
  • signal start bits
  • scale and offset
  • units
  • multiplexing
  • comments

Automation should encode a workflow you already understand. It should not hide a DBC you have never reviewed.

Related reading: