Reading Toyota hybrid DTC sub-codes (INF codes) over a plain ELM327
Everything here targets Gen2 Toyota Prius (2004–2009) and the phase-4 Toyota/Lexus hybrid ECU family it belongs to. Hardware is any ELM327 — Bluetooth SPP, Wi-Fi TCP, or USB. No dealer tool, no proprietary dongle.
Toyota hybrids append a 3-digit INF (detail) code to many hybrid DTCs: P0AA6-526, P0AA6-611, P3000-123. The INF is the number that decides the repair — for P0AA6 it separates a genuinely bad battery from an inverter, cable, or harness fault. Every free OBD app shows you P0AA6 and stops. This document is the wire-level detail needed to go further.
Three layers are covered, and they are at different levels of confidence. That distinction is stated explicitly at each layer rather than smoothed over, because building on the wrong one produces confident, wrong answers about someone's car.
| Layer | Status |
|---|---|
DTC read (service 13) |
Confirmed on-car |
Bulk data read (service 21, single-byte LIDs) |
Confirmed on-car |
| INF detail tables — layout and identifiers | Confirmed against Toyota's own database |
| INF detail tables — which request reaches them | One open question. Probe included. |
1. ECU addressing
On Gen2 the hybrid system answers on two physical addresses:
| Header | ECU |
|---|---|
7E2 |
HV / power management |
7E3 |
HV battery |
This trips people up constantly, including published references: on Gen2 the battery ECU is 7E3, not 7E2. Gen2.5 and Gen3 consolidate onto 7E2. Gen4 moves to 7D2 and is natively UDS, so little below applies there.
Use physical addressing throughout. Do not send any of this to the functional address 7DF.
2. Session setup
This exact sequence is known-good against an ELM327 v1.5 clone:
ATH0 matters. With headers on you get a CAN ID prefix on every line and every parser below needs reworking. ATSTFF matters because the first request after protocol detection can take over a second; a default timeout produces spurious NO DATA.
3. Reading DTCs — service 13
This ECU family is KWP2000, not UDS. DTCs come from service 0x13 (readDiagnosticTroubleCodesByStatus), not UDS 0x19.
5300 is the response you get on a healthy car: service 0x13 + 0x40 = 0x53, followed by a count of zero. The status mask differs per ECU (B0 on 7E2, 80 on 7E3); both are Techstream's own masks for those ECUs.
The INF number — the 611 in P0AA6-611 — is carried in the DTC record from this read. The tables in §5 tell you which details are active; this read tells you which code and sub-code you are looking at. A complete implementation needs both.
Service 0A is not involved at any point. That is generic OBD-II permanent-DTC storage and has nothing to do with Toyota detail codes.
4. Bulk data — service 21 with single-byte local identifiers
This is the service that makes a Dr. Prius-class app possible, and its shape is the key to everything in §5.
0x21 + 0x40 = 0x61, echoing the identifier. CE is a single-byte local identifier, and on Gen2 21CE/21D0/21CF on 7E3 return the block voltages, module temperatures, pack current and SOC that every hybrid battery app displays.
Multiple identifiers go in one request, which is a KWP2000 feature and is worth building around — it cuts a poll cycle to a single round trip:
The response concatenates each identifier's data, each block prefixed by its own single-byte identifier echo. A real captured response, reassembled from the ELM's multi-line output:
Note what this response shape rules out. Three identifiers, three echoes, one byte each. Whatever else is true, identifiers on this ECU family are one byte and service 21 accepts several per request. Keep that in mind for §5.
Multi-frame responses arrive from the ELM327 as a length prefix followed by indexed continuation lines (0:, 1:, 2: …). Strip the indices, concatenate in order, and drop the length prefix before parsing.
5. The INF detail tables
5.1 What they are
Toyota's phase-4 hybrid database stores INF detail information as five parallel tables, partitioned by the hundreds digit of the INF code, 63 slots each:
| Stored identifier bytes | INF range | Notable contents |
|---|---|---|
C6 05 |
201–263 | |
C7 05 |
301–363 | |
C8 05 |
401–463 | |
C9 05 |
501–563 | P0AA6-526 |
CA 05 |
601–663 | P0AA6-611, P0AA6-612 |
All five share one layout, byte for byte, with a single exception noted in §5.3. A tool that reads only one of these tables cannot report the codes in the other four — and P0AA6-526, one of the most commonly cited hybrid battery codes, is in C9 05.
Not every INF lives in this group at all. P3000-123 and P3000-125 — the two sub-codes the 2004 repair manual documents for P3000 — are stored under identifiers 04 03 and C3 01, separate tables entirely.
5.2 The open question: which request reaches them
The identifiers are stored as the byte pairs above. There are two ways to read those bytes, and they imply different requests:
Reading A — two-byte UDS DID, byte-swapped. CA 05 means DID 0x05CA, requested as:
On a 2004 Gen2 this is refused. Observed repeatedly, header set and acknowledged:
7F 22 11 — negative response, service 0x22, NRC 0x11 serviceNotSupported. The ECU answered; what it said was that it does not implement service 0x22. Consistent with §3 and §4: this is a KWP2000 ECU, and UDS 0x22 is not its read-data service.
Reading B — single-byte local identifier, in group 05. CA 05 means LID 0xCA, requested the same way as the working reads in §4:
Supporting evidence for Reading B:
- Service
21with single-byte LIDs demonstrably works on this vehicle (§4), and the multi-identifier response shape proves identifiers are one byte. - The same database stores identifier
E1under three different second bytes —E1 01,E1 02,E1 03. Under Reading A those are three unrelated DIDs sharing a low byte by coincidence. Under Reading B it is one identifier appearing in three groups. - The identifiers that provably work on
7E3(CE,CF,D0,D1) are stored in the battery database in exactly this same two-byte form. - Toyota's detail-code lookup routine takes its PID handle as a single byte.
- Service
22appears in no phase-3 or phase-4 diagnostic script. It first appears in phase 5 — Gen4,7D2— where UDS is native and it is genuinely correct.
What is not yet known is whether 7E2 answers service 21 at all. Every confirmed mode-21 read above is on 7E3. The one mode-21 request logged against 7E2 (2181, a Gen3-specific identifier) returned NO DATA — silence, not a refusal, which discriminates nothing. That gap is what §6 closes.
5.3 Layout
One table serves all five. Read the ordinal as the last two digits of the INF code: ordinal 26 is INF 226 in C6 05, INF 526 in C9 05, INF 626 in CA 05.
Payload is 57 bytes, offsets 0..56.
The one exception across tables: ordinal 01 is a 16-bit field spanning bytes 29–30 in the CA 05 table, and a single byte at offset 29 in the other four.
Not every ordinal is a populated code in every table — the 63-slot shape is fixed, and some slots are unused per generation.
5.4 Bit extraction — the easiest thing here to get wrong
Bit numbering is MSB-first. "bit 0" is the most significant bit of the byte:
So ordinal 32 is payload[19] & 0x80, not & 0x01.
Reversing this does not crash and does not look wrong. It silently swaps ordinals 32↔39, 33↔38, 34↔37, 35↔36 — eight fields exchanged in pairs, producing a confident diagnosis naming the wrong component. If you implement one assertion in your test suite, make it this one.
Whole-byte fields are the raw byte value. Ordinal 01 in the CA 05 table is (payload[29] << 8) | payload[30], big-endian.
A field is active if and only if its extracted value is nonzero. The value is the raw reported byte or bitfield, not a transformed code.
Reference extractor, for a field spanning bitStart..bitEnd (inclusive, MSB-first, absolute bit offsets = byteOffset * 8 + bit):
6. The probe that closes the open question
Roughly ten read-only commands. Car parked, engine off, any background polling stopped, physical addressing only. Log the raw responses and read the NRCs — do not merely check for success. A refusal carries more information than a timeout.
| Header | Send | What it discriminates |
|---|---|---|
7E2 |
2101 |
Does 7E2 answer service 21 at all? The load-bearing unknown |
7E2 |
21CA |
61 CA … ⇒ settled, 6xx table located |
7E2 |
21C9 |
the 5xx table — where P0AA6-526 lives |
7E2 |
2105CA |
service 21 with a two-byte identifier — the remaining variant |
7E2 |
2205CA |
control; expect 7F 22 11 again |
7E2 |
22F186 |
any UDS DID; a second 7F 22 11 closes service 22 regardless of DID |
7E3 |
21CA, 21C9 |
in case the tables live on the battery ECU |
7E2 |
1381, 1382 |
additional DTC status masks |
Interpreting the result:
61 CA <bytes>— done. Decode with §5.3.7F 21 31(requestOutOfRange) — service21is right, identifier wrong. Sweep21C6through21CA.7F 21 11— service21absent on7E2. Genuinely new territory; report it.7F 21 7For7F 21 22— the only responses that would make a diagnostic-session question relevant. Nothing observed so far suggests a session is needed;7E2is read in the default session by the dealer tool.
A car with no stored fault reads all zeroes. Confirming any of this needs a vehicle with an actual stored hybrid DTC. 5300 from §3 means zero codes and nothing to see.
7. Implementation notes worth the time
ATSH is adapter-global state, and this will bite you. The header and the request that follows it must be one atomic critical section. If any other thread — a 2 Hz battery poller, a background health check — can issue its own ATSH between your header and your request, it will, and your request goes to the wrong ECU and returns plausible garbage. This is a real failure mode, not a theoretical one: a battery-data request landing on the HV ECU because a DTC sweep set ATSH7E2 in between. Lock across the whole group, not per exchange.
Related: never send a bare request that relies on whatever header happens to be current. Every request belongs inside an explicit set header → request group.
Parse negative responses explicitly. 7F <service> <NRC> reaching a decoder that expects data surfaces as a confusing parse failure rather than the useful message the ECU actually sent. Worth naming at minimum:
0x13 is incorrectMessageLength, not requestOutOfRange — these two get mixed up in circulating documentation.
Do not report stale or partial data. A short response body is a truncated read, not a zeroed reading. Treat it as an error rather than silently rendering a partial payload as "all clear" — on a battery diagnostic that is the difference between "no fault" and "we didn't ask properly."
Gate the detail read on there being codes. Requesting detail tables when the DTC count is zero returns nothing useful and, on some ECUs, a refusal that looks like a bug.
8. Scope and provenance
- The table layouts and identifiers come from the phase-4 Toyota hybrid diagnostic database. That file is a multi-generation union for the phase-4 HV ECU family, not a 2004-specific file. Gen2 is where it was checked, not the boundary of what it describes.
- The 63-row layouts were verified row-for-row against that database.
- Sections 1–4 are confirmed against a 2004 Gen2 with an ELM327 v1.5 clone: service
13answering5300, service21returning real data on7E3, service22refused on7E2. - No
61 CAor62 05 CAresponse has ever been observed. The 57-byte payload length is derived from the maximum offset in the table, not measured from a capture. Until §6 runs on a car with a stored fault, §5.3 is a decoded specification, not a decoded response. - Gen3, Gen4, Prime, Aqua, Auris, Camry Hybrid, CT200h: untested. Gen4 (
7D2, phase 5) is native UDS and service22is plausibly correct there.
If you run the §6 probe — on any generation, with any result including a boring one — the raw response text is the contribution that finishes this.
License
Public domain. No warranty. Test on your own vehicle, at your own risk. Everything here is read-only; nothing in this document writes to, clears, or reconfigures an ECU, and it should stay that way.