The car ignores UDS and answers TP2.0

Three weeks after the last post, the ESP32-S3 still had not received a byte from the OBDLink CX, and I had stopped believing I would find out why by staring at it. So I changed the board.

The new one is a Waveshare ESP32-P4-86-Panel-ETH-2RO: an ESP32-P4 behind a 4 inch 720 by 720 touchscreen, sized to sit in an 86 mm wall junction box, with an ESP32-C6 alongside it doing Bluetooth and Wi-Fi as a coprocessor over SDIO. This variant also carries Ethernet, an RS485 terminal and two relays, none of which I have a use for yet. About $75 from an Australian retailer, though the listing does not say which of the two variants it ships, so treat that as the price of the plain 86 Box. A wall panel is an odd thing to point at a car. It is also a square screen large enough to read from the driver's seat, with a real radio chip behind it, and that was the whole argument.

The diagnostic core moved across untouched, which is what the layering rules from the first post were for. What is new is the shell (an amber header, nine rows of 68 pixels, a two-line footer), the ST7703 panel bring-up over DSI, and the GT911 touch controller. It built, 104 host cases and 625 assertions passed, and the manifest recorded the whole thing as implemented and unverified, because nothing had run on the panel. Porting a UI is not evidence about the adapter either. The S3's silence went into the notes as unexplained.

Two boot loops before the panel lit

I flashed it at lunchtime and it rebooted continuously. Two separate causes, fixed separately.

The C6 shipped with ESP-Hosted slave firmware too old to answer the version query. arduino-esp32 recorded the slave as 0.0.0, skipped the Bluetooth controller bring-up because that path needs 2.6.0 or newer, and reported success regardless, so NimBLE went on to initialise against a controller that had never been switched on and the P4 took a load access fault before the panel or the console had a chance to show anything at all. Nothing. The fix reads the slave version itself and refuses, visibly:

The panel refusing to start because the C6 firmware is too old. A framebuffer capture pulled off the running P4 over USB.

Espressif's own updater fetches a new C6 image over the C6's Wi-Fi, which is circular when the C6 is the thing that does not work, and would have meant baking my network's login into the firmware. So I wrote a console command that streams the image down the USB serial link in CRC32-checked 2 KB chunks and hands it to the slave OTA path. 1,206,528 bytes, 115 seconds at 115200 baud, no retries, and the chip id in the image header checked before a byte was sent. The C6 is on 2.12.3 now.

With the controller up, the second loop. The first scan called ble_hs_id_infer_auto() before the host had synced with the controller. This NimBLE allocates its identity context lazily during startup, and until then the pointer is NULL and the call faults on it. On the S3 the sync takes milliseconds, so the order had never mattered. Over SDIO to the C6 it took 3.3 seconds. A synced flag now gates scanning and connecting, and the link supervisor's existing backoff does the retry: first scan refused at 2.7 seconds, host synced at 6.0, second window at 7.6 saw advertisements at once.

There was a third thing, quieter than either. The P4 prebuilt is compiled with CONFIG_LOG_MAXIMUM_LEVEL=1, which deletes every info-level log macro at compile time. ble_scan printed its header and then nothing. The one line I had spent three weeks waiting to see, the notification with the adapter's bytes in it, would never have printed on this board. Logging goes through a sink of my own now.

Then, left scanning on the bench, it connected to a stranger. A device called YT* at -87 dBm, over and over. My filter searched the entire raw advertisement for the two bytes of the adapter's service UUID, and that device's manufacturer data happened to contain F0 FF. The matcher walks the advertising structures now and looks only inside the service UUID lists.

Twenty-one days late, the adapter says OK

The vendor's documentation says the CX accepts bonding only in the first five minutes after it is powered on. Two attempts outside that window failed: the first sat for thirty seconds and timed out, the second lost the connection partway through. I pulled the adapter, plugged it back in, and tried again inside the window.

app: ble DISCOVERING -> BONDING (peer requires authentication)
nimble: pairing wants numeric comparison; accepting 471345
link: security: BONDED
nimble: CCCD reads back as 01 00 (0100 = notifications enabled)
app: notify handle=0x0029 len=10 hex=415445300D4F4B0D0D3E text="ATE0OK>"
app: ATE0 -> OK [OK]

The numeric comparison prompt came 1.5 seconds after bonding began. ATE0 went out, and OK came back. Those are the first bytes this project ever received from the adapter.

I want to be careful about what that does and does not explain. The August sessions on the S3 bonded successfully too, so the five-minute window does not account for them on its own. The same command path that received nothing there receives everything here. What differed is not pinned down, I have not retested the S3, and the notes say unexplained rather than something more comforting.

The identity probe ran: OBDLink CX r1.7.1, STN2310 firmware v5.10.1 dated 17th November 2023, AT@2 refused as an unknown command. The first real run found a defect of my own on the way: the probe's queue top-up picked the first step not yet executed, which was one already waiting in the queue, so AT@1 through STMFR went out twice. A submitted flag and a host test that holds each command to exactly one write fixed it.

Then a full restart of the P4 with the bond in flash, more than five minutes after the adapter was last powered. Encryption resumed in 0.35 seconds with no pairing exchange and the link was ready 13.4 seconds from power-on, with nobody touching anything. The window gates pairing only.

Adapter link up, ready, MTU 247. Framebuffer capture from the P4.

Then the car. Ignition on, engine off. ATSP0 then 0100: the adapter searched, settled on its protocol A6, which is ISO 15765-4 CAN with 11-bit identifiers at 500 kbps, and two controllers answered at 7E8 and 7E9. Mode 09 PID 02 came back as three ISO-TP frames carrying the VIN, which matched what the vendor's phone app had shown. Mode 01 PID 01 on 7E8: MIL off, zero stored codes. 12.2 V at the port.

Two components from what the car actually sent

The next slice was written from the transcript rather than from a standard. An ISO-TP component that turns an ELM header-on line into a CAN frame, takes a single frame as it is, and otherwise reassembles a message from its first frame and the consecutive frames that follow, keeping one message in progress per identifier because 7E8 and 7E9 answer the same request and their frames can interleave on the way back. The adapter does flow control itself, so a flow control frame here is ignored. Its first fixture is the Scirocco's own three-frame VIN reply, byte for byte.

The UDS component encodes exactly two services, ReadDataByIdentifier and ReadDTCInformation, through one gate that refuses everything else and leaves the buffer empty. Session control and reset are refused too, because they change state. Every constant was cross-checked against two independent implementations of ISO 14229-1 before it went in, and the sources are cited in the headers.

The first read on the device crashed the board. A std::string_view passed through printf varargs hands %s the string's length as a pointer, and the faulting address was 0x3, which happens to be the length of 7E0. %.*s.

UDS is silent everywhere

22 F190, the standard identifier for the VIN, to 7E0: NO DATA. To 7E1: NO DATA. To the functional address 7DF: NO DATA. With the adapter told to wait about a second: NO DATA. Then sixteen reads at identifiers from an ODIS-derived list: the gateway at 0x710, the central electronics at 0x70E, and the two door modules at 0x74A and 0x74B. Every one of them NO DATA. Never once a negative response, which is the thing a UDS server sends when it hears you and declines. Mode 09 kept answering in between, so the bus and the adapter were fine. Nothing on this car spoke UDS to me.

Seven bytes, and 201 answers

Older Volkswagen group cars carry KWP2000 over a transport called TP2.0, which opens a channel with one frame on CAN identifier 0x200. The setup frame to logical address 01 is 01 C0 00 10 00 03 01, and the reply is expected on 0x201. First attempt: NO DATA.

The adapter pads raw frames to eight bytes unless told otherwise, and TP2.0 setup is seven. ATV1 sends exactly the bytes given. Then:

01C00010000301 -> 201 00 D0 00 03 40 07 01

A positive response. The module at logical address 01 will listen on 0x300 and transmit on 0x740. That reply is byte for byte the worked example in two references that agree with each other, jazdw.net/tp20 and the OVMS project's VW-TP-2.0.txt, and it was the first manufacturer-level exchange with this car. The disconnect I sent afterwards got no reply. Several seconds of BLE round trips had passed by then and the channel had probably timed out waiting for the parameters message that should follow setup at once. Probably. It is written down as not established.

The wrong address space

My planning table listed the controllers I wanted by their VCDS addresses: 01 engine, 09 central electronics, 19 gateway, 42 driver door, 52 passenger door. The first identification run walked that list. 08, 15, 17, 19, 42, 44, 52 and 5F were all silent, and only the engine answered.

The TP2.0 destination byte is its own address space. Destination 09 answered and identified itself as the steering rack, where VCDS uses 09 for central electronics, and pq-flasher opens its PQ35 steering channel with 0x9 as well. So nothing maps one onto the other now. A module is whatever it says it is.

Which meant probing all 256 destinations, setup only, 500 ms each.

Two probe runs across every TP2.0 destination byte. Above, a wide receive filter: 42 windows cut short by a stale reply, five modules lost. Below, an exact filter per destination: thirteen answer.

The first sweep found twelve. The second, with the adapter's adaptive timing switched off so that the windows I asked for were honoured, found eight, and lost the gearbox, the airbag controller, the steering rack and two others. It took a while to see why. A module whose setup is answered but never followed up keeps repeating its setup reply for seconds, and because my receive filter passed everything from 0x200 to 0x3FF, that stale reply landed in the next destination's window on the wrong identifier, ended the window early, and was read by the probe as silence from a module that had never been asked anything. Forty-two windows in that run ended between 79 and 221 ms. The real ones ran to about 600.

An exact filter of 0x200 plus the destination, and a parameters message before each disconnect so the module is satisfied rather than left repeating itself, and every silence ran its full window, 544 to 869 ms. Every answer arrived within 75 to 193 ms measured at the P4 with the BLE round trip included. Thirteen destinations answered.

Identification, and the quiet-time guess

Stage two opens each channel properly, sends parameters, starts a diagnostic session with 10 89, and asks for identification with 1A 9B. Through an adapter that returns one command at a time over BLE, a reply of unknown length had to be ended by guessing how long the bus would stay quiet, and every guess lost a module.

At 150 ms, a module waiting for my acknowledgement repeated its 50 89 every 100 ms or so, the bus was never quiet, and after three repeats it sent a disconnect. My acknowledgement then went to a closed channel. Earlier runs had only passed because adaptive timing was returning early. At 40 ms five modules identified themselves and the airbag controller's reply came apart.

The answer was to stop guessing. TP2.0 lets the receiver state a block size in its parameters, and asking for one packet per acknowledgement means the module sends one packet and waits. Each adapter command then expects exactly one frame, with a generous window and no quiet-time at all. The first run with that in place had the engine controller resend a packet mid-message, because its timer expired before my acknowledgement reached it, and my receiver flagged the resend as out of sequence. It treats a resent packet as a repeat to acknowledge again now.

Thirteen responders across six runs. The three probe runs on the left, the three identification runs on the right, and which of them lost which module.

Six modules identify themselves, on a single run:

01   1K8 907 115 J    0020   2.0l R4/4V TFSI
02   02E 300 062 G    4002   GSG DSG AG6
05   1K0 909 605 AE   .300   13 AIRBAG VW8
09   1K0 909 144 R    3501   EPS_ZFLS Kl.
22   1Q0 959 701 H    2162   Tuer-SG
23   1Q0 959 702 H    2162   Tuer-SG

Destination 03 answers every session request with busyRepeatRequest and has done so on every run. 0C, 1F, 20, 28 and 33 open a channel and then refuse ReadEcuIdentification outright, with option 9B and with option 9C alike, so what they are stays unknown. 29 answers setup on some runs and says nothing after it. The two Tuer-SG entries are door controllers, and their part numbers and software level are the fact the mirror work had been waiting on. Which door is which is another matter. A Ross-Tech scan of a 2014-15 Scirocco lists 701 H as the driver's side, but that is somebody else's car, and on this one it is not established.

Ten measuring groups

ReadDataByLocalIdentifier needs no login and was already on the encoder's allowlist, so I asked destination 22 for its measuring groups. It answers groups 01 through 0A, four fields each, in the VAG measuring-block form, and refuses 0B onwards as out of range. The field types decode from vag-blocks: 25 is a 16-bit status word and 21 a percentage. What the bits mean lives in proprietary label files, so they are recorded raw and left unnamed.

The panel, in the evening

With the link reliable, the panel started earning its screen. The adapter's supply voltage is polled and shown in the header. At link-up the firmware reads the VIN over mode 09 and matches it against a small registry, so the car is named rather than reduced to a status word.

The panel with the adapter at 11.6 V and the car identified. Framebuffer capture from the P4.

Where the day ended

137 host cases, 1,033 assertions, and a diagnostic core that reaches thirteen of this car's controllers through an adapter that three weeks ago had never answered. Six of them are identified by their own word.

Plenty is not. The GT911 acknowledges on I2C and the shell is wired to it, but two touch-watch windows saw no events and I could not say afterwards whether a finger had been on the glass; touch is unvalidated. Reconnecting after the adapter itself is power-cycled has not been tried. The S3's silence stays unexplained. And the two door modules are identified without being told apart, which is where the next post starts.