Why J1939 feels different from normal CAN

J1939 runs on CAN, but it changes how you should think about identifiers and signals.

In a typical proprietary CAN database, one CAN ID often maps directly to one message definition in the DBC. In J1939, the important identifier is usually the PGN, or Parameter Group Number. The PGN is derived from the 29-bit extended CAN identifier. The individual parameters inside a PGN are called SPNs, or Suspect Parameter Numbers.

That gives you a three-layer mental model:

29-bit CAN ID -> PGN -> SPNs

The CAN ID still matters. It contains priority, addressing fields, and source address. But for decoding engineering values, the PGN is the key.

The 29-bit J1939 identifier

A J1939 CAN ID uses the extended 29-bit format.

The fields are commonly viewed like this:

Priority | Reserved | Data Page | PDU Format | PDU Specific | Source Address
  3 bits |   1 bit  |   1 bit   |   8 bits   |    8 bits    |     8 bits

The PGN is built from:

Reserved + Data Page + PDU Format + PDU Specific

There is one important rule:

  • If PDU Format is below 240, the PDU Specific field is a destination address, so the PGN's low byte is treated as 00.
  • If PDU Format is 240 or above, the PDU Specific field is part of the PGN.

This is the PDU1 vs PDU2 distinction that causes a lot of first-time J1939 confusion.

Extract PGN fields in Python

This function is a useful sanity check when looking at candump logs.

def decode_j1939_id(can_id: int) -> dict[str, int]:
    priority = (can_id >> 26) & 0x7
    reserved = (can_id >> 25) & 0x1
    data_page = (can_id >> 24) & 0x1
    pdu_format = (can_id >> 16) & 0xFF
    pdu_specific = (can_id >> 8) & 0xFF
    source_address = can_id & 0xFF

    if pdu_format < 240:
        pgn = (reserved << 17) | (data_page << 16) | (pdu_format << 8)
        destination_address = pdu_specific
    else:
        pgn = (reserved << 17) | (data_page << 16) | (pdu_format << 8) | pdu_specific
        destination_address = None

    return {
        "priority": priority,
        "reserved": reserved,
        "data_page": data_page,
        "pdu_format": pdu_format,
        "pdu_specific": pdu_specific,
        "source_address": source_address,
        "destination_address": destination_address,
        "pgn": pgn,
    }


fields = decode_j1939_id(0x0CF00401)
print(fields)
print(hex(fields["pgn"]), fields["pgn"])

Expected PGN:

0xf004 61444

PGN 61444 is commonly known as EEC1, Electronic Engine Controller 1, in J1939 examples.

Decode a candump log into PGN counts

Before loading a J1939 DBC, it helps to know which PGNs are present.

import re
from collections import Counter
from pathlib import Path

line_re = re.compile(r"\([^)]+\)\s+\w+\s+(?P<id>[0-9A-Fa-f]+)#(?P<data>[0-9A-Fa-f]*)")


def pgn_from_can_id(can_id: int) -> int:
    pdu_format = (can_id >> 16) & 0xFF
    pgn = (can_id >> 8) & 0x3FFFF
    if pdu_format < 240:
        pgn &= 0x3FF00
    return pgn


counts = Counter()

for line in Path("j1939-capture.log").read_text().splitlines():
    match = line_re.match(line)
    if not match:
        continue
    can_id = int(match.group("id"), 16)
    counts[pgn_from_can_id(can_id)] += 1

for pgn, count in counts.most_common(20):
    print(f"PGN {pgn:06d} 0x{pgn:05X}: {count} frames")

This gives you the traffic profile before you decode values.

What an SPN really is

An SPN is a signal definition inside a PGN. It describes how to interpret part of the data payload.

An SPN definition needs the same practical fields you already know from DBC work:

  • start position
  • bit length
  • byte order
  • scale
  • offset
  • unit
  • valid range or special values

Example decoding logic:

Payload bytes: FF FF FF 68 13 FF FF FF
Engine speed raw bytes: 68 13
Little-endian raw value: 0x1368 = 4968
Scale: 0.125 rpm/bit
Decoded value: 621 rpm

This is why a J1939 DBC is valuable. It packages PGN/SPN definitions into a format tools can apply consistently.

Use candump with J1939

Capture all J1939 traffic on a SocketCAN interface:

candump -t a can0

Log it:

candump -t a -l can0

Filter a PGN class manually by CAN ID mask when needed. For example, PGN 0xF004 appears in IDs where the PF/PS bytes are F0 04, while the source address can vary.

candump can0,0CF00400:03FFFF00

Do not treat this as a universal J1939 filter recipe. It depends on whether you are matching priority, reserved/data page bits, PGN bits, and source address. For quick analysis, parsing a full log with Python is often less error-prone.

Decode with a J1939 DBC

With a J1939 DBC:

cat j1939-capture.log | python3 -m cantools decode j1939.dbc

If you only want one-line output:

cat j1939-capture.log | python3 -m cantools decode --single-line j1939.dbc

If the decoder reports unknown frames, ask:

  • Is the DBC using the expected PGN representation?
  • Is the frame extended, not standard?
  • Is this a proprietary PGN?
  • Is the source address changing?
  • Is the capture from the bus segment the DBC was built for?

PDU1 vs PDU2 in practical terms

PDU1 messages are destination-specific.

PF < 240
PS = destination address
PGN low byte = 00

PDU2 messages are broadcast-style group extension messages.

PF >= 240
PS = group extension
PGN includes PS

If your PGN extraction script ignores this rule, you will group frames incorrectly.

Where DBC Utility helps

J1939 files can be large. A full J1939 DBC can contain many PGNs and thousands of SPNs. In that environment, a text editor is the wrong primary tool.

Use DBC Utility to:

  • inspect extended-frame message IDs
  • review PGN-style message definitions
  • check SPN-like signal scale, offset, unit, and start bit
  • compare J1939 DBC revisions
  • review mux-heavy or dense payloads
  • confirm comments and value tables before release

The important point is discipline: use raw logs to prove what the bus did, then use the DBC to prove how the payload should be interpreted.

References