Reading a Lexus CT200h's hybrid battery from an iPhone
An iOS app that reads the hybrid battery of a Lexus CT200h through a cheap Bluetooth diagnostic adapter: how to query the battery's control unit, how to decode voltages, currents and temperatures, and how to control the fan safely.
Contents
In short
- The hybrid battery's control unit (ECU) knows voltages, current, charge and temperatures, but the car doesn't show them. A cheap adapter in the diagnostic port is enough to read them.
- The app asks the ECU for data with diagnostic requests and gets back packets of bytes. The real work is turning those bytes into correct numbers.
- A wrong but plausible number is the worst kind of error: my temperatures were 10 °C too high, and nothing flagged it.
- The app can also drive the battery cooling fan, but in the normal modes it can never cool less than the car would on its own.
- If the app stops talking, the car takes the fan back within a couple of seconds.
The Lexus CT200h is a compact hybrid, a close relative of the Toyota Prius. Its traction battery is a nickel-metal hydride (NiMH) pack under the boot floor, cooled by a fan that draws air from the cabin. The battery ECU constantly measures voltages, current, state of charge and temperatures, but no screen in the car shows them. There are Android apps that read them; I wanted an iOS app that showed my car's data and also let me control the fan.
The hardware is minimal: a Bluetooth Low Energy adapter based on the ELM327 (the chip behind almost every cheap diagnostic adapter) plugged into the OBD-II port, the one workshops use for diagnostics. The app is written in Swift and SwiftUI.
Why look at the battery
The pack is made of 28 modules in series, which the ECU measures in pairs: 14 blocks of about 14.4 V nominal, around 200 V in total. A NiMH battery ages mostly with heat, and as it ages the blocks stop behaving alike: one sags more than the others under load, or its internal resistance goes up. Looking at the 14 blocks side by side, and at the pack temperature while driving, tells you far more than the warning light on the dashboard, which comes on when the problem is already serious.
If this isn't your field. Picture 14 batteries connected in a row. As long as they're all alike, the pack works well. When one starts to weaken, the whole pack suffers, and the car notices late. The app lets you see the 14 batteries one by one.
The communication chain
Between the phone and the battery there are two very different links.
The first link is Bluetooth Low Energy. The ELM327 started life as a command interpreter on a serial port; over BLE the serial port becomes a pair of GATT characteristics, one to write to and one that sends notifications. Different adapters use different service identifiers, so the app looks through a few known pairs for one with a writable characteristic and one that notifies.
The second link is the CAN bus, the network the car's control units use to talk to each other. The adapter takes text commands from the phone, turns them into CAN frames, sends them to the ECU and returns the response as hex text.
Setting up the adapter
At startup the app configures the adapter with a fixed sequence of AT commands, all documented in the ELM327 datasheet:
| Command | What it does |
|---|---|
ATWS | restarts the adapter |
ATE0 | no echo of commands in the response |
ATSP6 | ISO 15765-4 protocol: CAN with 11-bit identifiers, 500 kbit/s |
ATAT1 | adaptive timeouts |
ATH1 | shows the address of whoever responds |
ATL0, ATS0 | no line feeds, no spaces: shorter responses to parse |
ATCAF1 | the adapter handles CAN frame formatting itself |
Then, before each request to the battery ECU, the app sets the destination address (7E2) and the same address for flow-control messages, which are needed when the response is long.
Long responses: several frames per message
A CAN frame carries at most 8 bytes of data. A request like "give me the block voltages" fits easily in one frame, but the response, with 14 voltages of 2 bytes each, doesn't. The ISO 15765-2 standard (ISO-TP) splits long messages across several frames: the first says how long the message is, the receiver replies "go ahead, send the rest", and the following frames are numbered.
Each request is made of a mode and a PID, the identifier of the requested value. This is the pattern. Bytes marked xx are data; lengths and contents are illustrative:
→ 7E2 02 21 81 request: mode 21, PID 81
← 7EA 10 LL 61 81 xx xx xx xx first frame: the message is LL bytes long
→ 7E2 30 00 00 flow control: send the rest
← 7EA 21 xx xx xx xx xx xx xx consecutive frame 1
← 7EA 22 xx xx xx ... consecutive frame 2
The 61 81 at the start of the response is the acknowledgement: the positive response to a mode 21 request has code 61, followed by the requested PID. The ELM327 sends the flow control by itself and returns the frames one per line; the app strips the protocol bytes, checks that the response starts with the right acknowledgement and puts the content back together.
Keeping the session open
The ECU closes the diagnostic session if it hears nothing for a few seconds. So every 2.5 seconds the app sends a tester present message (3E 00), which in the UDS diagnostic standard simply means "I'm still here".
One command at a time
The adapter handles one command at a time, but in the app requests come from three independent places: the data polling loop, the tester present and the fan control.
Access to the adapter goes through a Swift actor, a construct that guarantees its code is never run by two tasks at once. That isn't enough, though. An actor suspends at every waiting point, for example while it waits for the adapter's response, and at that moment it can start running another request. A complete exchange takes several steps: set the address, send the request, wait for the response. If another exchange slips in halfway, the adapter receives interleaved commands. The symptom was an intermittent "busy" write error that was hard to reproduce.
The fix is an explicit queue: each complete exchange takes a "turn" before it starts and only releases it at the end; anything that arrives in the meantime waits in line. It's the same principle as the ticket at a deli counter: there's only one counter, and each customer is served completely before the next.
From bytes to numbers
These are the data the app requests from the ECU. Mode 21 is a Toyota-specific diagnostic mode, not standard OBD-II:
| Mode + PID | Contents |
|---|---|
2181 | voltage of the 14 blocks, 12 V auxiliary battery, pack voltage |
2187 | temperatures: intake air and probes in the pack |
2195 | internal resistance of the blocks |
2198 | current, charge and discharge power limits, minimum and maximum state of charge |
2101 | state of charge |
219B | fan status |
For the formulas I started from the PID tables the community uses for the third-generation Prius, which has the same hybrid system as the CT200h. I compared them with each other and, above all, with readings from my car.
Most values are 16-bit big-endian words: two bytes, high byte first. A block voltage, for example:
bytes received 2E 14
raw value 0x2E14 = 46 × 256 + 20 = 11,796
voltage 11,796 / 819.2 ≈ 14.40 V
In the sources the same formula shows up in different forms: raw × 79.99 / 65,535, or raw / 819.2, or raw × 0.122 / 100 rounded. They agree to within 0.1%: 65,535 / 80 ≈ 819.2, so full scale is about 80 V. Three different sources giving the same result is a good sign.
State of charge is a single byte, with 255 = 100%. The power limits in 2198 are bytes with a step of 0.5 kW and an offset of −64 kW.
Every decoder checks the length of the response and discards values outside a plausible range instead of showing them: a NiMH block must be between 5 and 25 V, the 12 V auxiliary battery between 0 and 20 V, the pack between 100 and 400 V. An out-of-range value almost always points to a decoding error, not an unusual battery. The app also keeps the raw bytes of every response, so the decoding can be redone later.
The sign of the current
Current arrives as a 16-bit value centred on 0x8000: subtract 32,768 and divide by 100 to get amps, with a resolution of one hundredth. In Toyota's convention the value is positive when the battery discharges.
The app flips the sign, because for the person looking at it "+" reads more naturally as the battery charging. A choice like this has to be verified, not assumed. I checked it on a real capture: while the engine was charging the pack and the state of charge went from 38 to 50%, the raw value stayed below the centre, meaning current flowing in. And the largest charging current coincided with regenerative braking, as it should.
Temperatures and the 10-degree offset
Temperatures are where it's easiest to go wrong. The obvious reading takes one byte per probe and subtracts 40, the most common convention in standard OBD-II. With that reading the numbers are plausible, 30, 35, 38 degrees, but about 10 °C higher than reality. Public sources disagree on this point, which is why I checked it thoroughly.
Each temperature is actually a 16-bit word with a different scale:
temperature = raw × 255.9 / 65,535 − 50
The reason for exactly 10 degrees shows up when you do the arithmetic. Multiplying by 255.9 / 65,535 is almost exactly the same as dividing by 256, that is, taking only the high byte. So the correct formula is, in practice, "high byte − 50". The standard convention reads that same byte and subtracts 40. An example:
bytes received 4E 07
correct formula 19,975 × 255.9 / 65,535 − 50 ≈ 28.0 °C
OBD-II convention 0x4E = 78, 78 − 40 = 38 °C
It's the most insidious kind of error: no crash, no absurd values, just a wrong number that looks right. That's why, for every quantity, I check two things: that the sources agree with each other, and that the trend makes sense given what the car is doing. Current must change sign between charging and discharging; temperature must rise under load and fall when the fan runs hard.
The fan
Besides reading, the app can control the battery cooling fan, with a diagnostic request that asks the ECU to force one of the 6 available speeds. It's the only part where a mistake has consequences for the car, and I designed it starting from what can go wrong.
The ECU takes control back by itself
Captures from the car show that the ECU holds a commanded speed for only about 2 seconds after the last command, then returns to its own automatic control: in the logs the speed dropped from 6 to 3 to 1. To hold a speed, the app therefore repeats the command every 1.5 seconds.
This behaviour is also the most important safeguard. If the phone freezes, Bluetooth drops or the app is closed, the commands stop and after a couple of seconds the fan is back under the car's control. There is no state in which the app leaves the fan stuck.
A command only counts as applied if the ECU replies with a positive acknowledgement. If it replies with a rejection, or doesn't reply at all, the app does not consider the speed applied.
Modes
| Mode | What it does | Can it cool less than the car? |
|---|---|---|
| Automatic | the car manages the fan; the app sends nothing | no |
| Minimum speed | a fixed speed chosen by the user, raised by the safety curve if needed | no |
| Protected curve | a user temperature → speed curve, with the same floor | no |
| Free curve | the user's curve, with no floor | yes, expert mode |
| Manual | a fixed speed, no protection | yes, expert mode |
The two expert modes can only be unlocked after a warning and a double confirmation. If they are locked again while one of them is active, the app falls back to the protected curve.
The safety curve
In the protected modes the commanded speed never drops below that of a safety curve, based on the highest temperature among the pack's probes. The curve starts from the manufacturer's documented thresholds and is a little more cautious at the top: speed 1 from 36 °C, 2 from 39, 3 from 42, 4 from 44, 5 from 46 and 6 from 48 °C.
There is a single rule: the effective speed is the higher of the one the user asks for and the one from the safety curve. The user can only add cooling, never take it away.
Hysteresis
If the temperature hovers around a threshold, say between 41.9 and 42.1 °C, a naive rule would change speed on every reading. To avoid that there is a 2 °C hysteresis: the speed goes up as soon as the temperature crosses the threshold, but only comes down when the temperature is 2 degrees below it again. In the example, speed 3 engages at 42 °C and stays on until the pack drops below 40 °C.
It's the same principle as a home thermostat, which doesn't switch the boiler on and off every time the temperature moves a tenth of a degree above or below the setting.
Other cars, only one verified
Since the CT200h protocol is the same as the third-generation Prius, I added profiles for other Toyota and Lexus hybrids. The app recognises the car from its VIN (vehicle identification number). When that isn't enough, it queries each profile's battery ECU with a probe PID and checks the response: a correct response confirms that the app is really talking to that ECU, even if it can't always tell apart two models of the same family. In that case the interface shows a "?" and lets you choose by hand.
The only profile tested on a real car is the CT200h. The others are checked against the sources, not against cars. For those the app shows a warning and focuses on monitoring; for the newer families (TNGA platform, such as the Lexus UX250h SUV) the sources don't even agree on the fan command, so it stays disabled. To complete these checks the app can export every raw OBD frame: one drive with the right car is enough to confirm or correct a profile.
Limits
- It's a personal app, not a certified diagnostic tool.
- Some scale constants can change between ECU calibrations, which is why I keep the raw bytes.
- Confirming profiles other than the CT200h needs tests on those cars.
- The code is not public.