Skip to content

Firmware Architecture

Multi-protocol automotive bus library for ESP32. Handles BMW I/K-Bus and OBD-II K-line (ISO 9141 / ISO 14230) over a shared single-wire bus interface with PC817 optocoupler isolation.

graph TD
    APP["<b>Application Layer</b><br/>main.cpp / obd2_scanner.cpp"]
    APP -->|"facade: zero-change migration"| FACADE["<b>IbusEsp32</b><br/>Backward-compatible wrapper"]
    FACADE --> IBUS

    subgraph Protocol Handlers
        IBUS["<b>IbusHandler</b><br/>BMW I/K-Bus FSM<br/>XOR checksum<br/>Source filtering<br/>Packet callback"]
        OBD2["<b>KLineObd2</b><br/>ISO 9141 slow init<br/>ISO 14230 fast init<br/>Echo clearing<br/>Frame parsing<br/>TesterPresent"]
    end

    IBUS -->|"reads RX ring<br/>delegates TX"| TRANSPORT
    OBD2 -->|"reads RX ring<br/>sends via serial()<br/>flushes RX"| TRANSPORT

    TRANSPORT["<b>KLineTransport</b><br/>UART · GPIO ISR · esp_timer<br/>RX ring 256B · TX ring 128B<br/>Bus idle detection<br/>TX inversion · KLineConfig"]

    TRANSPORT --> RING["<b>RingBuffer</b><br/>malloc'd circular buffer<br/>Bounds-checked peek/remove"]

IbusHandler and KLineObd2 sit as parallel protocol handlers on top of a shared KLineTransport. Neither knows about the other. IbusEsp32 is a facade that composes one transport and one handler, preserving the original API for existing sketches.

firmware/lib/KLine/
library.json PlatformIO metadata (v2026.02.13)
KLineTransport.h/cpp Shared hardware layer
IbusHandler.h/cpp BMW I/K-Bus protocol
KLineObd2.h/cpp OBD-II K-line protocol
IbusEsp32.h/cpp Backward-compatible facade
RingBuffer.h/cpp Circular byte buffer
E46Codes.h ~100 BMW E46 command arrays (namespace ibus{})
Obd2Pids.h OBD-II PID constants + decode helpers (namespace obd2{})

Transport and protocol are independent concerns. KLineTransport handles UART configuration, pin management, interrupt routing, ring buffers, and bus idle timing. IbusHandler parses BMW message framing. KLineObd2 runs ISO 9141/14230 init sequences and request/response cycles. These share no protocol logic with each other.

Both handlers need ring buffer access, but they use it differently. IbusHandler peeks ahead into the RX ring without consuming bytes until a complete message with valid checksum is found. KLineObd2 reads bytes one at a time with timeouts, blocking until a response arrives. Inheritance would force a common interface where none exists.

No virtual functions. On ESP32 (Xtensa/RISC-V, single-core Arduino loop), vtable dispatch adds overhead and indirection for no benefit. The handler type is known at compile time. References are resolved directly.

The facade (IbusEsp32) preserves the original API so existing sketches like main.cpp need zero changes. It owns the transport and handler as member variables and delegates every call:

// IbusEsp32.cpp — the entire implementation
void IbusEsp32::run() {
_transport.drainUart();
_handler.process();
_transport.run();
}

New code that needs both protocols (or just OBD-II) can use KLineTransport and KLineObd2 directly, bypassing the facade entirely.

The shared hardware layer. Owns all physical resources: UART peripheral, GPIO interrupt, esp_timer, and both ring buffers.

Everything protocol-specific is parameterized through KLineConfig:

struct KLineConfig {
uint32_t baud = 9600; // 9600 for BMW, 10400 for OBD-II
uint32_t framing = SERIAL_8E1; // 8E1 for BMW, 8N1 for OBD-II
uint16_t idleTimeoutUs = 1500; // 0 = no idle detect (master/slave)
uint16_t idleCheckUs = 250; // timer resolution
uint16_t packetGapMs = 10; // min gap between own packets
bool txInvert = true; // true for optocoupler, false for transistor
ChecksumType checksumType = CHECKSUM_XOR; // XOR or MOD256
};

Setting idleTimeoutUs = 0 disables bus idle detection entirely. The transport sets _clearToSend = true at init and keeps it there. This is the correct mode for OBD-II K-line, which is master/slave — the tester owns the bus after init, there is no contention to detect.

Setting idleTimeoutUs = 1500 enables multi-master mode for BMW I/K-Bus. The transport monitors the RX pin for bus activity and only permits TX after 1.5ms of silence.

The PC817 optocoupler inverts the TX signal (TX HIGH drives the bus LOW). KLineTransport calls uart_set_line_inverse(UART_SIGNAL_TXD_INV) when txInvert = true, so the UART peripheral itself handles the inversion. No software bit-flipping needed.

For direct transistor circuits (no optocoupler), set txInvert = false.

Protocol handlers access the RX ring through transport methods:

  • rxAvailable() — bytes waiting in the ring
  • rxPeek(n) — look at byte n without consuming (IbusHandler uses this heavily)
  • rxRemove(n) — discard n bytes (after bad checksum or filter reject)
  • rxRead() — consume one byte (KLineObd2 reads byte-by-byte with timeouts)
  • flushRx() — drain both UART FIFO and ring (KLineObd2 calls this after init sequences)
  • drainUart() — move bytes from UART FIFO into ring buffer

The transport also exposes serial() for direct UART access. KLineObd2 needs this for init sequences where it writes individual bytes and reads echoes outside the normal ring buffer flow.

write() appends a framed message to the TX ring: a length prefix byte, the message bytes, and a checksum byte (computed per config.checksumType). The entire message must fit or the entire message is dropped — no partial writes.

sendRaw() bypasses the TX ring and writes directly to the UART. KLineObd2 uses this for init pulse sequences that do not follow normal message framing.

TX scheduling happens in run() -> trySend():

  1. Check _clearToSend (bus idle)
  2. Check TX ring has data
  3. Check packetGapMs has elapsed since last TX
  4. Send next packet from ring

KLineObd2 needs to detach the UART from the TX pin during 5-baud slow init (bit-bang at 200ms per bit) and fast init (25ms LOW/HIGH pulse). The transport exposes txPin() and uartNum() for this. After init, KLineObd2 re-attaches the UART with uart_set_pin().

BMW I/K-Bus protocol parser. Takes a reference to KLineTransport and reads from its RX ring buffer.

stateDiagram-v2
    [*] --> FIND_SOURCE
    FIND_SOURCE --> FIND_LENGTH
    FIND_LENGTH --> FIND_MESSAGE
    FIND_LENGTH --> FIND_SOURCE : bad length
    FIND_MESSAGE --> GOOD_CHECKSUM
    FIND_MESSAGE --> FIND_SOURCE : timeout 100ms\nremove 1 byte
    GOOD_CHECKSUM --> FIND_SOURCE : callback fired
    GOOD_CHECKSUM --> BAD_CHECKSUM
    BAD_CHECKSUM --> FIND_SOURCE : remove 1 byte\nretry

The FSM peeks into the RX ring without consuming bytes until it has a complete, checksum-validated message. On GOOD_CHECKSUM, it reads the bytes out of the ring and fires the callback. On BAD_CHECKSUM, it removes one byte (the presumed bad source byte) and restarts — this slides the window forward through noise or framing errors.

The 100ms timeout in FIND_MESSAGE prevents the parser from stalling on partial messages. At 9600 baud, a maximum-length I-Bus message (36 data bytes + overhead = ~40 bytes) takes approximately 44ms. If the remaining bytes never arrive (sender crashed, bus glitch), the timeout fires, removes the source byte, and the FSM restarts.

XOR of all bytes from source through the last data byte. The result must equal the final byte of the message. This is computed by peeking through the ring without consuming:

uint8_t checksum = 0;
for (int i = 0; i <= _length; i++) {
checksum ^= _transport.rxPeek(i);
}
if (_transport.rxPeek(_length + 1) == checksum) { ... }

Optional. When enabled, only messages from specified source addresses pass through to the callback. Others are silently discarded (bytes removed from ring). Up to 16 filter addresses. Off by default (sniffer mode — all traffic passes through).

using PacketCallback = std::function<void(const uint8_t* packet, uint8_t length)>;

The callback receives the complete raw message including source, length, destination, data, and checksum bytes. The length parameter is total byte count (not the length field from byte 1). The callback runs synchronously inside process() — keep it short.

The activity LED toggles around the callback: ledOn() before, ledOff() after.

OBD-II K-line handler for ISO 9141-2 (5-baud slow init) and ISO 14230 / KWP2000 (fast init). Takes a reference to KLineTransport. Operates in a blocking request/response style — call requestPid() and it blocks until the ECU responds or times out.

Full sequence:

  1. Detach UART TX from GPIO pin via pinMatrixOutDetach()
  2. Hold TX HIGH for 300ms (W0 idle period)
  3. Bit-bang target address (default 0x33) at 5 baud: 200ms per bit, LSB first, start/stop framing. Total: 2 seconds for one byte
  4. Re-attach UART TX via uart_set_pin()
  5. Flush RX (UART received garbage framing errors during the 2-second bit-bang)
  6. Read sync byte (0x55) — ECU confirms baud rate
  7. Read keyword bytes (KW1, KW2) — ECU declares protocol capabilities
  8. Send inverted KW2 back to ECU — tester confirms handshake
  9. Verify echo of inverted KW2 (half-duplex bus)
  10. Read inverted target address from ECU — final acknowledgment

After success, _initialized = true and the session is active.

  1. Detach UART TX, hold HIGH 300ms
  2. TiniPulse: 25ms LOW + 25ms HIGH on TX pin
  3. Re-attach UART, flush RX
  4. Send StartCommunication request (service 0x81, functional addressing)
  5. Verify echo (5 bytes)
  6. Read and parse StartComm positive response (0xC1 = 0x81 + 0x40)
  7. Extract keyword bytes from response

K-line is a single wire. Everything you transmit appears on the RX line as well. clearEcho() reads back each transmitted byte and compares against the expected value. A mismatch means bus contention — another device was transmitting simultaneously.

The format byte encodes both address mode and data length:

Bits 7-6: Address mode (0=none, 2=physical, 3=functional)
Bits 5-0: Data length (0 = separate length byte follows address bytes)

parseFrameData() decodes this structure and returns the index of the first data byte (service ID position). Both readResponse() and requestPid() use this to locate response data regardless of header format.

ECUs drop the diagnostic session after the P3 timeout (~5 seconds). requestPid() checks elapsed time since the last request and sends TesterPresent (service 0x3E) automatically if more than 4 seconds have passed. This is transparent to the caller.

If the ECU returns service 0x7F (negative response), requestPid() returns 0. In debug mode, it prints the NRC (Negative Response Code). No automatic retry — the caller decides what to do.

Call reset() to clear the _initialized flag. Then call slowInit() or fastInit() again. This is the correct response after a bus error, timeout, or P3 session expiration.

Backward-compatible wrapper. Owns a KLineTransport and an IbusHandler as member variables:

class IbusEsp32 {
private:
KLineTransport _transport;
IbusHandler _handler;
};

The constructor initializes _handler with a reference to _transport. begin() populates a KLineConfig from the IBUS_* defines and calls _transport.begin(). Every other method is a one-line delegation.

Existing code like the bus sniffer in main.cpp uses IbusEsp32 and needs no changes:

IbusEsp32 ibus;
ibus.begin(Serial1, RX_PIN, TX_PIN, LED_PIN);
ibus.onPacket(callback);
// loop:
ibus.run();

The IBUS_* defines in config.h are re-exported by IbusEsp32.h with defaults, so #include "IbusEsp32.h" still works without #include "config.h". The KLINE_* buffer size defines in KLineTransport.h fall through to IBUS_* defines if present, maintaining backward compatibility with existing platformio.ini build flags.

FeatureBMW I/K-BusOBD-II K-line
Baud960010400
Framing8E1 (even parity)8N1 (no parity)
ChecksumXOR of all bytesSum mod 256
Bus modelMulti-masterMaster/slave
Init sequenceNone (always-on bus)5-baud slow or fast init pulse
Idle detection1.5ms quiet before TXNot needed (tester owns bus)
Message formatSource + Length + Dest + Data + XORFormat + [Addr] + [Len] + Data + Sum
TX styleQueued (ring buffer, async)Direct (blocking request/response)
SessionNone (stateless bus)Maintained (P3 keepalive required)
TX inversionYes (optocoupler)No (transistor circuit)

Three execution contexts share state:

  1. GPIO ISR (onRxPinChange, IRAM_ATTR) — runs on any RX pin edge. Records _lastRxTransitionUs and sets _clearToSend = false. Returns immediately if _isTransmitting is true (suppresses loopback echo from own TX).

  2. esp_timer callback (onIdleTimerCallback) — runs every 250us from timer interrupt context. Checks if (now - _lastRxTransitionUs) >= idleTimeoutUs. If so, sets _clearToSend = true. Skips check if already clear or currently transmitting.

  3. Arduino loop (run() / trySend() / sendNextPacket()) — reads _clearToSend to decide whether to transmit. Sets _isTransmitting = true before writing to UART, clears it after flush() + 200us settling delay.

_lastRxTransitionUs is uint32_t, not int64_t. On 32-bit ESP32 (Xtensa LX6/LX7, RISC-V), 32-bit aligned reads and writes are atomic. This prevents torn reads where the ISR updates the timestamp while the timer callback is reading it.

_clearToSend and _isTransmitting are volatile bool. Single-byte writes are atomic on all ESP32 variants.

When transmitting, the UART TX data appears on the RX pin (optocoupler loopback or half-duplex bus echo). Without protection, the GPIO ISR would interpret this as bus activity from another module and clear _clearToSend, blocking subsequent transmissions.

The fix: set _isTransmitting = true before writing, and the ISR checks it first:

void IRAM_ATTR KLineTransport::onRxPinChange(void* arg) {
KLineTransport* self = static_cast<KLineTransport*>(arg);
if (self->_isTransmitting) return; // ignore own loopback
...
}

After the last byte is flushed, the transport resets the idle timer and clears _isTransmitting with a compiler memory barrier between them:

_lastRxTransitionUs = (uint32_t)esp_timer_get_time();
_clearToSend = false;
__asm__ __volatile__("" ::: "memory");
_isTransmitting = false;

The barrier ensures the idle timer reset is visible to the timer callback before _isTransmitting goes false. Without it, the compiler could reorder the stores and the timer callback might see _isTransmitting = false while _clearToSend is still stale, potentially granting a premature clear-to-send.

  • Atomic TX writes: write() checks free space in the TX ring before writing anything. If the entire message (length prefix + body + checksum) does not fit, the whole message is dropped. No partial packets in the ring.

  • TX corruption recovery: sendNextPacket() reads the length prefix from the TX ring. If it is zero, negative, or exceeds KLINE_MAX_MSG, the entire ring is flushed. Same if the ring has fewer bytes than the length claims (underrun). This prevents one corrupt message from poisoning all subsequent transmissions.

  • Ring buffer bounds: peek(n) validates n against available(), returns -1 if out of range. remove(n) clamps to available(). malloc failure sets _size = 0, disabling all operations rather than dereferencing null.

  • FSM timeout: The FIND_MESSAGE state has a 100ms watchdog. If the expected bytes never arrive, the FSM drops the candidate source byte and restarts. This prevents a permanent stall from a truncated message (bus glitch, sender crash mid-packet).

  • RX flush after init: KLineObd2 calls flushRx() after both slow and fast init. During the 2-second 5-baud bit-bang, the UART RX is receiving framing errors at whatever the bit-bang edges look like at 10400 baud. These garbage bytes must be discarded before reading the ECU response.

  • Echo verification: clearEcho() reads back every transmitted byte and compares against expected. Mismatch indicates bus contention (another device was transmitting). Init sequences abort on echo mismatch rather than trying to parse a corrupted response.