← Sketches · v0.2

v0.2 — OLED + Real OBD2 (Bluetooth ELM327)

Waiting on parts
Display
0.96" SSD1306 OLED
Data source
Bluetooth ELM327
Source file
sketches/v0.2_oled_real_obd/v0.2_oled_real_obd.ino

Goal

Replace the fake data generators from v0.1 with live OBD2 values streamed wirelessly from the car. Display layer stays identical — only the data layer changes.

Heads up: An earlier version of this plan used a USB-style ELM327 cable with a CP2102 USB-to-UART bridge and an F-F USB adapter. That does not work. The ESP32 dev board’s USB port is for flashing/power only — it cannot act as a USB host. Do not buy a USB ELM327 cable or a CP2102 for this project. Use a Bluetooth ELM327 dongle instead.

Hardware

  • LAFVIN ESP32 DevKit v1 (has built-in Bluetooth Classic)
  • 0.96” SSD1306 OLED (same I²C wiring as v0.1 — GPIO21 / GPIO22)
  • Bluetooth ELM327 dongle — must be Bluetooth Classic (SPP), not WiFi or iOS-only BLE

That’s it. No soldering, no UART tap, no voltage divider, no USB-to-TTL adapter.

See the OLED + Bluetooth ELM327 wiring doc for pairing instructions and example code.

Data layer: ELMduino over BluetoothSerial

The ESP32’s built-in classic Bluetooth radio connects to the dongle using the standard Bluetooth SPP (Serial Port Profile). From the firmware’s perspective it’s just a Stream — ELMduino talks OBD2 over it exactly as it would over a hardware UART.

Key PIDs are in the PID reference:

  • RPM (0x0C), Speed (0x0D), Coolant (0x05), Boost (0x0B), Battery (ATRV)

Status

Waiting for parts — Bluetooth ELM327 dongle on order. Once paired:

  1. Pair dongle once from a phone, note the MAC address and PIN (usually 1234)
  2. Hardcode the MAC + PIN into the ESP32 sketch
  3. Drop in the getRPM() / getCoolant() / etc. data functions behind the same interface v0.1 uses — display code doesn’t change

Why Bluetooth over USB

USB ELM327 cableBluetooth ELM327
Needs CP2102 USB-UARTYesNo
Needs soldering / tappingYes (UART tap approach)No
Works with ESP32No (ESP32 has no USB host)Yes (built-in BT Classic)
Stays in the carNo — cable in the wayYes — dongle stays in OBD port
Cost~€15 cable + €5 adapter + €5 CP2102~€10–€30 dongle, nothing else

Source — v0.2_oled_real_obd.ino

451 lines · pulled from the Arduino repo at build time

Show full source
// ============================================================
// BMW Dash Display — OLED + Real OBD2 Data
// Hardware: ESP32 DevKit + 0.96" SSD1306 OLED + Bluetooth ELM327
// Wiring:   OLED VCC→3.3V  GND→GND  SDA→GPIO21  SCL→GPIO22
//           ELM327 connects via Bluetooth — no wires needed
// Libraries: Adafruit SSD1306, Adafruit GFX, ELMduino
// ============================================================

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <BluetoothSerial.h>
#include <ELMduino.h>

// ── Display config ──────────────────────────────────────────
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64
#define OLED_RESET    -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// ── ELM327 Bluetooth config ────────────────────────────────
BluetoothSerial SerialBT;
#define ELM_NAME "OBDII"  // Change to match your dongle name (common: OBDII, OBD2, V-LINK)
ELM327 myELM327;

// ── Car data ────────────────────────────────────────────────
struct CarData {
  float rpm     = 0;
  float speed   = 0;
  float coolant = 0;
  float boost   = 0;
  float intake  = 0;
  float load    = 0;
  float battery = 0;
  float oil     = 0;
  bool  connected = false;
};
CarData car;

// ── OBD polling state machine ───────────────────────────────
enum OBD_PID {
  PID_RPM,
  PID_SPEED,
  PID_COOLANT,
  PID_BOOST,
  PID_INTAKE,
  PID_LOAD,
  PID_OIL,
  PID_BATTERY
};

// Priority schedule: RPM & speed polled most, temps least
const OBD_PID pollSchedule[] = {
  PID_RPM, PID_SPEED,
  PID_RPM, PID_SPEED, PID_BOOST,
  PID_RPM, PID_SPEED,
  PID_RPM, PID_SPEED, PID_LOAD,
  PID_COOLANT, PID_INTAKE,
  PID_RPM, PID_SPEED,
  PID_OIL, PID_BATTERY
};
const int SCHEDULE_LENGTH = sizeof(pollSchedule) / sizeof(pollSchedule[0]);
int scheduleIndex = 0;

int consecutiveErrors = 0;
const int ERROR_THRESHOLD = 20;

int oilTempFailures = 0;
bool oilTempSupported = true;

// ── Screen management ───────────────────────────────────────
int currentScreen = 0;
const int NUM_SCREENS = 4;
unsigned long lastScreenChange = 0;
const int SCREEN_INTERVAL = 5000;

// ============================================================
// ELM327 BLUETOOTH CONNECTION
// ============================================================

void connectELM327() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.print("Searching for");
  display.setCursor(0, 10);
  display.print(ELM_NAME);
  display.print("...");
  display.display();

  SerialBT.begin("BMW_DASH", true);  // true = master mode
  Serial.println("Bluetooth started — scanning...");

  if (!SerialBT.connect(ELM_NAME)) {
    display.setCursor(0, 30);
    display.print("BT pair failed!");
    display.setCursor(0, 40);
    display.print("Check dongle is on");
    display.display();
    Serial.println("Bluetooth pairing failed");
    car.connected = false;
    delay(3000);
    return;
  }

  display.setCursor(0, 30);
  display.print("BT paired! Init...");
  display.display();

  int attempts = 0;
  const int MAX_ATTEMPTS = 5;

  while (attempts < MAX_ATTEMPTS) {
    if (myELM327.begin(SerialBT, false, 2000)) {
      car.connected = true;
      consecutiveErrors = 0;
      display.setCursor(0, 50);
      display.print("CONNECTED!");
      display.display();
      Serial.println("ELM327 connected");
      delay(1000);
      return;
    }
    attempts++;
    delay(1000);
  }

  car.connected = false;
  display.setCursor(0, 50);
  display.print("ELM327 init failed");
  display.display();
  Serial.println("ELM327 init failed");
  delay(2000);
}

// ============================================================
// OBD POLLING — non-blocking state machine
// ============================================================

void updateOBD() {
  if (!car.connected) return;

  OBD_PID currentPid = pollSchedule[scheduleIndex];

  // Skip oil temp if unsupported
  if (currentPid == PID_OIL && !oilTempSupported) {
    scheduleIndex = (scheduleIndex + 1) % SCHEDULE_LENGTH;
    return;
  }

  float val = 0;

  switch (currentPid) {
    case PID_RPM:     val = myELM327.rpm();                break;
    case PID_SPEED:   val = myELM327.kph();                break;
    case PID_COOLANT: val = myELM327.engineCoolantTemp();  break;
    case PID_BOOST:   val = myELM327.manifoldPressure();   break;
    case PID_INTAKE:  val = myELM327.intakeAirTemp();      break;
    case PID_LOAD:    val = myELM327.engineLoad();         break;
    case PID_OIL:     val = myELM327.oilTemp();            break;
    case PID_BATTERY: myELM327.batteryVoltage();           break;
  }

  if (myELM327.nb_rx_state == ELM_SUCCESS) {
    consecutiveErrors = 0;

    switch (currentPid) {
      case PID_RPM:     car.rpm     = val;                         break;
      case PID_SPEED:   car.speed   = val;                         break;
      case PID_COOLANT: car.coolant = val;                         break;
      case PID_BOOST:   car.boost   = (val / 100.0) - 1.013;      break;
      case PID_INTAKE:  car.intake  = val;                         break;
      case PID_LOAD:    car.load    = val;                         break;
      case PID_OIL:
        car.oil = val;
        oilTempFailures = 0;
        break;
      case PID_BATTERY:
        car.battery = atof(myELM327.payload);
        break;
    }

    scheduleIndex = (scheduleIndex + 1) % SCHEDULE_LENGTH;
  }
  else if (myELM327.nb_rx_state != ELM_GETTING_MSG) {
    // Error — skip and move on
    consecutiveErrors++;

    if (currentPid == PID_OIL) {
      oilTempFailures++;
      if (oilTempFailures >= 3) {
        oilTempSupported = false;
        Serial.println("Oil temp PID not supported — disabling");
      }
    }

    scheduleIndex = (scheduleIndex + 1) % SCHEDULE_LENGTH;

    // Reconnect if too many errors in a row
    if (consecutiveErrors >= ERROR_THRESHOLD) {
      Serial.println("Too many OBD errors — reconnecting");
      car.connected = false;
      connectELM327();
    }
  }
  // ELM_GETTING_MSG → still waiting, just return (non-blocking)
}

// ============================================================
// DISPLAY SCREENS (identical to v0.1)
// ============================================================

void drawScreenDots() {
  int startX = (128 - (NUM_SCREENS * 10)) / 2;
  for (int i = 0; i < NUM_SCREENS; i++) {
    if (i == currentScreen) {
      display.fillCircle(startX + (i * 10), 62, 2, SSD1306_WHITE);
    } else {
      display.drawCircle(startX + (i * 10), 62, 2, SSD1306_WHITE);
    }
  }

  // Connection indicator
  if (!car.connected) {
    display.setTextSize(1);
    display.setCursor(90, 56);
    display.print("NOLINK");
  }
}

// Screen 1: RPM + Speed
void drawScreen1() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  // RPM
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("RPM");

  display.setTextSize(2);
  display.setCursor(0, 10);
  display.print((int)car.rpm);

  // Vertical divider
  display.drawFastVLine(68, 0, 42, SSD1306_WHITE);

  // Speed
  display.setTextSize(1);
  display.setCursor(74, 0);
  display.print("km/h");

  display.setTextSize(2);
  display.setCursor(74, 10);
  display.print((int)car.speed);

  // RPM bar graph
  display.drawFastHLine(0, 43, 128, SSD1306_WHITE);
  int rpmBar = map((int)car.rpm, 0, 4500, 0, 124);
  rpmBar = constrain(rpmBar, 0, 124);
  display.fillRect(2, 45, rpmBar, 6, SSD1306_WHITE);
  display.drawRect(2, 45, 124, 6, SSD1306_WHITE);

  // Coolant temp footer
  display.setTextSize(1);
  display.setCursor(0, 53);
  display.print("CLT:");
  display.print((int)car.coolant);
  display.print("C");

  drawScreenDots();
  display.display();
}

// Screen 2: Boost + Load
void drawScreen2() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("BOOST PRESSURE");

  // Boost value large
  display.setTextSize(2);
  display.setCursor(0, 10);
  if (car.boost >= 0) display.print("+");
  display.print(car.boost, 2);
  display.setTextSize(1);
  display.print(" bar");

  // Boost bar -0.5 to +1.5 bar
  display.drawFastHLine(0, 30, 128, SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 32);
  display.print("-0.5");
  display.setCursor(96, 32);
  display.print("+1.5");

  int boostBar = map((int)(car.boost * 100), -50, 150, 0, 120);
  boostBar = constrain(boostBar, 0, 120);
  display.drawRect(4, 41, 120, 8, SSD1306_WHITE);
  display.fillRect(4, 41, boostBar, 8, SSD1306_WHITE);

  // Load
  display.setCursor(0, 53);
  display.print("LOAD: ");
  display.print((int)car.load);
  display.print("%  INT:");
  display.print((int)car.intake);
  display.print("C");

  drawScreenDots();
  display.display();
}

// Screen 3: Temps
void drawScreen3() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("TEMPERATURES");
  display.drawFastHLine(0, 9, 128, SSD1306_WHITE);

  // Coolant
  display.setCursor(0, 13);
  display.print("Coolant  ");
  display.setTextSize(2);
  display.print((int)car.coolant);
  display.setTextSize(1);
  display.print(" C");

  // Oil
  display.setCursor(0, 32);
  display.print("Oil      ");
  display.setTextSize(2);
  display.print((int)car.oil);
  display.setTextSize(1);
  display.print(" C");

  // Intake
  display.setCursor(0, 51);
  display.print("Intake   ");
  display.print((int)car.intake);
  display.print(" C");

  drawScreenDots();
  display.display();
}

// Screen 4: Electrical
void drawScreen4() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("ELECTRICAL");
  display.drawFastHLine(0, 9, 128, SSD1306_WHITE);

  // Battery voltage large
  display.setCursor(0, 13);
  display.print("Battery");
  display.setTextSize(2);
  display.setCursor(0, 23);
  display.print(car.battery, 1);
  display.setTextSize(1);
  display.print(" V");

  // Battery bar — 10V to 15V
  display.drawRect(0, 40, 120, 8, SSD1306_WHITE);
  int batBar = map((int)(car.battery * 10), 100, 150, 0, 118);
  batBar = constrain(batBar, 0, 118);
  display.fillRect(0, 40, batBar, 8, SSD1306_WHITE);

  // Warning if low
  display.setCursor(0, 52);
  if (car.battery < 11.5) {
    display.print("!! LOW VOLTAGE !!");
  } else if (car.battery > 14.8) {
    display.print("!! HIGH VOLTAGE !!");
  } else {
    display.print("Voltage normal");
  }

  drawScreenDots();
  display.display();
}

// Startup splash
void drawSplash() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  display.setTextSize(2);
  display.setCursor(7, 8);
  display.print("BMW DASH");

  display.setTextSize(1);
  display.setCursor(25, 30);
  display.print("Display v0.2");

  display.setCursor(22, 44);
  display.print("[ LIVE DATA ]");

  display.display();
  delay(2500);
}

// ============================================================
// SETUP & LOOP
// ============================================================

void setup() {
  Serial.begin(115200);

  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    Serial.println("SSD1306 not found — check wiring:");
    Serial.println("VCC→3.3V  GND→GND  SDA→GPIO21  SCL→GPIO22");
    while (true);
  }

  Serial.println("Display OK — connecting to ELM327 via Bluetooth");
  drawSplash();
  connectELM327();
}

void loop() {
  unsigned long now = millis();

  // Poll OBD (non-blocking)
  updateOBD();

  // Auto-rotate screens every 5 seconds
  if (now - lastScreenChange >= SCREEN_INTERVAL) {
    currentScreen = (currentScreen + 1) % NUM_SCREENS;
    lastScreenChange = now;
  }

  // Draw current screen
  switch (currentScreen) {
    case 0: drawScreen1(); break;
    case 1: drawScreen2(); break;
    case 2: drawScreen3(); break;
    case 3: drawScreen4(); break;
  }
}