Cardog engineering · field guide

How a vehicle's computer system actually works

Written for software engineers who know networks and protocols but have never put a scope on a CAN bus. Everything here is grounded in the TARS codebase and in real captures from a 2013 Cadillac SRX — the addresses, the negative responses, and the bugs are the ones we actually hit.

The one-sentence version

A modern car is not a computer — it is an undocumented local network of 20–80 independent computers, with no DNS, no service discovery, no directory, several mutually incompatible dialects on the same wire, a legally-mandated public API that exposes about 5% of what's there, and a private API that the manufacturer never published and actively gates behind cryptography.

Every layer below adds one specific capability, and every layer has a way to fail that looks exactly like "the car isn't there." Understanding which layer is failing is the entire skill.

The stack, in one view

Click any layer to jump to it. Each row names the closest thing in web engineering — the analogies are imperfect but they get you oriented fast.

What "connecting to the car" actually means

Three very different activities get collapsed into that one phrase. Distinguishing them is the first thing to internalise:

1 · Basic ECU handshake

Plug in, auto-detect the protocol, ask the powertrain module for its VIN and supported PIDs.

Difficulty: low. Legally standardised since 1996. One address, one mode, guaranteed to answer.

2 · Passive bus capture

Listen to everything every module broadcasts — thousands of frames per second — and transmit nothing at all.

Difficulty: medium. Trivially safe, but the data is an unlabeled byte stream. The work is decoding.

3 · Interrogating other modules

Address the airbag module, the ABS controller, the body computer directly and ask them questions in their own dialect.

Difficulty: high. Undocumented addressing, per-OEM services, security gates, and you are now transmitting on a live vehicle network.

Most consumer scan tools do #1. openpilot-class work lives in #2. TARS does all three, and the third one is where essentially all of the engineering difficulty sits.


Layer 0

Wires and buses — there is more than one network

The single biggest misconception: that "the OBD port" is one network. It is a connector that exposes several physically separate networks on different pins, running at different speeds, carrying different modules.

SAE J1962 · 16-pin diagnostic connector 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 HS-CAN · pins 6 & 14 · 500 kbit/s ECM, TCM, ABS, ADAS. Twisted pair. The legislated OBD-II bus. SW-CAN / LS-GMLAN · pin 1 · 33.3 kbit/s Body, cluster, radio, doors, seats. GM. Single wire, no pair. MS-CAN · pins 3 & 11 · 125 kbit/s Mid-speed body/infotainment. Ford & others. 16 = battery+
One connector, several networks. A scan tool that only wires up pins 6/14 — which is most of them — is physically incapable of seeing the modules on pin 1. This is why a generic scanner reports "no body module faults" on a GM vehicle that has plenty of them.

Why the adapter can only be on one bus at a time

A J2534 pass-thru device opens one protocol channel per physical channel. Reaching the single-wire GMLAN bus means tearing down the 500k HS-CAN channel and reconnecting with SW_CAN_PS at 33.3 kbit/s. That is a real, user-visible mode switch — TARS surfaces it explicitly rather than hiding it:

apps/diagnostic-ui/components/uds/tabs/ModuleScanTab.tsx
// One J2534 device serves one channel at a time, so reaching the single-wire
// (LS-GMLAN) modules means moving the RLink off the HS bus — a stop + restart
// with single_wire, reusing the current backend/channel.
await fetch("/api/can/start", { method: "POST", body: JSON.stringify({
  backend,
  channel,
  bitrate: wantSingleWire ? 33300 : 500000,
  listen_only: false,   // active — a scan must transmit
  single_wire: wantSingleWire,
})});

And in the sidecar, the constraint is enforced at the driver level — not every J2534 DLL even declares single-wire capability:

python/cansniffer/j2534/bus.py
if self._single_wire and not self._dev.protocols.get("SW_CAN_PS"):
    raise J2534Error(
        f'J2534 driver "{self._dev.name}" does not declare SW_CAN_PS, so it '
        "cannot open the single-wire (LS-GMLAN) bus.")
Consequence for tooling

"Scan all modules" is not one operation. It is N bus sessions, each requiring a physical reconfiguration of the adapter, with the previous bus's live data going dark while you're on the other one. Any UI that pretends otherwise is lying to the technician.

Adapter classes, and what each can actually do

ClassExampleReads raw CANTransmitsBus access
ELM327 cloneBWBWND BT, vLinkerYes, via ATMA (silent, listen-only)OBD-II and UDS through the ELM command set — but not arbitrary raw framesHS-CAN only
STN chipsetOBDLink MXYesYes, plus the richer ST command setHS + MS + SW via STP presets
Native CANPEAK PCAN, CANableYes, full rateYes, arbitrary framesWhatever you wire to
J2534 pass-thruTOPDON RLink X3YesYesHS + SW + CAN-FD, per driver

TARS treats these as peers and selects the best transport per task — see the architecture section. An ELM327 in monitor-all mode is silent: it does not transmit, and it does not even ACK frames, which makes it the safest possible thing to hang on a customer's live bus. It also means it can never run a module sweep.


Layer 1

CAN frames — broadcast to everyone, addressed to no one

A CAN frame is astonishingly small and astonishingly dumb. Everything above it is a convention layered on top by people who wanted it to do more.

Interactive · click a field
A CAN frame has three parts that matter: an arbitration ID, a length, and up to 8 data bytes. Click any field below to see what it does.
7E8Arb ID
8DLC
04D0
41D1
0CD2
0AD3
F8D4
AApad
AApad

What is not in a CAN frame

This list is the whole reason vehicle diagnostics is hard:

  • No source address. You cannot ask "who sent this."
  • No destination address. Everything is a broadcast; addressing is a convention layered on top by assigning IDs.
  • No session, no connection, no sequence numbers. Above 8 bytes, ISO-TP invents all of that.
  • No type information. Byte 3 of frame 0x3E9 means whatever the OEM's internal spec says. That spec is not public.
  • No authentication of any kind. Anything on the bus can claim to be anything.
  • No error semantics. A module that ignores you is indistinguishable from a module that isn't installed.
Scale check

A 500 kbit/s HS-CAN bus on a running car carries roughly 2,000 frames per second. TARS' frame buffer keeps 1,500 frames — about 0.75 seconds of bus — precisely because low-rate ADAS IDs would otherwise fall out of the window between decode passes.


Layer 2a · read-only

Passive listening — total visibility, zero meaning

Put the adapter in listen-only mode and you receive every frame every module broadcasts. Nothing you do can affect the vehicle. This is where Cardog's openpilot-lineage work lives, and it is a completely different problem from diagnostics.

What passive gives you
  • Every signal the vehicle broadcasts continuously — wheel speeds, steering angle, torque, pedal position, gear, door/latch states, ADAS object lists
  • High rate — 10–100 Hz per signal, versus ~2–5 Hz for polled OBD-II
  • The real internal signals, not the emissions-sanitised subset
  • Absolute safety: listen_only=True means the transceiver never drives the bus
  • Works while the car is being driven, with no interference
What passive cannot give you
  • Meaning. You get 0x3E9 → 1A 2F 00 04 ... and nothing tells you what any of it is
  • Anything a module doesn't volunteer — stored fault codes, part numbers, calibration IDs, freeze frames, adaptation values
  • Anything that requires asking — actuator tests, service resets, module programming
  • Modules that are quiet at rest. A module with nothing to broadcast is invisible
  • Identity. Two vehicles can use the same CAN ID for entirely different signals

Decoding: the actual work

Turning a passive capture into signals is a reverse-engineering problem with three standard approaches, all of which TARS implements:

ApproachHow it worksCoverage
DBC databases Community-reverse-engineered signal definitions (comma.ai's opendbc). A DBC maps CAN ID + bit range + scale + offset → named signal. Excellent for the platforms openpilot covers; nothing for the rest.
Bus fingerprinting (FPv1) Match the observed set of {can_id: dlc} against known platform fingerprints. TARS scores by coverage rather than opendbc's strict eliminate-on-mismatch, because a partial sniff won't see every ID. Identifies the platform when you don't know the vehicle. Only GM and comma-body publish these today.
Passive indexing Watch the bus for diagnostic traffic, reassemble ISO-TP, and catalogue which ECUs answered which services with which PIDs/DIDs. Builds an address book from observation alone. Learns the vehicle's real topology if something else on the bus is already asking questions.
python/cansniffer/uds/passive_index.py — output shape
ecus:
  - response_id: 0x7E8
    request_id:  0x7E0
    name: ECM
    kind: physical_11
    services:
      - sid: 0x01
        name: OBD2_ShowCurrentData
        request_count: 560
        response_count: 551
        pids:
          - pid: 0x0C
            name: EngineRPM
            sample_decoded: { value: 871.5, unit: rpm }
The mental model that matters

Passive listening is reading the vehicle's internal gossip. Active diagnostics is interviewing individual modules. They surface almost disjoint sets of information, they have completely different risk profiles, and a serious tool has to do both. Cardog's AV research stack is mostly the first; TARS is mostly the second; the interesting work is where they meet.


Layer 2b · the legislated API

OBD-II — the 5% that's guaranteed to work

OBD-II exists because of emissions regulation, not because anyone wanted to give you a diagnostic API. It is narrow, fixed, universally supported, and almost never enough.

The addressing scheme is arithmetic, and that's the whole trick

Requests go to one of nine well-known IDs; responses come back at request + 8. There is no negotiation and no discovery:

FUNCTIONAL BROADCAST — one request, every ECU may answer Tester TARS 0x7DF 02 01 0C ECM 0x7E0 TCM 0x7E1 … 0x7E7 0x7E8 04 41 0C 0A F8 0x7E9 (silent — TCM has no RPM PID) PHYSICAL ADDRESSING — one request, one named module Tester TARS 0x7E0 ECM req 0x7E0 0x7E8 = 0x7E0 + 8 The +8 rule is the ONLY reason a tester knows where to listen. Every OEM dialect reinvents this arithmetic with a different constant — see the OEM dialects section.
Functional vs physical addressing. A functional broadcast (0x7DF) asks everyone; a physical request (0x7E0–0x7E7) asks one module. Functional is how you discover the powertrain modules. As we'll see, it is also the technique that fails completely on GM body modules — which is exactly why they were invisible to generic scan tools for two decades.

The modes, and what each is actually for

ModeNameWhat you getPractical value
$01Show current data~130 defined PIDs: RPM, coolant, MAF, O2, fuel trimsThe live-data workhorse
$02Freeze frameA snapshot of Mode 01 values at the moment a DTC setHigh — tells you conditions at failure
$03Stored DTCsEmissions-related fault codes onlyLimited — misses body/chassis entirely
$06On-board monitor resultsPer-monitor test IDs with min/max/actualVery high, badly underused — catches failures before they set a code
$07Pending DTCsCodes from the current drive cycleUseful for intermittents
$09Vehicle infoVIN, calibration IDs (CALID), CVN, ECU name, IPTEssential for identity anchoring

Capability discovery: the supported-PID bitmap

OBD-II does have one genuine discovery mechanism. Ask for PID 0x00 and the ECU returns a 4-byte bitmap of which PIDs in 0x01–0x20 it supports; PID 0x20 gets you the next block, and so on. TARS walks all seven blocks per ECU:

python/cansniffer/uds/active.py · obd2_pid_scan()
ranges = [0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0]
for base in ranges:
    send_isotp(0x7DF, bytes([0x01, base]))          # functional broadcast
    # SF: data[1] = 0x41 (mode+0x40), data[2] = base, data[3..6] = bitmap A..D
    for i, b in enumerate(bitmap):
        for bit in range(8):
            if b & (0x80 >> bit):
                supported.add(base + 1 + i*8 + bit)
The ceiling

Everything above is legally mandated emissions data from the powertrain. Not one byte of it tells you about the airbag module, the ABS controller, the body computer, the transfer case, the seats, the doors, or the cluster. A no-crank complaint, a check-engine-and-no-communication complaint, a dead-battery-parasitic-draw complaint — OBD-II is silent on all of them. That is the ceiling that motivates everything that follows.


Layer 3 · transport

ISO-TP — the layer that eats engineers

A CAN frame carries 8 bytes. A VIN is 17. A DTC list can be 60. ISO 15765-2 is the segmentation-and-reassembly protocol that bridges the gap — and it is where the most instructive bug in this entire codebase lived.

Four frame types, encoded in one nibble

The first data byte of a diagnostic frame is the PCI (Protocol Control Information). Its high nibble is the frame type:

NibbleTypeLayoutMeaning
0x0SF — Single Frame0L dd dd dd dd dd dd ddWhole message fits. L = length 1–7.
0x1FF — First Frame1L LL dd dd dd dd ddStart of a multi-frame. 12-bit total length, 6 payload bytes.
0x2CF — Consecutive Frame2S dd dd dd dd dd dd ddContinuation. S = sequence 1–15, wraps to 0.
0x3FC — Flow Control3F BS STThe receiver's permission slip. F = CTS/WAIT/OVFL, BS = block size, ST = min gap.
Interactive · a multi-frame VIN read, step by step
step 0 / 5
Press Step forward to walk a 22 F1 90 (ReadDataByIdentifier — VIN) exchange frame by frame. The byte values are constructed for teaching; the framing rules are exact. Watch what the tester is obliged to do.
The bug that cost days — and it will cost yours too

ISO 15765-2 makes the receiver responsible for releasing the consecutive frames. The ECU sends the First Frame, then stops and waits ~1 second for the tester's Flow Control frame. If your code doesn't send FC, the ECU never sends the rest, reassembly never completes, and the request times out.

The pathology is diabolical, because it is selective. The comment we left in the source says it best:

python/cansniffer/uds/active.py · ActiveUdsClient.request()
# A response longer than 7 bytes arrives as First Frame + Consecutive
# Frames, and ISO 15765-2 makes the RECEIVER responsible for releasing the
# CFs: the ECU sends the FF and then waits (N_Bs, typically 1s) for our Flow
# Control before sending anything more. Without this the ECU stays silent,
# the reassembly never completes, and the request times out — which is why
# every short reply (session control, seed request, TesterPresent) worked
# while every real payload (VIN, DTC list, ECU part numbers, memory reads)
# appeared dead.
iso_frame = parse_frame(data)
if iso_frame is not None and iso_frame.kind == FrameType.FF:
    self._send_frame(physical_request_id(request_id, response_id),
                     build_flow_control(), is_extended)

Note physical_request_id(). Flow control is point-to-point, so if the original request went out as a functional broadcast (0x7DF), you cannot send the FC there — every other ECU would see it. You have to invert the responder's ID back to its physical request ID. That's a second, subtler correctness trap sitting right behind the first one.

Block size and STmin — the ECU throttles you

The FC frame carries two parameters the tester must honour when sending a long request:

  • BS (block size) — how many consecutive frames you may send before waiting for another FC. BS=0 means "send it all." Blasting past a non-zero BS overruns the ECU's buffer and the transfer is silently dropped.
  • STmin — minimum inter-frame gap. Encoded weirdly: 0x00–0x7F is milliseconds, but 0xF1–0xF9 is 100–900 microseconds. Getting that decode wrong makes you 100× too slow or 100× too fast.
  • FlowStatus WAIT (PCI byte 0x31 — frame type 3, status 1) restarts the clock: the ECU is asking for more time, not refusing. OVERFLOW (0x32) is fatal. CTS is 0x30.
Passive reassembly is a different problem

When you're sniffing rather than transacting, you don't enforce flow control — you just stitch frames back together. But a busy bus interleaves transactions on the same CAN ID. TARS' passive reassembler explicitly does not let an unrelated Single Frame flush an in-progress multi-frame transfer, because on a live bus another tool's 41 0C .. reply will land between a First Frame and its Consecutive Frames.


Layer 4 · the real protocol

UDS — sessions, security, and the honest "no"

ISO 14229 is what professional tools actually speak. It is a proper request/response protocol with an error model — and the errors are frequently more informative than the successes.

The response convention

Positive response
request  22 F1 90
response 62 F1 90 31 47 4B ...
          ↑ 0x22 + 0x40

Service ID + 0x40, then the request's identifier echoed back, then the data.

Negative response
request  22 F1 90
response 7F 22 31
          ↑    ↑   ↑ NRC: requestOutOfRange
          │    └ the service you asked for
          └ always 0x7F

This is still a successful interaction. The module is alive, it heard you, and it told you why not.

The single most useful insight in this document

A negative response is evidence of presence. A module that replies 7F 3E 12 ("sub-function not supported") has proven it exists, is powered, is on this bus, at this address, and speaks this protocol. A timeout proves nothing at all. TARS' module sweep counts both positive and negative answers as "found" for exactly this reason — and the UI distinguishes responding from present · 0x12 rather than throwing the second one away.

Services you'll meet

SIDServiceWhat it doesRisk
$10DiagnosticSessionControlSwitch to extended/programming session. Most interesting services are gated behind this.state change
$11ECUResetReboot the module.disruptive
$14ClearDiagnosticInformationErase stored DTCs.destroys evidence
$19ReadDTCInformationFault codes with status masks. The modern DTC read.read-only
$22ReadDataByIdentifierRead a 16-bit DID. VIN is $F190, part number $F187, software version $F189.read-only
$27SecurityAccessSeed/key challenge-response. Gates everything that writes.gate
$2EWriteDataByIdentifierWrite a DID — configuration, VIN, options.writes
$2FInputOutputControlByIdentifierForce an output — this is what "bidirectional control" means.actuates
$31RoutineControlRun a built-in routine: bleed ABS, relearn throttle, calibrate.actuates
$34/$36/$37Request/Transfer/ExitDownloadFirmware reprogramming.bricks modules
$3ETesterPresentKeep-alive. Actuates nothing — which makes it the ideal discovery probe.read-only

TARS' adaptive retry path treats the dangerous half of that table as off-limits by construction — a policy object, not a code review convention:

src/app/agent-runtime/services/obd2-service/AdaptiveProtocolPolicy.ts
safety: {
  // Keep high-risk UDS services out of the adaptive retry path.
  blockedServices: ['10','11','14','27','28','2E','2F','31','34','36','37','85']
}

Negative response codes — searchable

Reading NRCs fluently is most of the job. Filter the table:

NRCNameWhat it actually means in the bay
0x10generalRejectThe module refused without saying why. Usually a malformed request.
0x11serviceNotSupportedThe only NRC that means "absent." This module does not implement this service at all. TARS uses exactly this to classify a service as absent vs present-but-refused.
0x12subFunctionNotSupportedRight service, wrong sub-function. Extremely common on GMLAN-era modules — they reject 3E 00 but answer a bare 3E. Still proves presence.
0x13incorrectMessageLengthYour framing is wrong. Almost always a bug on your side, not the car's.
0x22conditionsNotCorrectRight request, wrong moment. Engine running, vehicle moving, wrong session, ignition state. GM body modules refuse DTC reads with this while the engine runs — retry key-on/engine-off.
0x24requestSequenceErrorYou skipped a step — sent a key before requesting a seed, transferred data before requesting download.
0x31requestOutOfRangeThe service exists; that identifier doesn't. Sweeping DIDs and collecting the ones that don't return 0x31 is how you map a module's data space.
0x33securityAccessDeniedYou need to pass seed/key first. The module is telling you the door exists and is locked.
0x35invalidKeyYour seed/key algorithm is wrong. Repeated failures usually trigger a lockout timer.
0x37requiredTimeDelayNotExpiredThe anti-bruteforce lockout is active. Back off.
0x78responsePendingNot an error. "Still working, don't time out." The tester must extend its deadline from P2 (1 s) to P2* (5 s) and keep waiting.
0x7FserviceNotSupportedInActiveSessionThe service exists but you're in the default session. Send $10 03 first.
Timing is protocol

ISO 14229-2 puts P2server at 50 ms and P2*server at 5 s, but a real tester needs slack for adapter latency and bus load — TARS uses a 1 s client-side P2 and the standard 5 s P2* after an NRC 0x78 (P2_CAN_DEFAULT / P2_STAR_CAN_DEFAULT). Get this wrong and modules that were working perfectly appear dead — a long DTC read or a memory dump will legitimately spend seconds in responsePending before answering.


Layer 5 · where the complexity really lives

OEM dialects — same wire, four different address books

This is the part nobody warns you about. ISO 15765 standardised the powertrain slots and nothing else. Every manufacturer built their body/chassis network on their own addressing scheme, and several predate the standard entirely.

Four arithmetic rules, and none of them is discoverable

Each scheme relates a request ID to a response ID by fixed arithmetic. Knowing the rule is the difference between finding a module and concluding the bus is empty:

Interactive · address arithmetic
try 0x7E0 · 0x241 · 0x713 · 0x18DA10F1
SchemeFunctionalPhysical requestResponse ruleWho uses it
ISO 15765-4
11-bit normal
0x7DF0x7E0–0x7E7request + 8 Everyone, for legislated powertrain
ISO 15765
29-bit normal-fixed
0x18DB33F10x18DA<TA>F1swap last two bytes
→ 0x18DAF1<TA>
Ford F-series, late Stellantis
GMLAN legacy
GM Global A
0x1010x241–0x25Frequest + 0x400 GM body & chassis — the ones OBD-II never reaches
VAG 11-bit —0x700–0x774request + 0x6A VW / Audi / Škoda / SEAT body, chassis, infotainment

These live in one place in the codebase, with the reasoning attached — because six months later nobody remembers why 0x400 and not 0x8:

python/cansniffer/uds/addressing.py
GMLAN legacy (GM Global A body/chassis modules — the ones ISO 15765 never
reaches, which is why a plain OBD-II scan sees only powertrain):
    Functional (all-node) request: 0x101
    Physical request:              0x241..0x25F   (GM assigns per module)
    Physical response:             request + 0x400 (0x641..0x65F)

The +0x400 relationship is GMLAN's analogue of ISO's +8: it is arithmetic, so
we can invert a response id to its request id without a lookup. Which module
sits at which 0x24x slot is NOT arithmetic — GM assigns it per vehicle — so the
named pairs below are documented Global A defaults, confirmed or corrected by
live discovery.
Read that last sentence again

The arithmetic is universal; the assignment is per-vehicle. Address 0x241 is the BCM on a 2013 SRX. On a different GM platform it might be something else. That distinction — arithmetic you can compute vs. assignment you must verify — is the design principle that separates a tool that works on one car from a tool that works on a fleet.

Different dialects also mean different services

GMLAN-era modules predate UDS and use GM's own service set. Same bus, same ISO-TP, entirely different service IDs for the same conceptual operation:

OperationUDS (ISO 14229)GM legacy (GMLAN)Notes
Read an identifier$22 <16-bit DID>$1A <8-bit DID>Different width, different namespace, different response layout
Read fault codes$19 <subfunc> <mask>$A9 81 <mask>GM's is readStatusOfDTCByStatusMask
Live data stream$22 / $2A$AA (readDataByPacketIdentifier)GM's is a subscription model, not a poll
Keep-alive$3E 00$3E (bare, no sub-function)Critical: many GMLAN modules reject 3E 00 with NRC 0x12

The identity block is the practical payoff. GM modules publish who built them and what they are via $1A:

python/cansniffer/uds/active.py · _GM_DETAIL_DIDS
(0xB0, "GM module id")            (0x9A, "Hardware version")
(0x90, "VIN (as stored)")         (0x9B, "Software module id")
(0x92, "Supplier + base part")    (0x9C, "Boot software id")
(0x97, "End-model part number")   (0xB4, "ECU serial (traceability)")
(0x98, "Diagnostic address / mfg") (0xC1, "Calibration / build")
(0x99, "Manufacturing date")      (0xCB, "ECU diagnostic spec")

VAG modules publish the same class of information through standard UDS DIDs ($F187 part number, $F189 software version, $F197 system name). TARS tries both conventions on every responder and keeps whichever answers — a module only speaks one of them, so the empty one simply drops out.


Layer 6 · the hard part

Module discovery — nmap, but the network can hurt someone

There is no registry. Nothing on the vehicle will tell you which modules are installed. You find out by asking, one address at a time, and interpreting silence correctly.

Why the obvious approach fails

The textbook method is a functional broadcast: shout 3E 00 at 0x101 and collect whoever answers. On GMLAN this returns nothing, and it took real bench time to understand why:

Functional broadcast · fails
tester → 0x101 3E 00
(1.5 s of silence)
→ 0 responders

GMLAN modules routinely suppress the positive response to a functional TesterPresent. And many reject the 00 sub-function outright with NRC 0x12. Two independent reasons for the same empty result.

Physical sweep · works
tester → 0x241 3E
0x641  → 7E        (positive)
tester → 0x242 3E
0x642  → 7E
... one unicast per id ...

A bare $3E sent physically to each ID in the range. Modules that ignore the broadcast answer a unicast probe on their own address immediately.

python/cansniffer/uds/active.py · sweep_modules()
"""Discover modules by PHYSICALLY addressing each request id in a range.

This is how a scan tool actually finds GM body/chassis modules, and it
is more reliable than a functional broadcast for GMLAN: those modules
commonly suppress the positive response to a functional TesterPresent
(so 0x101 enumeration comes back empty) yet answer a physical request on
their own id immediately.

Read-only: bare TesterPresent actuates nothing."""
for req in range(start_id, end_id + 1):
    resp_id = infer_response_id(req)          # req + 0x400 for GMLAN
    res = self.request(req, b"\x3e", response_id=resp_id, timeout=0.5)
    if res.timed_out: continue
    # a negative response still proves the module is present

The correlation problem — and a bug worth stealing

Here is a failure mode that will bite anyone building this. Response CAN IDs are shared. Every tester on the bus gets its Mode 01 answers from 0x7E8. If your own app is polling live data in another thread — or the shop's other scan tool is plugged in — a frame arriving at the ID you're watching is not necessarily your answer.

Observed live

A request for 01 05 (coolant temperature) returned 41 01 .. (monitor status) — because a concurrent poller happened to be mid-transaction. The service ID matched perfectly. Only the echoed identifier revealed the mismatch.

The fix is a per-service table of how many bytes a positive response echoes back, and refusing to accept anything that doesn't match:

python/cansniffer/uds/active.py · _RESPONSE_ECHO_LEN
0x01: 1,  # OBD-II Mode 01 — PID
0x02: 2,  # Mode 02 freeze frame — PID + frame number
0x1A: 1,  # GM legacy ReadDataByIdentifier — identifier
0x22: 2,  # ReadDataByIdentifier — DID
0x31: 3,  # RoutineControl — controlType + routine id
# 0 (or absent) means the service echoes nothing identifying, so
# service-level correlation is all that is available — that is the honest
# default for the transfer services, not an oversight.

There's a further subtlety: the suppressPosRspMsgIndication bit (0x80) lives in the sub-function byte of the request and is not echoed back, so it has to be masked out of the comparison for services like $10, $11, $19, $27, $3E.

From "something answered" to "this is an ABS module"

A responding address is not an identification. TARS separates hints from evidence, and this is the design pattern to carry into any fleet-scale tooling:

HINT · a guess from a table Seeded address book 0x245 labelled "EBCM?" — convention only Convention. Can be wrong. probe PRESENCE · it answered Physical $3E sweep 0x245 answered. So did 0x252. Two candidates. Neither is proven. $1A EVIDENCE · it told us Identity read 0x245 is id 0x59; the EBCM is 0x252, id 0x8F The guessed slot was the wrong one. The rule, from the source: "a CAN-id label is only a hint. The authoritative identity comes from each module's own $1A block (supplier at $1A92, GM module id at $1A B0) — the sweep reads it with identify=True, and that is what corrects a hint the vehicle disproves."
Hint → presence → evidence. Every scan-tool vendor that ships a static address table per vehicle is stuck at step one. Reading identity turns a guess into a fact, and it is what lets the same code work on a platform nobody has characterised yet — on the SRX, 0x254 had no address-book entry at all and the vehicle named itself SIEM 0300.

Capability probing without touching anything

Once a module is found, TARS classifies which services it answers — using NRCs as a read-only probe. Nothing is actuated; the module's refusal is the measurement:

python/cansniffer/uds/active.py · read_module_detail()
def _service_state(payload):
    res = self.request(request_id, payload, ...)
    if res.timed_out:   return "no-response"
    if res.is_negative:
        return "absent" if res.nrc == 0x11 else f"present (NRC 0x{res.nrc:02X})"
    return "present"

services = {
  "dtc_read_A9":       _service_state(b"\xA9\x81\x12"),
  "read_data_by_packet_AA": _service_state(b"\xAA"),
  "read_data_by_id_22":  _service_state(b"\x22\xF1\x90"),
}

Only NRC 0x11 means "this module doesn't have that service." Every other NRC means the service exists and the module declined this particular call — which is a completely different fact, and one that a naive implementation throws away.


Worked example · real capture

Walkthrough: sweeping a 2013 Cadillac SRX

Everything above, applied. This is real output from the TARS Module Scan against the LS-GMLAN single-wire bus — the bus a generic scan tool cannot physically reach.

What the tool had to do before a single probe went out

  1. Check the capture's current state — which backend, which channel, which bus, listen-only or active.
  2. Recognise that the app's own bring-up leaves the capture passive, and a sweep must transmit.
  3. Stop the HS-CAN capture, restart on single_wire at 33,300 bit/s with listen_only: false.
  4. Verify the J2534 driver declares SW_CAN_PS — not every PassThru DLL does, and the sidecar refuses rather than silently opening the wrong bus.
  5. Warn the operator that the HS-CAN view is now dark.
  6. Then, and only then, send 31 unicast TesterPresent probes across 0x241–0x25F.
The banner the operator sees

Switched the adapter to the single-wire bus (active mode) — the HS-CAN view is paused until you switch back.

Module Scan · GM body / chassis on single-wire
0x241–0x25F on LS-GMLAN · probe 3E (bare TesterPresent) · identify=true · read-only
Idle — press replay to walk the address range.
Real capture data. Rows appear in probe order.

Reading the results

Thirteen modules answered on a bus that OBD-II describes as not existing. Three findings are worth pulling out:

Confirmed hints

0x241 → 0x641, GM module id 0x40. The address book said BCM; the identity read agrees. The hint is now evidence, and the source comment records it: "confirmed on 2013 SRX."

Named by the vehicle, not the table

0x254 → 0x654 had no entry in the address book at all. The $1A 92 supplier read came back SIEM 0300 — a Siemens module. The vehicle named a module our table had never heard of.

Still unknown, honestly

0x242, 0x246, 0x24B answered with GM ids 0xAF, 0x66, 0xBC but no supplier string. They're real, they're powered, and we don't know what they are. The UI says "Unknown module" rather than guessing.

Drilling into one module: the SDM at 0x243

Selecting a module runs the full $1A detail block plus a capability probe. The airbag module (Sensing & Diagnostic Module) returned this:

Identity — read via $1A
GM module id0x28
Diagnostic address / mfg504352415…
Manufacturing date20121113
Hardware version6
Calibration / build015D9496
ECU diagnostic spec015D943E
Capabilities — inferred from NRCs
DTCs ($A9)no-response
Data by DID ($22)present · NRC 0x31
Live data ($AA)present · NRC 0x12

Fault codes: not available — no response to $A9 81.

An engineer new to this reads that right-hand column as three failures. It is the opposite — it's three distinct, actionable facts:

ResultNaive readingCorrect reading
$22 → NRC 0x31 "DID read broken" The service works. requestOutOfRange means $F190 specifically isn't implemented here — this module doesn't store the VIN. Sweeping the DID space is now worth doing: whichever identifiers come back with something other than 0x31 are this module's real data map.
$AA → NRC 0x12 "Live data unsupported" The service exists. subFunctionNotSupported means our packet-identifier sub-function was wrong — the module wants a different one. Worth iterating.
$A9 → no response "Module is dead" Genuinely inconclusive — the one result that is ambiguous. Could be session-gated, could need key-on/engine-off, could be security-gated. Note it, don't invent it.
Why this matters commercially

A generic OBD-II scanner on this vehicle reaches the legislated powertrain slots and their emissions codes, and nothing else. TARS reports thirteen modules on this bus alone — plus the ISO powertrain slots on the other one — each carrying at minimum its GM module id, several carrying a supplier string, and any of them drillable for hardware revision, manufacturing date, calibration ID and a per-service capability map. Same $40 connector, same car. The difference is entirely in the layers described above.


System design

How TARS is put together

Everything above has to be reachable by a voice agent mid-conversation, on a laptop, in a bay, over one of five possible physical transports. The architecture exists to make that tractable.

PHYSICAL ELM327 / vLinker OBDLink MX (STN) PEAK / CANable J2534 (RLink X3) BLE (mobile) I/O OWNERS · two processes, deliberately Electron main process OBD2Manager · ecuRuntime · GMUdsExecutor · IsoTpLayer · udsFacade Owns serial / BLE / TCP. ELM327 AT+ST command layer. OBD2ConcurrencyManager serialises one link into prioritised lanes: diagnostic vs live-stream. Python CAN sidecar (cansniffer) FrameBus pub/sub · ActiveUdsClient · IsoTpReassembler Owns native CAN & J2534. Raw capture, DBC decode, module sweep, DID sweep, security access. Binds 127.0.0.1:8765. Ships as a PyInstaller exe. LOCAL API SURFACE IPC bridge · token-authenticated /adapter/status · /state · /obd2/* · /uds/* · /gm/* · /mode06/* · /transports Next.js routes · /api/can/* start · stop · status · stream · uds/module-sweep · uds/module-detail ONE DECLARATIVE TOOL TABLE — the anti-drift layer mcp/tars-car/toolTable.js 25 vehicle tools. Each entry is a pure request(args) BUILDER, not a call() that performs I/O — so the table describes WHAT to ask for, and each consumer owns HOW the request travels. CONSUMERS MCP server (node) Serves the same tools to Claude / any MCP client. Executes over HTTP to bridge or app server. Realtime voice agent + workspace UI Same entries → typed function tools over WebRTC. Drives live charts, diagnosis cards, the 3D digital twin.
Two I/O owners, one tool table. The split is not accidental: the Electron main process owns the serial/BLE adapter because only it can, and the Python sidecar owns native CAN and J2534 because python-can and the PassThru DLLs live there. Neither the MCP server nor the voice agent touches hardware — they are clients of the app.

Why a single tool table matters

The same 25 operations have to be callable from a Claude MCP client, from an OpenAI realtime voice agent, and from React panels. Three copies of those schemas is three chances to drift. The file's own header states the constraint:

mcp/tars-car/toolTable.js
// That is why each entry carries a pure `request(args)` builder instead of a
// `call(args)` that performs I/O: the table describes WHAT to ask for
// (backend, method, path, body, timeout) and each consumer owns HOW the
// request travels. Keep this file free of imports and side effects — it must
// load in node and in the browser bundle alike, and a second copy of these
// schemas is exactly the drift this table exists to prevent.
Bridge-backed tools (serial / BLE adapter)

car_status · app_state · car_read_dtcs · car_readiness · car_read_pid · car_read_pids · car_obd_command · car_uds_read_did · car_uds_session · car_uds_send · car_uds_routine · car_clear_dtcs · car_list_actuators · car_run_actuator · car_mode06_captured · car_mode06_run · car_freeze_frame · car_transports

Sidecar-backed tools (native CAN / J2534)

can_status · can_active_ids · can_enumerate_modules · can_uds_send · can_uds_did_sweep · can_module_sweep · can_module_detail

Note the asymmetry. Anything requiring raw frame access or a bus switch has to be sidecar-side — an ELM327 in monitor mode structurally cannot do it.

The transport abstraction

A technician says "pull the codes." The agent calls one tool. Underneath, that might be Web Bluetooth in a browser, a serial port in the Electron main process, a TCP socket to a Wi-Fi gateway, or a J2534 DLL — and the answer has to look identical. Two mechanisms make that work:

  • Unified service API. A single diagnostic interface presents the same methods regardless of which transport is live; adapters are peers, and the best one for the task is selected rather than hard-coded.
  • Single-port handoff. A vLinker/ELM327 speaks over one COM port. TARS releases the OBD-II link before handing that same port to the CAN sidecar for raw monitoring, then takes it back. Two processes, one physical resource, explicit ownership transfer.
Concurrency is not optional here

A single serial or GATT link is strictly serial, but the app wants to stream live PIDs continuously while a DTC scan runs while the agent issues a UDS read. The concurrency manager serialises all of it into prioritised lanes. Without that, requests interleave on the wire and you get exactly the mis-correlation bug described in the discovery section — but now caused by your own app.


The honest assessment

Why this is genuinely hard

Not "hard" as in tedious. Hard as in the problem has properties that most software problems don't.

Effort does not track coverage

The legislated 5% of vehicle data is the easy 5%. Everything past it is bespoke per manufacturer, per platform, per model year:

Data reachable vs. engineering required
OBD-II Mode 01/03/09
standardised, guaranteed
~5%
+ Mode 06 & freeze frame
standardised, poorly documented
~10%
+ Passive CAN with DBC
reverse-engineered per platform
~35%
+ UDS on non-powertrain
per-OEM addressing & services
~70%
+ Security access & bidirectional
seed/key per module family
~95%

Illustrative proportions of the diagnostically useful data on a typical 2013 GM vehicle. The shape is the point: the first bar is a weekend, the last bar is the entire product.

The nine properties that make it hard

PropertyWhy it hurts
No service discoveryNothing enumerates the modules. You probe an address range and interpret silence. There is no /.well-known.
Silence is ambiguousNo response = not installed, or asleep, or wrong bus, or wrong sub-function, or busy, or your framing was wrong. Six causes, one symptom.
Shared response channelsResponse IDs carry every tester's traffic. Correlation must be done in software, per service, or you report another tool's answer as your own.
Undocumented by designOEM service sets and DID namespaces are trade secrets. Coverage is built by observation, community knowledge, and careful probing.
Fragmentation is the normFour address arithmetics, two DID namespaces, two DTC services — before you count model-year variations within one manufacturer.
One channel per adapterMulti-bus vehicles need sequential bus sessions with the previous bus's data going dark. Not a parallelisable problem.
Timing is semanticsP2/P2*, N_Bs, STmin, block size. Getting timing wrong doesn't degrade performance — it produces wrong answers that look like absent hardware.
Real physical consequences$2F actuates. $31 runs routines. $14 destroys evidence. $34/$36/$37 can brick a module. Safety has to be structural, not a code review note.
Security by obscurity, enforcedSeed/key gates every write path. Algorithms are per module family, unpublished, and repeated failures trigger lockout timers.

What "read-only" has to mean architecturally

Three independent mechanisms, because one is not enough:

  1. Probe choice. The sweep uses a bare $3E TesterPresent. It cannot actuate anything, by construction. Not "we're careful" — the payload has no destructive interpretation.
  2. Policy blocklist. Twelve high-risk service IDs are excluded from the adaptive retry path in configuration, not in a comment.
  3. Honest reporting. DTC reads are attempted and the exact outcome is reported — positive, NRC, or timeout — rather than assumed. From the source: "GMLAN body modules commonly refuse a DTC read with conditionsNotCorrect while the engine runs, and saying so is more useful than pretending it worked."
What I'd want the team to take away

The hard part was never "talk to a car." Reading RPM off an ELM327 is an afternoon. The hard part is building something that stays correct when the vehicle disagrees with your assumptions — an address book that gets corrected by identity reads, a correlation layer that refuses a plausible-but-wrong answer, a capability map derived from refusals, and a UI that says "unknown module" instead of guessing. Every one of those is a decision to prefer an honest gap over a confident fabrication. On a system where a wrong answer sends a technician down a four-hour diagnostic path, that trade is not close.


Reference

Glossary

CANController Area Network. The physical/data-link bus. Broadcast, priority-arbitrated, 8 data bytes per frame.
Arbitration IDThe 11- or 29-bit label on a CAN frame. A message-type identifier and bus priority — not an address.
HS-CANHigh-speed CAN. 500 kbit/s, twisted pair, J1962 pins 6 & 14. Powertrain, chassis, ADAS.
SW-CAN / LS-GMLANSingle-wire CAN. 33.3 kbit/s, J1962 pin 1. GM body, cluster, radio, doors.
MS-CANMid-speed CAN. 125 kbit/s, pins 3 & 11. Ford and others.
ISO-TPISO 15765-2. Segmentation, reassembly and flow control for payloads > 7 bytes.
PCIProtocol Control Information — the first byte of an ISO-TP frame; its high nibble is the frame type.
SF / FF / CF / FCSingle Frame, First Frame, Consecutive Frame, Flow Control.
BS / STminBlock size and minimum inter-frame gap, dictated by the receiver in its Flow Control frame.
UDSUnified Diagnostic Services, ISO 14229. The modern diagnostic protocol.
SIDService Identifier. Positive response = SID + 0x40.
NRCNegative Response Code. Delivered as 7F <sid> <nrc>. Proof of presence.
DIDData Identifier. 16-bit under UDS ($22), 8-bit under GM legacy ($1A).
PIDParameter Identifier. The OBD-II Mode 01 data points.
DTCDiagnostic Trouble Code. Read via $19 (UDS), $A9 (GM legacy), or Mode $03 (OBD-II).
Functional addressingBroadcast request — 0x7DF (ISO 11-bit), 0x18DB33F1 (29-bit), 0x101 (GMLAN).
Physical addressingUnicast to one module's own request ID. What actually finds GMLAN modules.
TesterPresent$3E. Keep-alive that actuates nothing — the safe discovery probe. GMLAN modules often want it bare, without the 00 sub-function.
SecurityAccess$27. Seed/key challenge-response gating write and actuation services.
P2 / P2*UDS response timeouts. ISO 14229-2 specifies 50 ms / 5 s server-side; TARS uses a 1 s client-side P2 and the standard 5 s P2* after NRC 0x78.
J1962The 16-pin diagnostic connector itself.
J2534SAE pass-thru standard. A vendor DLL exposing PassThruConnect/ReadMsgs/WriteMsgs — how a PC drives an OEM-grade interface.
ELM327 / STNSerial OBD adapter chipsets driven by AT (and ST) commands. STN devices add bus switching.
DBCA CAN signal database — maps CAN ID + bit range + scale to named signals. comma.ai's opendbc is the open corpus.
GMLANGM's legacy diagnostic addressing and service set for body/chassis modules.
SDM / BCM / EBCM / IPC / PSCMSensing & Diagnostic Module (airbag), Body Control Module, Electronic Brake Control Module, Instrument Panel Cluster, Power Steering Control Module.

Reference

Source map — where each layer lives in the repo

The protocol behaviour, addresses, service tables and code quotations in this document trace to these files in versions/openai-realtime-agents.

ConcernFile
Address books, all four OEM schemes, the +8 / +0x400 / +0x6A arithmeticpython/cansniffer/uds/addressing.py
Active UDS client — request correlation, flow control, module sweep, identity reads, capability probing, security accesspython/cansniffer/uds/active.py
ISO-TP parse / reassemble / build (sidecar side)python/cansniffer/uds/iso_tp.py
ISO-TP layer for the ELM327 text protocol (app side)src/app/agent-runtime/services/obd2-service/gm/IsoTpLayer.ts
Passive catalogue builderpython/cansniffer/uds/passive_index.py
Platform fingerprinting & DBC selectionpython/cansniffer/uds/fingerprint.py
J2534 PassThru bindings, single-wire channel bring-uppython/cansniffer/j2534/bus.py · defs.py · registry.py
Frame bus, transport backends (PCAN / SocketCAN / gs_usb / slcan / ELM327 / J2534)python/cansniffer/web/sources.py
Sidecar proxy routessrc/app/api/can/**
Module Scan UI, bus switching, result renderingapps/diagnostic-ui/components/uds/tabs/ModuleScanTab.tsx
Module detail view & UDS response decodingapps/diagnostic-ui/components/uds/ModuleDetailView.tsx · decodeUdsResponse.ts · parseNrc.ts
Known ECU address presets (11-bit and 29-bit) used by the UIapps/diagnostic-ui/components/uds/presets/ecuAddresses.ts
GM UDS executor, seed/key, safety layer, actuator registrysrc/app/agent-runtime/services/obd2-service/gm/**
Adaptive protocol policy & blocked-service listsrc/app/agent-runtime/services/obd2-service/AdaptiveProtocolPolicy.ts
Electron main-process I/O owner & UDS facadeelectron/services/obd2/OBD2Manager.js · udsFacade.js · cansnifferService.js
The single declarative tool table (MCP + realtime agent)mcp/tars-car/toolTable.js · backends.js

Prepared for the Cardog team · August 2026.
The module list, GM module ids, supplier strings and the SDM identity and capability block in the walkthrough are from a live capture on a 2013 Cadillac SRX. The CAN-frame inspector, the OBD-II addressing diagrams, the ISO-TP stepper and the passive-index sample are constructed for teaching: the framing and addressing rules in them are exact, the specific byte values are illustrative.