Astro/Starlight documentation site with 8 content pages: - Circuit design (SPICE validation, R2 sweep data, K-line compat) - Firmware architecture (composition diagram, concurrency model) - BMW I/K-Bus protocol (message format, module addresses) - OBD-II K-line protocol (init sequences, PID decode formulas) - Bill of materials - Getting started guide - Development journal (4-session engineering log) Docker deployment: multi-stage build (Node builder + Caddy static), caddy-docker-proxy labels, Makefile for management.
359 lines
18 KiB
Plaintext
359 lines
18 KiB
Plaintext
---
|
|
title: Firmware Architecture
|
|
description: "AutoWire library composition pattern and concurrency model"
|
|
---
|
|
|
|
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:
|
|
|
|
```cpp
|
|
// 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`:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
_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.
|