Pairing an ESP32 with an OBD-II adapter over BLE
I want my car's own data on a screen I control, and I would like to stop asking permission for it. That is the whole ambition. It starts somewhere much smaller: getting one Bluetooth adapter to say anything at all.
The car is a Volkswagen Scirocco. The only thing touching its diagnostic port is an OBDLink CX, about $124 here, sold mostly to people coding BMWs. I did not buy it for that. I bought it because it speaks Bluetooth Low Energy properly instead of pretending to be a serial port over a classic link, which is what most cheap adapters do and which makes them miserable to write against. The other end is an ESP32-S3 behind a 3.49 inch panel, 640 by 180, which runs the diagnostic core and shows what it knows.
By the end of the second day it had not sent me a single byte.
I wrote the whole thing before I owned the adapter
The first commit has no hardware behind it. None. The adapter had not arrived, so every line on the adapter side was written against OBDLink's published documentation and left sitting there waiting to be contradicted.
That sounds like the wrong order and I would do it again, because it forced the core to be testable without a car in the room:
automotive_core Result, provenance, tracing, state machines. No ESP-IDF.
platform Interfaces only.
platform_host Host implementations, including a fake BLE central.
platform_esp ESP-IDF implementations. The only place esp_*.h is allowed.
transport Framing, MTU chunking, BLE link state, retry supervision.
obdlink Status vocabulary, command queue, session, identity probe.
safety The write interlock.
A shell script fails the build if anything under components/ outside
platform_esp reaches for an ESP-IDF or FreeRTOS header. Without it the core
would have quietly stopped being testable inside a week, because that is what
always happens.
Two design choices from that day carried everything since. The first: facts carry their source. A value is held together with an evidence set rather than a single mutable label, so confirming something on hardware adds an observation next to the documentation instead of overwriting it, and a contradicting observation keeps both. I built that because documentation contradicted by hardware is the most valuable thing this project can produce, and I wanted a place to put it that was not a comment.
The second: silence is not sleep. The adapter is recorded as asleep only on a documented signal. Anything else that goes quiet is unresponsive, then faulted. A device that stops answering has not told you why, and guessing on its behalf is how you end up with a status word that lies.
Ninety-seven test cases, 583 assertions, a 630 KB image, nothing flashed.
Three guesses, all wrong, all invisible from the source
Then I put it on hardware, and the panel told me what it thought of my reading comprehension.
SPI DMA cannot source from PSRAM on this board. The composed frame is 220 KB, it
does not fit in internal RAM, and queueing a transaction whose tx_buffer lives
in PSRAM makes the driver reject it and the error check abort. What you see is a
boot loop with the panel flickering. What it means is that the frame has to go
out through a small internal DMA-capable stripe buffer, a piece at a time.
The command and address ride the first stripe only. 0x2C is RAMWR and starts
at the window origin; 0x3C is RAMWRC and continues where the last one stopped.
I was re-issuing RAMWR for every chunk, which repainted the same band down the
whole panel and looked convincingly like a tiling bug in my compositor.
The rotation was inverted and the words came out back to front. The mapping that
actually works is nativeX = (height - 1) - logicalY, nativeY = logicalX, and
the AXS15231B wants RGB565 with the bytes swapped. Only the column window needs
setting; this controller sweeps rows by itself.
So I stopped guessing and built a way to look: a shot console command and a
script that pulls the framebuffer over USB. Every line of the dump carries its
own address, which matters more than it sounds. A line lost to backpressure
becomes a single black stripe instead of rolling the entire image and sending me
hunting for a rendering fault that does not exist.
The adapter, and five ways to not connect to it

Pairing stalled for exactly thirty seconds. Exactly. Round numbers in a stall are a gift, because nothing organic takes precisely thirty seconds, and this one turned out to be NimBLE's security manager timer rather than anything of mine. The adapter asks for a passkey. My IO capability was no-input-no-output, which forces Just Works and leaves the stack no way to answer a question it has been asked. Unhandled, that event is a silent stall: the application is expected to supply something, and returning zero supplies nothing.
ble_store_config_init() was never called. With nowhere to put keys, the
security manager refused to initiate anything at all, and the boot log had been
saying exactly that for two days, in a line about failing to restore IRKs that I
read past every single time I scrolled through it.
Taking the peer's identity key made things worse rather than better. Once bonded, the adapter advertises a resolvable private address; resolving it handed me an all-zero public address that no connect could ever reach. I match on a service UUID anyway, so the identity key bought me nothing and cost me the one address that could actually be dialled.
Name matching was never going to work either. Names live in the scan response, the controller's duplicate filter suppresses it, and turning that filter off floods this board's USB console until it gives up emitting entirely. The service UUID rides the primary advertisement and survives filtering.
Then the observation that undid the assumption the code was built on: enumeration succeeds while unbonded, and it is subscribing that gets refused. I had only escalated to bonding when discovery failed. Discovery never failed, so bonding never happened.
Watching a phone succeed
Pairing the same adapter from an iPhone offers a random six-digit number to confirm. That is LE Secure Connections numeric comparison, and it is not the fixed PIN the vendor's notes describe for "older BLE standards". Declaring keyboard-only had quietly negotiated the legacy path. It completed. It reported bonded. That is precisely why it took me so long to doubt it: I had a legacy bond where the adapter was offering a modern one, and nothing in my own logs called that a problem.
Switching to display-yes-no with numeric comparison auto-accepted, because there is nobody standing at a headless terminal to read a number off it:
pairing wants numeric comparison; accepting 316010
security: BONDED
I also started reading the CCCD back after subscribing. A write status of zero tells you the ATT operation was accepted. It does not tell you the adapter armed notifications. Reading the value is the only way to separate those two, and without it "subscribed" is an assumption wearing the clothes of a fact.
Two defects that had nothing to do with the car
Scanning could wedge permanently, and this one is my favourite because of how quiet it is. Starting a scan was refused whenever a local "am I scanning" flag was set, and only a scan-complete event clears that flag. Drop one of those events and the flag stays true forever. Every subsequent scan is refused as already scanning. The device never scans again, says nothing about it, and looks perfectly healthy. On something whose entire job is to sit unattended and keep looking, that is the end of its usefulness. On hardware it showed up as "scan failed to start", repeating until I reflashed the board. The fix: cancel unconditionally before starting, and never refuse on the strength of a flag.
GATT writes were not serialised, so a payload could leave in pieces that interleaved with another. Payloads go as one write now, and a payload too large for one write is refused outright, because a truncated command is worse than no command at all.
Scan windows went from half duty to continuous. The power saving lives in the gaps between windows, not in gaps punched inside an already short one, and half duty merely halved my chances of catching a weak advertiser. Measured: a full duty survey heard the adapter at -84 dBm while the supervisor's half duty windows kept closing empty.
Where day two ended
One hundred host cases, 594 assertions, and a fake central that now models what the adapter does instead of what the documentation implied.
The link is bonded with Secure Connections. MTU is 247. The CCCD at handle
0x002a reads back 01 00, so notifications are armed and I can prove it
rather than hope it. Writes are accepted at the right handle, the signal sits at
-68 dBm and the engine is running. Every GAP event the stack delivers is logged
now, which is how I caught the wedged scan, and which establishes something I
would rather not have established: a notification event has never once been
delivered.
Not one byte. Not a reply, not an error, not even the power-on banner. The same adapter, on the same car, minutes earlier, handed the vendor's own phone app the VIN without complaint.
Every explanation I can test has been tested and eliminated. Not signal, at -68 dBm. Not sleep, with the engine running. Not the pairing type, now that Secure Connections works and I have checked it. Not a dead subscription, with the CCCD reading back armed. The fault is mine, it is somewhere I have not thought to look, and after two days I have run out of places I know how to look at.