i-k-bus-board/docs/architecture.md
Ryan Malloy 6d40c9e30b Add project docs: development journal, architecture, circuit design
Three reference documents built from conversation history and codebase:
- development-journal.md: chronological engineering log across 4 sessions
  (SPICE validation, R2 fix, ESP32 port, multi-protocol refactor, hardening)
- architecture.md: AutoWire composition pattern, concurrency model,
  protocol comparison, defensive design
- circuit-design.md: PC817 optocoupler RX/TX paths, R2 sweep data,
  K-line compatibility analysis, SPICE netlist inventory

Also updates CLAUDE.md with OBD-II hardening notes from code review.
2026-02-13 06:25:19 -07:00

18 KiB

AutoWire Library 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.

Composition Diagram

                    Application Layer
     +-----------------------------------------+
     |             main.cpp (sniffer)           |
     |                                          |
     |   IbusEsp32 ibus;                        |
     |   ibus.begin(Serial1, RX, TX, LED);      |
     |   ibus.onPacket(callback);               |
     |   ibus.run();                             |
     +-----------------------------------------+

                       |   (facade: zero-change migration)
                       v

     +-------------------+   +-------------------+
     |    IbusHandler    |   |    KLineObd2      |
     |                   |   |                   |
     | BMW I/K-Bus FSM   |   | ISO 9141 slow init|
     | XOR checksum      |   | ISO 14230 fast    |
     | Source filtering   |   |   init            |
     | Packet callback   |   | Echo clearing     |
     |                   |   | Frame parsing     |
     |                   |   | TesterPresent     |
     +--------+----------+   +---------+---------+
              |                         |
              |  reads RX ring          |  reads RX ring
              |  delegates TX           |  sends via serial()
              |                         |  flushes RX
              v                         v
     +-----------------------------------------+
     |            KLineTransport                |
     |                                          |
     | UART (HardwareSerial)                    |
     | GPIO ISR (IRAM_ATTR, RX pin)             |
     | esp_timer (periodic idle check)          |
     | RX ring buffer (256 bytes)               |
     | TX ring buffer (128 bytes)               |
     | Bus idle detection                       |
     | TX inversion (uart_set_line_inverse)     |
     | Configurable: KLineConfig struct         |
     +-----------------------------------------+

              |
              v

     +-----------------------------------------+
     |             RingBuffer                   |
     |                                          |
     | malloc'd circular buffer                 |
     | Bounds-checked peek(n), remove(n)        |
     | capacity = size - 1 (standard ring)      |
     +-----------------------------------------+

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.

File Inventory

firmware/lib/AutoWire/
  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{})

Why Composition (not Inheritance)

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.

KLineTransport

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

Configuration

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.

TX Inversion

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.

Ring Buffer Access

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.

TX Queue

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

Pin Exposure

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().

IbusHandler

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

State Machine

FIND_SOURCE --> FIND_LENGTH --> FIND_MESSAGE --> GOOD_CHECKSUM --> (callback, back to FIND_SOURCE)
     ^              |                |                   |
     |              |                |                   v
     +----- bad ----+         timeout (100ms)      BAD_CHECKSUM ----> remove 1 byte, retry
     |                         remove 1 byte
     +<-----------------------------+

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.

Checksum

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) { ... }

Source Filtering

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).

Packet Callback

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.

KLineObd2

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.

5-Baud Slow Init (ISO 9141-2)

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.

Fast Init (ISO 14230, KWP2000)

  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

Half-Duplex Echo

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.

ISO 14230 Frame Parsing

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.

TesterPresent Keepalive

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.

Negative Response Handling

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.

Session Recovery

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.

IbusEsp32 (Facade)

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.

Protocol Comparison

Feature BMW I/K-Bus OBD-II K-line
Baud 9600 10400
Framing 8E1 (even parity) 8N1 (no parity)
Checksum XOR of all bytes Sum mod 256
Bus model Multi-master Master/slave
Init sequence None (always-on bus) 5-baud slow or fast init pulse
Idle detection 1.5ms quiet before TX Not needed (tester owns bus)
Message format Source + Length + Dest + Data + XOR Format + [Addr] + [Len] + Data + Sum
TX style Queued (ring buffer, async) Direct (blocking request/response)
Session None (stateless bus) Maintained (P3 keepalive required)
TX inversion Yes (optocoupler) No (transistor circuit)

Concurrency Model

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.

Atomicity

_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.

TX Loopback Race

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.

Defensive Design

  • 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.