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:

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.

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.

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.

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.

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.