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.
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:
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.
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.
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.
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.
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:
// 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:
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.")
"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
| Class | Example | Reads raw CAN | Transmits | Bus access |
|---|---|---|---|---|
| ELM327 clone | BWBWND BT, vLinker | Yes, via ATMA (silent, listen-only) | OBD-II and UDS through the ELM command set — but not arbitrary raw frames | HS-CAN only |
| STN chipset | OBDLink MX | Yes | Yes, plus the richer ST command set | HS + MS + SW via STP presets |
| Native CAN | PEAK PCAN, CANable | Yes, full rate | Yes, arbitrary frames | Whatever you wire to |
| J2534 pass-thru | TOPDON RLink X3 | Yes | Yes | HS + 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.
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.
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.
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.
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.
- 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=Truemeans the transceiver never drives the bus - Works while the car is being driven, with no interference
- 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:
| Approach | How it works | Coverage |
|---|---|---|
| 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. |
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 }
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.
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:
The modes, and what each is actually for
| Mode | Name | What you get | Practical value |
|---|---|---|---|
| $01 | Show current data | ~130 defined PIDs: RPM, coolant, MAF, O2, fuel trims | The live-data workhorse |
| $02 | Freeze frame | A snapshot of Mode 01 values at the moment a DTC set | High — tells you conditions at failure |
| $03 | Stored DTCs | Emissions-related fault codes only | Limited — misses body/chassis entirely |
| $06 | On-board monitor results | Per-monitor test IDs with min/max/actual | Very high, badly underused — catches failures before they set a code |
| $07 | Pending DTCs | Codes from the current drive cycle | Useful for intermittents |
| $09 | Vehicle info | VIN, calibration IDs (CALID), CVN, ECU name, IPT | Essential 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:
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)
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.
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:
| Nibble | Type | Layout | Meaning |
|---|---|---|---|
| 0x0 | SF — Single Frame | 0L dd dd dd dd dd dd dd | Whole message fits. L = length 1–7. |
| 0x1 | FF — First Frame | 1L LL dd dd dd dd dd | Start of a multi-frame. 12-bit total length, 6 payload bytes. |
| 0x2 | CF — Consecutive Frame | 2S dd dd dd dd dd dd dd | Continuation. S = sequence 1–15, wraps to 0. |
| 0x3 | FC — Flow Control | 3F BS ST | The receiver's permission slip. F = CTS/WAIT/OVFL, BS = block size, ST = min gap. |
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.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:
# 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=0means "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–0x7Fis milliseconds, but0xF1–0xF9is 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 is0x30.
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.
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
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.
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.
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
| SID | Service | What it does | Risk |
|---|---|---|---|
| $10 | DiagnosticSessionControl | Switch to extended/programming session. Most interesting services are gated behind this. | state change |
| $11 | ECUReset | Reboot the module. | disruptive |
| $14 | ClearDiagnosticInformation | Erase stored DTCs. | destroys evidence |
| $19 | ReadDTCInformation | Fault codes with status masks. The modern DTC read. | read-only |
| $22 | ReadDataByIdentifier | Read a 16-bit DID. VIN is $F190, part number $F187, software version $F189. | read-only |
| $27 | SecurityAccess | Seed/key challenge-response. Gates everything that writes. | gate |
| $2E | WriteDataByIdentifier | Write a DID — configuration, VIN, options. | writes |
| $2F | InputOutputControlByIdentifier | Force an output — this is what "bidirectional control" means. | actuates |
| $31 | RoutineControl | Run a built-in routine: bleed ABS, relearn throttle, calibrate. | actuates |
| $34/$36/$37 | Request/Transfer/ExitDownload | Firmware reprogramming. | bricks modules |
| $3E | TesterPresent | Keep-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:
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:
| NRC | Name | What it actually means in the bay |
|---|---|---|
| 0x10 | generalReject | The module refused without saying why. Usually a malformed request. |
| 0x11 | serviceNotSupported | The 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. |
| 0x12 | subFunctionNotSupported | Right service, wrong sub-function. Extremely common on GMLAN-era modules — they reject 3E 00 but answer a bare 3E. Still proves presence. |
| 0x13 | incorrectMessageLength | Your framing is wrong. Almost always a bug on your side, not the car's. |
| 0x22 | conditionsNotCorrect | Right 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. |
| 0x24 | requestSequenceError | You skipped a step — sent a key before requesting a seed, transferred data before requesting download. |
| 0x31 | requestOutOfRange | The 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. |
| 0x33 | securityAccessDenied | You need to pass seed/key first. The module is telling you the door exists and is locked. |
| 0x35 | invalidKey | Your seed/key algorithm is wrong. Repeated failures usually trigger a lockout timer. |
| 0x37 | requiredTimeDelayNotExpired | The anti-bruteforce lockout is active. Back off. |
| 0x78 | responsePending | Not 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. |
| 0x7F | serviceNotSupportedInActiveSession | The service exists but you're in the default session. Send $10 03 first. |
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.
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:
| Scheme | Functional | Physical request | Response rule | Who uses it |
|---|---|---|---|---|
| ISO 15765-4 11-bit normal |
0x7DF | 0x7E0–0x7E7 | request + 8 | Everyone, for legislated powertrain |
| ISO 15765 29-bit normal-fixed |
0x18DB33F1 | 0x18DA<TA>F1 | swap last two bytes → 0x18DAF1<TA> |
Ford F-series, late Stellantis |
| GMLAN legacy GM Global A |
0x101 | 0x241–0x25F | request + 0x400 | GM body & chassis — the ones OBD-II never reaches |
| VAG 11-bit | — | 0x700–0x774 | request + 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:
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.
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:
| Operation | UDS (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:
(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.
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:
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.
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.
"""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.
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:
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:
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:
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.
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
- Check the capture's current state — which backend, which channel, which bus, listen-only or active.
- Recognise that the app's own bring-up leaves the capture passive, and a sweep must transmit.
- Stop the HS-CAN capture, restart on
single_wireat 33,300 bit/s withlisten_only: false. - Verify the J2534 driver declares
SW_CAN_PS— not every PassThru DLL does, and the sidecar refuses rather than silently opening the wrong bus. - Warn the operator that the HS-CAN view is now dark.
- Then, and only then, send 31 unicast TesterPresent probes across
0x241–0x25F.
Switched the adapter to the single-wire bus (active mode) — the HS-CAN view is paused until you switch back.
Reading the results
Thirteen modules answered on a bus that OBD-II describes as not existing. Three findings are worth pulling out:
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."
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.
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:
| GM module id | 0x28 |
| Diagnostic address / mfg | 504352415… |
| Manufacturing date | 20121113 |
| Hardware version | 6 |
| Calibration / build | 015D9496 |
| ECU diagnostic spec | 015D943E |
| 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:
| Result | Naive reading | Correct 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. |
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.
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.
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:
// 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.
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
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.
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.
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:
standardised, guaranteed
standardised, poorly documented
reverse-engineered per platform
per-OEM addressing & services
seed/key per module family
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
| Property | Why it hurts |
|---|---|
| No service discovery | Nothing enumerates the modules. You probe an address range and interpret silence. There is no /.well-known. |
| Silence is ambiguous | No 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 channels | Response 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 design | OEM service sets and DID namespaces are trade secrets. Coverage is built by observation, community knowledge, and careful probing. |
| Fragmentation is the norm | Four address arithmetics, two DID namespaces, two DTC services — before you count model-year variations within one manufacturer. |
| One channel per adapter | Multi-bus vehicles need sequential bus sessions with the previous bus's data going dark. Not a parallelisable problem. |
| Timing is semantics | P2/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, enforced | Seed/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:
- Probe choice. The sweep uses a bare
$3ETesterPresent. It cannot actuate anything, by construction. Not "we're careful" — the payload has no destructive interpretation. - Policy blocklist. Twelve high-risk service IDs are excluded from the adaptive retry path in configuration, not in a comment.
- 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."
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.
Glossary
| CAN | Controller Area Network. The physical/data-link bus. Broadcast, priority-arbitrated, 8 data bytes per frame. |
| Arbitration ID | The 11- or 29-bit label on a CAN frame. A message-type identifier and bus priority — not an address. |
| HS-CAN | High-speed CAN. 500 kbit/s, twisted pair, J1962 pins 6 & 14. Powertrain, chassis, ADAS. |
| SW-CAN / LS-GMLAN | Single-wire CAN. 33.3 kbit/s, J1962 pin 1. GM body, cluster, radio, doors. |
| MS-CAN | Mid-speed CAN. 125 kbit/s, pins 3 & 11. Ford and others. |
| ISO-TP | ISO 15765-2. Segmentation, reassembly and flow control for payloads > 7 bytes. |
| PCI | Protocol Control Information — the first byte of an ISO-TP frame; its high nibble is the frame type. |
| SF / FF / CF / FC | Single Frame, First Frame, Consecutive Frame, Flow Control. |
| BS / STmin | Block size and minimum inter-frame gap, dictated by the receiver in its Flow Control frame. |
| UDS | Unified Diagnostic Services, ISO 14229. The modern diagnostic protocol. |
| SID | Service Identifier. Positive response = SID + 0x40. |
| NRC | Negative Response Code. Delivered as 7F <sid> <nrc>. Proof of presence. |
| DID | Data Identifier. 16-bit under UDS ($22), 8-bit under GM legacy ($1A). |
| PID | Parameter Identifier. The OBD-II Mode 01 data points. |
| DTC | Diagnostic Trouble Code. Read via $19 (UDS), $A9 (GM legacy), or Mode $03 (OBD-II). |
| Functional addressing | Broadcast request — 0x7DF (ISO 11-bit), 0x18DB33F1 (29-bit), 0x101 (GMLAN). |
| Physical addressing | Unicast 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. |
| J1962 | The 16-pin diagnostic connector itself. |
| J2534 | SAE pass-thru standard. A vendor DLL exposing PassThruConnect/ReadMsgs/WriteMsgs — how a PC drives an OEM-grade interface. |
| ELM327 / STN | Serial OBD adapter chipsets driven by AT (and ST) commands. STN devices add bus switching. |
| DBC | A CAN signal database — maps CAN ID + bit range + scale to named signals. comma.ai's opendbc is the open corpus. |
| GMLAN | GM's legacy diagnostic addressing and service set for body/chassis modules. |
| SDM / BCM / EBCM / IPC / PSCM | Sensing & Diagnostic Module (airbag), Body Control Module, Electronic Brake Control Module, Instrument Panel Cluster, Power Steering Control Module. |
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.
| Concern | File |
|---|---|
| Address books, all four OEM schemes, the +8 / +0x400 / +0x6A arithmetic | python/cansniffer/uds/addressing.py |
| Active UDS client — request correlation, flow control, module sweep, identity reads, capability probing, security access | python/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 builder | python/cansniffer/uds/passive_index.py |
| Platform fingerprinting & DBC selection | python/cansniffer/uds/fingerprint.py |
| J2534 PassThru bindings, single-wire channel bring-up | python/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 routes | src/app/api/can/** |
| Module Scan UI, bus switching, result rendering | apps/diagnostic-ui/components/uds/tabs/ModuleScanTab.tsx |
| Module detail view & UDS response decoding | apps/diagnostic-ui/components/uds/ModuleDetailView.tsx · decodeUdsResponse.ts · parseNrc.ts |
| Known ECU address presets (11-bit and 29-bit) used by the UI | apps/diagnostic-ui/components/uds/presets/ecuAddresses.ts |
| GM UDS executor, seed/key, safety layer, actuator registry | src/app/agent-runtime/services/obd2-service/gm/** |
| Adaptive protocol policy & blocked-service list | src/app/agent-runtime/services/obd2-service/AdaptiveProtocolPolicy.ts |
| Electron main-process I/O owner & UDS facade | electron/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.