본문 바로가기

자동차 속도 기반 IoT 장식 만들기 05 - ESP32 블루투스 OBD2 연동 구현

@밀양박씨!2026. 5. 1. 02:26

1. 이번 편 목표

지금까지 Carpybara는 웹 슬라이더로 속도를 수동 입력하는 시뮬레이터 방식이었습니다. 이번 편에서는 실제 차량 OBD2 포트에 Bluetooth 동글을 꽂아 속도·RPM·냉각수 온도·연료량·부하·전압을 실시간으로 받아오는 구현을 다룹니다.



2. 사용 하드웨어 / 라이브러리

  • OBD2 동글: Veepeak OBD2 Bluetooth (ELM327 칩셋)
  • ESP32 BT: BluetoothSerial (Classic BT SPP 클라이언트)
  • 멀티태스킹: FreeRTOS (xTaskCreatePinnedToCore)

ESP32의 Classic Bluetooth SPP를 사용합니다. BLE가 아닌 Classic BT인 이유는 ELM327 동글 대부분이 SPP 프로파일만 지원하기 때문입니다.


3. 전체 구조 — 왜 별도 태스크인가

ELM327에 PID를 보내고 응답을 기다리는 시간이 최대 2~3초입니다. 이 대기가 메인 루프에 있으면 그동안 화면이 완전히 멈춥니다. 해결책은 BT OBD 통신을 별도 FreeRTOS 태스크로 분리하는 것입니다.

inline void petBtObdInit() {
  PetBtObd::_bt.begin("CarPet_BT", true);  // BT 클라이언트 모드
  xTaskCreatePinnedToCore(
    PetBtObd::taskFn,  // 태스크 함수
    "btObd",           // 태스크 이름
    8192,              // 스택 크기
    nullptr, 2,
    nullptr,
    0   // 코어 0 고정 (디스플레이/WiFi는 코어 1)
  );
}

BT OBD 태스크를 코어 0에 고정하고, 디스플레이·WiFi 루프는 코어 1에서 돌아 서로 방해하지 않습니다. 공유 변수는 volatile로 선언해 컴파일러 레지스터 캐싱으로 인한 데이터 불일치를 막았습니다.

static volatile bool    g_connected = false;
static volatile int16_t g_speed     = 0;
static volatile int16_t g_rpm       = 800;
static volatile int16_t g_coolant   = 20;
// ...




4.구현 상세

1. ELM327 초기화 시퀀스

BT 연결 직후 AT 명령어 5개를 순서대로 보내 동글을 초기화합니다.

static const char* const AT_INIT[] = {
  "ATZ\r",    // 리셋
  "ATE0\r",   // 에코 끄기
  "ATL0\r",   // 줄바꿈 끄기
  "ATS0\r",   // 공백 끄기 (파싱 단순화)
  "ATSP0\r"   // 프로토콜 자동 감지
};

각 명령어 전송 후 > 프롬프트가 올 때까지 대기합니다. 3.5초 안에 응답이 없으면 초기화 실패로 판단하고 재연결을 시도합니다.



2. OBD2 PID 폴링

6종 PID를 순서대로 하나씩 보내고 응답을 받아 파싱하는 방식입니다. 한 번에 여러 PID를 보내면 응답이 섞이는 문제가 있어 순차 폴링을 택했습니다.

static const char* const PID_CMD[] = {
  "010D\r",  // 속도 (km/h)
  "010C\r",  // RPM
  "0105\r",  // 냉각수 온도
  "012F\r",  // 연료량 (%)
  "0104\r",  // 엔진 부하 (%)
  "0142\r"   // 제어 전압 (V)
};

응답 파싱은 OBD2 표준 공식을 그대로 적용합니다.

static void parseObd(uint8_t idx) {
  char* p = strstr(g_rx, PID_TAG[idx]);
  if (!p) return;
  p += 4;
  switch (idx) {
    case 0: g_speed   = (int16_t)hex2(p); break;                          // 속도: 1바이트
    case 1: g_rpm     = (int16_t)((hex2(p)*256 + hex2(p+2)) / 4); break;  // RPM: 2바이트 /4
    case 2: g_coolant = (int16_t)(hex2(p) - 40); break;                   // 냉각수: -40 오프셋
    case 3: g_fuel    = (int16_t)(hex2(p) * 100 / 255); break;            // 연료: 0~100%
    case 4: g_load    = (int16_t)(hex2(p) * 100 / 255); break;            // 부하: 0~100%
    case 5: g_voltage = (int16_t)((hex2(p)*256 + hex2(p+2)) / 100); break; // 전압: V×10
  }
}



3. 상태 머신으로 연결 관리

BT 태스크 내부는 4단계 상태 머신으로 구성되어 있습니다. 예기치 않은 연결 끊김이나 응답 타임아웃에도 자동으로 재연결을 시도합니다.

enum TState : uint8_t {
  IDLE,      // 재연결 대기
  TCONNECT,  // BT 연결 시도
  INIT,      // ELM327 초기화
  POLL       // PID 폴링 루프
};
  • IDLE: 웹 "연결" 버튼 또는 10초 타이머 후 TCONNECT로 전환
  • TCONNECT: _bt.connect(BT_OBD_NAME) — 최대 10초 대기
  • INIT: AT 명령어 5개 순서대로 전송, 실패 시 IDLE로 복귀
  • POLL: PID를 80ms 간격으로 순환 폴링, 연결 끊기면 IDLE로 복귀



4. ODO 미터 — 다중 PID 폴백

누적 주행거리 PID는 차종마다 다릅니다. 4개 PID를 우선순위 순으로 시도해 응답이 오는 첫 번째를 사용합니다.

// 우선순위: 제조사 확장 PID → 표준 거리 PID 폴백
if      (queryOnce("22F40D\r", 900) && parseMode22(...))  { /* 성공 */ }
else if (queryOnce("22A6\r",   900) && parseMode22(...))  { /* 성공 */ }
else if (queryOnce("0131\r",   900) && parseMode01(...))  { /* 성공 */ }
else if (queryOnce("0121\r",   900) && parseMode01(...))  { /* 성공 */ }

5. 웹 리모컨과 연동 — 연결 상태 실시간 표시

웹앱은 1.5초마다 /bt/status를 폴링해 연결 상태와 실차 데이터를 받아옵니다. 미연결 상태에서는 슬라이더 시뮬레이터 값을, 연결 시에는 실차 값을 카드에 표시합니다.

// JSON 응답 예시 (연결됨)
// {"c":true,"k":false,"ev":0,
//  "spd":60,"rpm":2100,"cool":88,
//  "fuel":72,"load":34,"volt":142,
//  "trip":15,"avg":48,"sec":1120,"odo":45230}



5. 결과

  • FreeRTOS 태스크 분리로 OBD2 폴링 중에도 디스플레이 애니메이션이 끊기지 않음
  • 속도 반영 주기 약 80ms (PID 6개 × 80ms = 최대 480ms 사이클, 실사용 체감 부드러움)
  • 웹 리모컨에서 연결/해제 버튼, 실시간 수치 카드, 트립/평균속도/ODO 모두 정상 동작
  • 연결 끊김 시 10초 후 자동 재연결 확인


6. 느낀 점

FreeRTOS 멀티태스킹 없이 BT OBD를 메인 루프에 넣었다면 화면 업데이트와 HTTP 처리가 모두 블로킹됐을 것입니다. ESP32의 듀얼 코어를 역할에 따라 나누는 것만으로 구조가 훨씬 깔끔해졌습니다. 다음 편에서는 이 구현을 실제 차량에 연결한 테스트 결과를 공유합니다.

밀양박씨!
@밀양박씨! :: 박씨의 개발블로그

lovebotw049 님의 블로그 입니다.

목차