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:

1
2
3
4
5
6
7
ATZ           reset
ATE0          echo off
ATL0          linefeeds off
ATS0          spaces off
ATH0          headers off
ATSP0         protocol auto-detect
ATSTFF        max timeout — hybrid ECUs are slow to first byte

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.

1
2
3
4
5
ATSH7E2
13B0          -> 53 <count> <codes...>

ATSH7E3
1380          -> 53 <count> <codes...>

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.

ATSH7E3
21CE          -> 61CE 6D 81 0B 85 F0 85 EB 85 E2 ...

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:

21CED0CF      -> 61CE <CE block> D0 <D0 block> CF <CF block>

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:

1
2
3
61CE 6D810B85ED85EB85E285DF85F085F685F185F385F485F385EB85E585E485E1
D0   0E000000000000000085DF0385F60513131313131313131313131313
CF   8CD180C54EAA000900008C498C1B8C54000000

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:

ATSH7E2
2205CA

On a 2004 Gen2 this is refused. Observed repeatedly, header set and acknowledged:

1
2
3
4
ELM >> ATSH7E2
ELM << OK
ELM >> 2205CA
ELM << 7F2211

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:

ATSH7E2
21CA

Supporting evidence for Reading B:

  • Service 21 with 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 E1 under 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 22 appears 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.

ord  offset       bits          ord  offset       bits
01   byte[29-30]  bits 0-7      33   byte[19]     bit 1
02   byte[0]      bits 0-7      34   byte[19]     bit 2
03   byte[1]      bits 0-7      35   byte[19]     bit 3
04   byte[31]     bits 0-7      36   byte[19]     bit 4
05   byte[2]      bits 0-7      37   byte[19]     bit 5
06   byte[3]      bits 0-7      38   byte[19]     bit 6
07   byte[32]     bits 0-7      39   byte[19]     bit 7
08   byte[11]     bits 0-7      40   byte[21]     bits 0-7
09   byte[12]     bits 0-7      41   byte[22]     bits 0-7
10   byte[33]     bits 0-7      42   byte[23]     bits 0-7
11   byte[13]     bits 0-7      43   byte[24]     bits 0-7
12   byte[14]     bits 0-7      44   byte[25]     bits 0-7
13   byte[34]     bits 0-7      45   byte[26]     bits 0-7
14   byte[4]      bits 0-7      46   byte[27]     bits 0-7
15   byte[5]      bits 0-7      47   byte[28]     bits 0-7
16   byte[6]      bits 0-7      48   byte[39]     bits 0-7
17   byte[7]      bits 0-7      49   byte[40]     bits 0-7
18   byte[8]      bits 0-7      50   byte[41]     bits 0-7
19   byte[9]      bits 0-7      51   byte[42]     bits 0-7
20   byte[10]     bits 0-7      52   byte[43]     bits 0-7
21   byte[15]     bits 0-7      53   byte[44]     bits 0-7
22   byte[16]     bits 0-7      54   byte[45]     bits 0-7
23   byte[17]     bits 0-7      55   byte[46]     bits 0-7
24   byte[18]     bits 0-7      56   byte[47]     bits 0-7
25   byte[20]     bits 0-7      57   byte[48]     bits 0-7
26   byte[35]     bits 0-7      58   byte[49]     bits 0-7
27   byte[36]     bits 0-7      59   byte[50]     bits 0-7
28   byte[37]     bits 0-7      60   byte[51]     bits 0-7
29   byte[38]     bits 0-7      61   byte[52]     bits 0-7
30   byte[54]     bits 0-7      62   byte[53]     bits 0-7
31   byte[55]     bits 0-7      63   byte[56]     bits 0-7
32   byte[19]     bit 0

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:

bit 0 -> 0x80    bit 1 -> 0x40    bit 2 -> 0x20    bit 3 -> 0x10
bit 4 -> 0x08    bit 5 -> 0x04    bit 6 -> 0x02    bit 7 -> 0x01

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):

uint32_t extract(const uint8_t *p, int bitStart, int bitEnd) {
    int endByte   = bitEnd   >> 3;
    int startByte = bitStart >> 3;
    int span      = endByte - startByte;
    int shiftTop  = 7 - (bitEnd & 7);

    uint32_t v = p[endByte] >> shiftTop;
    if (span > 1) {
        int shift = 8 - shiftTop;
        for (int i = endByte - 1; i > startByte; i--, shift += 8)
            v |= (uint32_t)p[i] << shift;
    }
    if (span > 0)
        v |= ((uint32_t)p[startByte] << (bitStart & 7)) << (bitEnd - bitStart - 7);
    else
        v &= (1u << (bitEnd - bitStart + 1)) - 1;
    return v;
}

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) — service 21 is right, identifier wrong. Sweep 21C6 through 21CA.
  • 7F 21 11 — service 21 absent on 7E2. Genuinely new territory; report it.
  • 7F 21 7F or 7F 21 22 — the only responses that would make a diagnostic-session question relevant. Nothing observed so far suggests a session is needed; 7E2 is 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:

1
2
3
4
5
0x11  serviceNotSupported          0x12  subFunctionNotSupported
0x13  incorrectMessageLength       0x22  conditionsNotCorrect
0x31  requestOutOfRange            0x78  responsePending
0x7F  serviceNotSupportedInSession (UDS)
0x80  serviceNotSupportedInSession (KWP2000)

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 13 answering 5300, service 21 returning real data on 7E3, service 22 refused on 7E2.
  • No 61 CA or 62 05 CA response 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 service 22 is 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.

Edit

Pub: 08 Aug 2026 21:25 UTC

Edit: 09 Aug 2026 00:50 UTC

Views: 24

Auto Theme: Dark