Skip to content

Development Journal

This is a working log of the BMW I/K-Bus interface board project. It started as a straightforward port of muki01’s optocoupler design for an ESP32 and ended up becoming a multi-protocol automotive bus library. The entries here are roughly chronological, split across four working sessions. I’m keeping track of what actually happened - including the dumb mistakes and dead ends - because that’s the stuff you need when you come back to a project six months later and wonder “why did I do it that way?”

The hardware side is based on muki01/I-K_Bus, which is an optocoupler-isolated interface for BMW’s proprietary body/instrumentation bus. The original design targets a 5V Arduino Nano. We’re targeting a 3.3V ESP32. That voltage difference turned out to matter more than I expected.


Session 1: SPICE Simulation of the Optocoupler Circuit

Section titled “Session 1: SPICE Simulation of the Optocoupler Circuit”

The project kicked off with a design brief already written up - the full CLAUDE.md had the schematic, protocol spec, module address map, the works. What it didn’t have was any proof the circuit actually works at 3.3V. The muki01 design was built for 5V Arduino, and we’re running on an ESP32 at 3.3V. Time to fire up the simulator.

The tool here is LTspice via the mcp-ltspice MCP server, which lets us build and simulate SPICE netlists programmatically. I was half expecting to need to build a PC817 model from scratch - optocoupler models can be weird because you’re dealing with an LED optically coupled to a phototransistor, and not every SPICE library bothers to include them.

Turns out LTspice already ships with a PC817 subcircuit in lib/sub/PC817.sub. Nice. It uses a voltage-controlled current source (VCCS, the G1 element) to model the optical coupling, with an Igain parameter that sets the current transfer ratio. Igain=1m gives you roughly PC817A behavior (~100% CTR). Not a perfect model of the real nonlinear CTR curve, but good enough for what we need.

Created two separate netlists: one for the RX path (bus to MCU) and one for the TX path (MCU to bus). Both failed on the first run.

The problem? The PC817 SPICE subcircuit pin order. The subcircuit defines its pins as:

.subckt PC817 1 2 3 4
* 1=Anode, 2=Cathode, 3=Collector, 4=Emitter

That’s not the physical DIP pin order. On the actual IC, pin 3 is the collector and pin 4 is the emitter, but in the SPICE netlist you instantiate it with positional arguments:

XU1 anode_net cathode_net collector_net emitter_net PC817 Igain=1m

I had the collector and emitter swapped. The phototransistor was backwards, so the emitter-follower output was doing nothing useful. Swapped them, both sims ran clean.

This is the kind of thing that eats an hour if you don’t know to look for it. The SPICE pin order is whatever the subcircuit author decided, and it might or might not match the physical package. Always check the .subckt definition.

The RX path was the easy one. Bus HIGH (12V idle) drives 5.4mA through R1 (2k) into U1’s LED. The phototransistor saturates, and in emitter-follower configuration (collector tied to VCC, emitter is the output), you get V(RX) pulled up toward VCC.

The bus signal for testing was byte 0x50 (the MFL steering wheel address) at 9600 baud, 8E1. That’s 01010000 in binary, but UART sends LSB first, so the actual bit pattern on the wire is 00001010 with even parity bit = 0.

MeasurementValueNotes
V(RX) HIGH3.128VNeeds to be > 2.475V (ESP32 VIH)
V(RX) LOW~0VNeeds to be < 0.825V (ESP32 VIL)
LED current5.21mAThrough R1 (2k) at 12V bus
Rise time (10-90%)5.8us5.6% of 104.17us bit time
Signal polarityPreservedBus HIGH = RX HIGH

Clean. 3.13V is 653mV above the ESP32 VIH threshold, and the rise time at 5.8us is well under the 104.17us bit period. The emitter-follower configuration preserves polarity (no signal inversion), which is convenient for the UART.

The TX path is more interesting. It goes: ESP32 TX pin -> R5 (470) -> R3 (10k) -> Q1 base (BC547) -> Q1 drives U2 LED through R2 -> U2 phototransistor pulls bus LOW.

With the original R2=470 at 5V VCC, the LED gets about 7.9mA. Plenty to saturate the phototransistor. But at 3.3V? Only 4.66mA. That’s where things got dicey.

But before we got to the R2 problem, the TX simulation revealed something interesting about the fundamental asymmetry of optocoupler-based bus interfaces:

  • Pulling the bus LOW: The phototransistor actively sinks current. Fast. About 5.8us fall time.
  • Releasing the bus HIGH: The phototransistor just turns off. The bus goes HIGH only because of the external pull-up resistor. Slow. 19us rise time with a 1k pull-up.

The pull-up has to charge whatever bus capacitance exists through whatever impedance the pull-up provides. That’s fundamentally slower than an active drive. This is why BMW designed the TH3122 transceiver IC - it has active drive in both directions. The optocoupler approach works fine at 9600 baud (19us is still only 18% of the bit period), but you can see why it wouldn’t scale to higher baud rates.

So the TX LED only gets 4.66mA at 3.3V with R2=470. Is that enough? Depends on the bus impedance.

If the bus pull-up is 10k? Sure, the phototransistor barely needs to sink any current. If the pull-up is 1k or lower? Now you need real drive current, and 4.66mA through the LED might not generate enough collector current to pull the bus below the LOW threshold.

Time for parameter sweeps. Three of them.

Sweep 1: R2 value (100-470 ohm) at worst-case CTR

Kept the bus at 1k pull-up (realistic worst case for a loaded BMW bus) and swept R2. The results told the story:

R2 (ohm)LED Current (mA)V(IBUS) LOW (V)Status
10015.00.17Solid
15011.90.19Solid
2209.70.27Solid
3307.00.24OK
3906.11.84Marginal
4704.72.51FAIL

The threshold is right around R2=330. At R2=390, the bus LOW voltage is already at 1.84V - getting uncomfortably close to logic-level ambiguity. At R2=470 (the original), V(IBUS) LOW is 2.51V. That’s not LOW. That’s “maybe LOW, maybe not, depends on who’s asking.”

Sweep 2: Bus pull-up impedance (510-10k ohm) at R2=470

Keeping the original R2=470 and sweeping the bus pull-up resistance:

R_PULL (ohm)V(IBUS) LOW (V)Status
5104.52FAIL
1k2.51FAIL
2k0.42OK
4.7k0.22Solid
10k0.15Solid

At 510 ohm pull-up, V(IBUS) LOW is 4.52V. That’s basically still HIGH. The phototransistor can’t sink enough current to overcome the pull-up. Even at 1k it’s still failing. You need the bus pull-up to be at least 2k for the original R2=470 to work at 3.3V.

Sweep 3: CTR grade (PC817A through D) at 1k bus

PC817 comes in four CTR grades. A is the cheapest (80-160%), D is the highest (200-400%). With R2=470 and 1k bus:

GradeIgainV(IBUS) LOW (V)Status
PC817A1m2.51FAIL
PC817B2m0.38OK
PC817C3m0.24Solid
PC817D4m0.19Solid

So if you use PC817B or better, the original R2=470 works even at 3.3V. But that’s adding a component constraint that doesn’t need to exist. Just change the resistor.

R2=220 doubles the LED current from 4.66mA to 9.71mA. That’s still only 19% of the PC817’s 50mA absolute maximum rating. Not even close to stressing the part.

Created a single-point validation netlist (reference/tx_validated_r2_220.cir) with worst-case conditions: PC817A grade, 1k bus pull-up, 3.3V VCC. Results:

MeasurementValue
V(IBUS) LOW0.266V
V(IBUS) swing8.08V (0.27V to 8.35V)
U2 LED current9.71mA
Q1 Vce(sat)0.073V
Bus rise time9.1us (8.7% of bit time)

0.266V. That’s definitively LOW on any bus. And it works with the cheapest PC817A grade, which means you can grab whatever’s in the parts bin.

K-line Compatibility Check (Spoiler: Nope)

Section titled “K-line Compatibility Check (Spoiler: Nope)”

While we had the simulator warmed up, I also checked whether this optocoupler circuit would work for the Tucker project’s OBD-II K-line interface. K-line has a 510 ohm pull-up to battery voltage - much stiffer than the BMW I/K-Bus.

With R2=220 and 510 ohm pull-up, the phototransistor needs to sink about 23.5mA. A PC817A at 9.71mA LED current generates maybe 22.4mA of collector current. That’s right at the edge - it might work, it might not, and “might work” is not a design spec for something plugged into a car.

Even dropping R2 to 100 ohm (20.5mA LED current) only barely gets there. The conclusion: optocouplers are the wrong tool for K-line’s low-impedance bus. The transistor design in the Tucker project is the right call for that use case. Different bus impedances, different hardware. Makes sense in retrospect.

The relevant netlists are saved at reference/opto_vs_kline_510.cir and reference/opto_vs_kline_ctr.cir for reference.


Session 2: ESP32 Port + Architecture Design

Section titled “Session 2: ESP32 Port + Architecture Design”

The muki01 IbusSerial library was written for AVR (Arduino Nano). It’s a solid piece of work - a proper state machine protocol handler with ring buffers for async message handling, bus contention timing, and sleep management. But it’s deeply tied to AVR hardware.

The major changes:

AVR ThingESP32 ReplacementWhy
Timer2 CTC mode (OCR2A=94, prescaler=256)esp_timer periodic callback at 250usAVR timer registers don’t exist on ESP32
TH3122 SEN/STA pin interruptGPIO interrupt on UART RX pin, CHANGE triggerWe don’t have a TH3122 - reading the RX line directly
SoftwareSerial (pins 7/8) for debugHardware UART0 (USB) at 115200ESP32 has 3 hardware UARTs, no need for software serial
PROGMEM / pgm_read_byte()Plain const arraysESP32 flash is memory-mapped, no special access needed
digitalPinToInterrupt(3)attachInterrupt(pin, isr, CHANGE) on any GPIOESP32 supports interrupts on any GPIO
TH3122 EN pin for sleep modeNot portedNo transceiver IC to put to sleep
Global volatile stateClass members with volatile where ISR-sharedCleaner encapsulation
TX signal polarity (for TH3122)uart_set_line_inverse(UART_SIGNAL_TXD_INV)Optocoupler inverts TX, so we invert it back in hardware

The TX inversion thing is worth explaining. The optocoupler design inverts the TX signal: when the ESP32 TX pin is HIGH (idle), Q1 turns ON, U2 LED fires, the phototransistor conducts, and the bus gets pulled LOW. That’s backwards from what the bus expects (bus idle should be HIGH). The muki01 AVR library works around this in software. On ESP32, we can just tell the UART peripheral to invert the TX output: uart_set_line_inverse(UART_NUM, UART_SIGNAL_TXD_INV). Hardware fix, no software gymnastics needed.

The bus idle detection was the trickiest part to port. The original uses AVR Timer2 in CTC (Clear Timer on Compare) mode. The timer fires an interrupt every ~1.5ms. If the SEN/STA pin hasn’t triggered during that window, the bus is idle and it’s safe to transmit.

Without the TH3122 chip, we don’t have a SEN/STA pin. Instead: GPIO interrupt on the RX pin (CHANGE trigger) to detect any bus activity, combined with an esp_timer periodic callback at 250us. Each time the timer fires, it checks if the time since the last RX transition exceeds 1500us. If yes, the bus is idle. Worst-case detection latency is idle_timeout + 250us = 1750us, which is well within the ~10ms inter-packet budget on a busy BMW bus.

Set up a PlatformIO project with three build environments: ESP32 (classic), ESP32-C3 (RISC-V), and ESP32-S3. Each has different pin assignments because the GPIO numbering varies across variants:

  • ESP32: GPIO 16/17 (UART2 defaults, free on most devkits)
  • ESP32-C3: GPIO 4/5 (general purpose), LED on GPIO 8
  • ESP32-S3: GPIO 15/16 (free on S3-DevKitM), LED on GPIO 48 (RGB)

All config is through -D build flags in platformio.ini, with config.h providing defaults. The main sketch (main.cpp) is a bus sniffer - it prints every I/K-Bus message it sees with module name lookup:

50 MFL -> 68 RAD [32 11] chk=1F

That’s a volume-up command from the steering wheel to the radio. The module lookup table maps addresses like 0x50 to “MFL” (Multi-Function steering wheel) and 0x68 to “RAD” (Radio). There are 17 modules in the lookup table. Anything that doesn’t match gets ”???” which is probably fine for initial testing.

All three environments build clean. Zero warnings.

Ran the code through review and it caught three real concurrency issues. These aren’t hypothetical - they’re the kind of bugs that work fine 99.9% of the time and then corrupt your data on a busy bus when you’re trying to demo something.

Bug 1: 64-bit Torn Reads

The original timestamp _lastRxTransitionUs was int64_t. On a 32-bit ESP32, reading a 64-bit value isn’t atomic. The GPIO ISR writes this variable on every RX edge. The timer callback reads it every 250us. If the timer reads between the two 32-bit halves of a write, you get garbage. The value could jump forward or backward by billions of microseconds.

Fix: Changed to uint32_t. Atomic on 32-bit CPUs. Wraps every ~71 minutes, which is fine - we only care about differences of 1500us.

Bug 2: TX Loopback Race

When transmitting, the code sets _isTransmitting = true to prevent the idle detection from triggering on our own TX bytes (which loop back on a half-duplex bus). After the last byte goes out, the code was clearing _isTransmitting before resetting the idle timer. This creates a tiny window where:

  1. Last TX byte finishes
  2. ISR fires from the TX loopback
  3. _isTransmitting gets cleared
  4. Timer fires, sees “idle” (because the idle timer wasn’t reset yet)
  5. Starts another TX while our loopback bytes are still on the bus

Fix: Reset the idle timer FIRST, then clear _isTransmitting, with a compiler memory barrier (__asm__ volatile("" ::: "memory")) between them to prevent reordering.

Bug 3: Partial TX Writes

The write() method was adding bytes to the TX ring buffer one at a time. If the buffer was almost full, you could get the source byte and length byte in the buffer but not the rest of the message. Now you have a partial packet in the ring, and the TX state machine will try to send it, interpret whatever comes after as data, and put garbage on the bus.

Fix: Check if the ENTIRE message fits in the ring buffer before writing any of it. All-or-nothing. Either the complete message goes in, or nothing does.

Then came a question that changed the project scope: can this library support both BMW I/K-Bus AND OBD-II K-line?

The two protocols share the same physical layer - single-wire, half-duplex, open-collector/drain. But everything above that differs: baud rate (9600 vs 10400), framing (8E1 vs 8N1), checksum (XOR vs additive mod 256), bus access model (multi-master vs master/slave), and initialization (none vs 5-baud wake-up).

The hardware layer is almost identical though. UART, GPIO interrupt, ring buffers, idle detection, TX inversion - all the same. So the design fell out naturally:

+-----------------+
| KLineTransport | UART, GPIO ISR, ring buffers, idle detect
+-----------------+
/ \
+------------+ +------------+
| IbusHandler| | KLineObd2 | Protocol-specific FSMs
+------------+ +------------+
|
+------------+
| IbusEsp32 | Backward-compatible facade
+------------+

KLineTransport owns all the hardware. Protocol-agnostic. Configurable baud, framing, checksum style.

IbusHandler is the BMW I/K-Bus FSM extracted from IbusEsp32. It reads from the transport’s RX ring, validates XOR checksums, applies source filtering, and delivers packets via callback.

KLineObd2 is new - OBD-II protocol handler with slow init, fast init, request/response.

IbusEsp32 stays as a thin facade wrapping Transport + Handler, so the existing BMW sniffer sketch doesn’t need any changes. Drop-in compatible.


This was the big build session. Three phases, done sequentially because each depended on the last.

The rule here was: extract KLineTransport and IbusHandler from the existing IbusEsp32 code, rewire everything, and verify that all three ESP32 environments still build with zero changes to main.cpp. If the sniffer sketch works identically before and after, the refactor is clean.

Created lib/KLine/ with the new file structure:

lib/KLine/
KLineTransport.h / .cpp <-- extracted from IbusEsp32
IbusHandler.h / .cpp <-- extracted from IbusEsp32
IbusEsp32.h / .cpp <-- now just a facade
KLineObd2.h / .cpp <-- (Phase 2)
Obd2Pids.h <-- (Phase 2)
RingBuffer.h / .cpp <-- unchanged
E46Codes.h <-- unchanged
library.json

The extraction was surgical. ISR code, timer code, ring buffer management, UART init, TX inversion - all went into KLineTransport. The BMW protocol state machine (FIND_SOURCE -> FIND_LENGTH -> FIND_MESSAGE -> checksum validation), source filtering, and callback dispatch went into IbusHandler. IbusEsp32 got rewritten to literally just hold a Transport pointer and a Handler pointer.

One wrinkle: the debug macros. The original code used IBUS_DEBUG to gate Serial.printf() calls. Since KLineTransport is shared between protocols, I renamed the macro to KLINE_DEBUG internally but kept IBUS_DEBUG as an alias in the build flags so existing platformio.ini configs don’t break. Same with buffer size macros - IBUS_RX_BUFFER_SIZE still works, it just maps to the transport’s buffer.

All three environments built. main.cpp untouched. Phase 1 done.

Now the new stuff. KLineObd2 needed to handle two initialization methods (because different ECUs support different ones) and the request/response cycle.

5-Baud Slow Init (ISO 9141)

This is the weird one. To wake up an ECU, you send a target address at 5 baud. Five. Baud. That’s 200ms per bit. A single byte takes 2 seconds to transmit. You can’t use a UART for this - no UART peripheral supports 5 baud. You have to bit-bang it.

The sequence:

  1. Detach the UART TX from the GPIO pin (so we can drive it manually)
  2. Drive TX LOW for 200ms (start bit)
  3. For each data bit: drive TX HIGH or LOW for 200ms
  4. Drive TX HIGH for 200ms (stop bit)
  5. Re-attach the UART TX
  6. Switch UART to 10400 baud, 8N1
  7. Wait for ECU to respond with sync byte (0x55) and two keyword bytes
  8. Send the inverted keyword2 back within 25ms (the “handshake”)

The default target address is 0x33, which is the ISO 9141 “all ECUs” address.

Fast Init (ISO 14230)

Much more civilized. Pull the TX line LOW for 25ms, then HIGH for 25ms (the “TiniPulse”), then send a StartCommunication request at 10400 baud. The ECU responds with its keywords. Faster but not universally supported.

Request/Response

OBD-II is half-duplex on K-line, which means you hear your own transmissions echoed back. The echo has to be cleared before you can read the real response. Standard stuff, but it means every send has to be followed by a read-and-discard of exactly the bytes you just sent.

Obd2Pids.h

Created a header with ~20 PID decode helpers from SAE J1979. Things like:

  • decodeRpm(a, b) -> (256 * a + b) / 4.0
  • decodeCoolantTemp(a) -> a - 40 (in celsius)
  • decodeSpeed(a) -> a (km/h, it’s that simple)
  • decodeThrottlePos(a) -> a * 100.0 / 255.0 (percent)

Plus the PID constants: PID_RPM = 0x0C, PID_SPEED = 0x0D, etc.

The Scanner Sketch

obd2_scanner.cpp is a simple loop that:

  1. Tries slow init, falls back to fast init
  2. Polls RPM, speed, coolant temp, throttle position, and battery voltage
  3. Prints results to serial monitor
  4. Sends TesterPresent every 4 seconds to keep the session alive

Hit a namespace collision here. I had a variable named obd2 and a namespace obd2 for the PID helpers. C++ didn’t appreciate that. Renamed the variable to scanner. Moving on.

Added build_src_filter directives to platformio.ini so each environment only compiles its sketch:

[env:esp32dev]
build_src_filter = +<main.cpp>
[env:obd2-scanner]
build_src_filter = +<obd2_scanner.cpp>

All four environments (3 BMW sniffer + 1 OBD-II scanner) built clean.

Updated library.json metadata with the new library name (K-Line) and file list. Updated CLAUDE.md with the architecture diagram, new file structure, OBD-II section, and the build instructions for the fourth environment. Not glamorous work but someone has to do it.


Ran the Apollo code review agent on the new OBD-II code. This is where it gets humbling.

C1: uart_set_pin() doesn’t actually detach the pin

The slow init sequence needs to bit-bang the TX pin at 5 baud. The code was calling uart_set_pin(UART_NUM, UART_PIN_NO_CHANGE, ...) thinking that UART_PIN_NO_CHANGE would disconnect the UART from the TX GPIO. It doesn’t. That constant means “don’t change this pin assignment” - it’s a no-op. The UART peripheral stays connected to the pin the entire time you’re trying to bit-bang it. So digitalWrite() and the UART are fighting over the same GPIO.

Fix: pinMatrixOutDetach(txPin, false, false) to actually disconnect the UART output matrix from the pin. Then reattachUartTx() reconnects it when bit-bang is done.

This one’s embarrassing because it’s the kind of thing you’d only catch by reading the ESP-IDF source code or by scoping the TX pin during init and seeing the UART peripheral trampling your bit-bang output.

C2: Timeout arithmetic underflow

readResponse() had code like:

uint16_t remaining = timeoutMs - (millis() - startMs);

If millis() - startMs exceeds timeoutMs, that unsigned subtraction wraps around to ~65535. Instead of timing out, the function waits another 65 seconds. On a real car, that means your scanner hangs for a minute every time an ECU doesn’t respond to a PID.

Fix: Added a remainingMs() helper that does the subtraction with signed comparison and clamps to 0:

static uint16_t remainingMs(unsigned long startMs, uint16_t timeoutMs) {
unsigned long elapsed = millis() - startMs;
if (elapsed >= timeoutMs) return 0;
return timeoutMs - (uint16_t)elapsed;
}

C3: readResponse() never validates checksum

The original code received the response, extracted the data bytes, and returned them. It never checked the checksum. On K-line, a corrupt byte is indistinguishable from a valid one unless you verify the additive mod-256 checksum at the end of each frame. Without this check, bus noise or partial collisions produce silently wrong data.

Fix: Compute checksumMod256 over all received bytes except the last, compare to the last byte. Reject the frame if they don’t match.

C4: clearEcho() doesn’t verify echo content

Half-duplex K-line means you hear your own TX echoed back on RX. The original clearEcho() just read N bytes and discarded them. It never compared them to what was actually sent. If another device was transmitting at the same time (bus contention), the echo bytes would be corrupted, and you’d never know. You’d then try to read a “response” that’s actually a mix of your echo and the other device’s data.

Fix: Compare each received echo byte against the expected byte. If they don’t match, return false and let the caller know the bus was busy.

I1: RX buffers not flushed after init

During 5-baud slow init, the UART is configured at 10400/8N1 but the pin is being bit-banged at 5 baud. The UART peripheral sees the 200ms-wide bit-bang pulses and tries to interpret them as 10400 baud bytes. It generates framing errors and fills the RX buffer with garbage. After init completes and real communication starts, this stale garbage is sitting in the buffer ahead of the real data.

Fix: flushRx() - clears both the UART hardware FIFO (via uart_flush_input()) and the ring buffer after init completes.

I2/I3/I5: Fixed-offset response parsing

The original code used hardcoded byte offsets to find the service ID in responses: buf[3] for one format, buf[2] for another. This works for the common case but ISO 14230 has multiple frame formats determined by the “format byte”:

  • Bits 7:6 = address mode (00 = no address, 01 = CARB mode, 10 = with address, 11 = with address)
  • Bits 5:0 = data length (0 = length in separate byte, 1-63 = length from format byte)

Depending on the address mode, the actual data (service ID) could be at offset 1, 3, or 4. The hardcoded offsets would break on ECUs that use different addressing modes.

Fix: Wrote parseFrameData() - a structural parser that reads the format byte, determines the address mode, finds the length (inline or separate byte), and returns the offset of the first data byte. Also sets dataLen so you know how many data bytes follow.

I4: Response buffer too small

Buffer was 16 bytes. Some OBD-II responses (especially multi-PID responses and VIN queries) can be longer. Bumped to 32 bytes.

I6: No TesterPresent keepalive, no session recovery

K-line diagnostic sessions have a timeout (P3max, typically 5 seconds). If the tester doesn’t send anything within that window, the ECU drops the session and you have to re-initialize. The original code had no keepalive mechanism and no way to detect or recover from a lost session.

Fix: P3_KEEPALIVE_MS = 4000 - automatically sends TesterPresent (service 0x3E) when the idle time approaches P3max. Also added consecutiveFailures tracking in the scanner sketch - after 5 consecutive failures, it calls reset() and re-runs the init sequence (slow init first, then fast init if slow fails).

I7: Missing null check after new

new KLineObd2() could return nullptr on memory exhaustion. The scanner sketch didn’t check. On an ESP32 with limited heap, this isn’t purely theoretical. Added the check.

All four PlatformIO environments still build clean. The commit message was straightforward: “Harden OBD-II K-line handler (code review findings)”.


Looking back across all four sessions, a few things stand out:

Simulate before you solder. The R2=220 fix came entirely from SPICE. If we’d built the prototype first with R2=470, we’d have had an intermittently-working TX path and spent hours with a scope trying to figure out why. The sweep netlists (tx_sweep_r2.cir, tx_sweep_rpull.cir, tx_sweep_ctr.cir) documented the entire operating envelope without touching a soldering iron.

Pin order is not pin numbering. SPICE subcircuit pin order is defined by the .subckt line. Physical IC pin numbering is defined by the package. They are not the same thing. Check the subcircuit definition. Every time.

Voltage matters more than you think. The jump from 5V to 3.3V cut the TX LED current almost in half (7.9mA to 4.66mA). That took the design from “works great” to “fails at normal bus loading.” A simple resistor change fixed it, but only because we caught it in simulation. On real hardware, it would’ve manifested as “sometimes my TX messages don’t work” which is one of the worst failure modes to debug.

Code review catches what testing misses. The uart_set_pin() no-op bug (C1) would’ve been invisible in a loopback test. The code would appear to work because the UART TX output and the bit-bang output happen to agree during the stop bit (both HIGH). It would only fail intermittently during the data bits when the UART tries to send its idle pattern while you’re pulling the line LOW for bit-bang. Good luck finding that with Serial.println() debugging.

Half-duplex is harder than it looks. Echo clearing, bus contention detection, checksum validation, session keepalive, timeout handling - each one is straightforward in isolation, but the combination creates a lot of edge cases. The original code handled the happy path. The hardening pass handled everything else.


As of the last commit, the project has:

  • Two validated SPICE netlists (RX and TX paths) plus four parameter sweep netlists
  • A multi-protocol PlatformIO library (K-Line) that builds for ESP32, ESP32-C3, and ESP32-S3
  • BMW I/K-Bus sniffer sketch (tested in simulation, not yet on hardware)
  • OBD-II K-line scanner sketch (code-reviewed, not yet on hardware)
  • 13 source files in the K-Line library
  • 4 PlatformIO build environments, all clean

What hasn’t been done yet: breadboard prototype, loopback testing, bench testing with a 12V supply, and actual vehicle testing. That’s the next chapter.

The hardware is simple enough - two PC817s, a BC547, five resistors, and a diode. The circuit has been validated in simulation down to specific voltages and currents at worst-case conditions. The firmware has been through two rounds of code review. At this point, the biggest unknown is what happens when you plug it into a real BMW and start listening to the K-Bus chatter from a dozen modules talking over each other.

Should be fun.