Home IoT Hub Series · 22

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업 대표 이미지

21편에서 TFT로 디스플레이를 키웠다. 그런데 GPIO가 모자랐다. 터치를 붙이려니 핀이 없고, 백라이트 PWM도 포기해야 하고, 센서 노드를 두뇌에 직결하는 것도 꿈도 못 꾼다.

이유는 간단하다. ESP8266으로는 한계가 왔다. 이제 두뇌를 ESP32로 올릴 때다.

ESP8266 vs ESP32

항목ESP8266 (NodeMCU)ESP32 (DevKit v1)
CPU80MHz 단일 코어240MHz 듀얼 코어
RAM50KB520KB
Flash4MB4MB
GPIO9개24개
SPI1개 (소프트웨어)2개 (하드웨어)
I2C1개2개
ADC1채널 10비트18채널 12비트
터치 핀없음10개 (정전식)
Wi-Fi2.4GHz b/g/n2.4GHz b/g/n
블루투스없음BLE 4.2 + Classic
가격2,500원4,000원
가격 차이가 크지 않은데도 성능과 확장성은 크게 좋아집니다. 센서 노드는 굳이 바꾸지 않아도 됩니다. ESP8266 센서 노드는 그대로 두고 두뇌만 ESP32로 교체하는 구조입니다. 센서 노드들은 여전히 IoT_Hub_Net에 접속하면 됩니다.

준비물과 가격

부품수량가격비고
ESP32 DevKit v11개4,000원30핀 버전
ILI9341 TFT (XPT2046 터치 포함)1개7,000원21편에서 터치 없는 걸 샀다면 통째로 교체
피에조 부저기존 재사용
점퍼 와이어 (암-암)12개
합계11,000원

핀 연결

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업 배선도

ESP32는 핀이 넉넉하다. TFT(SPI) + 터치(SPI 별도) + 부저 다 연결하고도 10개 이상 남는다.

센서/모듈보드 연결
VCC3.3V
GNDGND
CSGPIO15
RESETEN (리셋 공유)
DCGPIO2
MOSIGPIO23 (VSPI MOSI)
SCKGPIO18 (VSPI SCK)
LEDGPIO4 (PWM 밝기 제어)
T_CSGPIO5
T_IRQGPIO21
T_DOGPIO19
T_DINGPIO22
T_CLKGPIO17
+GPIO13
-GND
TFT와 터치가 별도 SPI 버스를 사용하므로 충돌이 없습니다.

선행 작업

1. ESP32 보드 매니저 설치: Arduino IDE Preferences → Additional Boards Manager URLs에 추가 후 보드 매니저에서 ESP32 설치.

https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json

2. 라이브러리: TFT_eSPI, XPT2046_Touchscreen

3. TFT_eSPI User_Setup.h: 라이브러리 폴더에서 아래처럼 수정합니다.

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업 - 전체 코드
#define ILI9341_DRIVER
#define TFT_CS   15
#define TFT_DC   2
#define TFT_RST  -1
#define TFT_MOSI 23
#define TFT_SCLK 18
#define TFT_BL   4
#define SPI_FREQUENCY 40000000

🚨 코드 넣기 전에 반드시!!

Wi-Fi SSID/비밀번호를 사용 중인 공유기 정보로 변경합니다. YOUR_SSID, YOUR_PASSWORD 부분이다. 안 바꾸면 Wi-Fi에 연결되지 않는다.

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업 - 전체 코드
const char* ssid     = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

전체 코드

아래는 생략 없는 전체 코드입니다. 이 파일 하나를 Arduino IDE에 붙여넣고, SSID와 비밀번호만 자신의 환경에 맞게 바꾼 뒤 ESP32에 업로드하면 됩니다.

[22편] ESP32 업그레이드 - 터치 TFT + 블루투스로 두뇌 파워업 - 전체 코드
#include <WiFi.h>
#include <WebServer.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>

// =============================================
// 1. Wi-Fi 설정 (직접 입력!)
// =============================================
const char* ssid     = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";
const char* ap_ssid     = "IoT_Hub_Net";
const char* ap_password = "iothub1234";

// =============================================
// 2. 하드웨어 핀
// =============================================
#define BUZZER_PIN 13
#define TFT_BL     4

TFT_eSPI tft = TFT_eSPI();
WebServer server(80);

#define TS_CS  5
#define TS_IRQ 21
XPT2046_Touchscreen ts(TS_CS, TS_IRQ);

// =============================================
// 3. 20개 메모리 슬롯
// =============================================
#define SLOT_COUNT 20
String slot[SLOT_COUNT] = {
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--",
  "--", "--", "--", "--", "--"
};

const char* label[SLOT_COUNT] = {
  "Living Temp","Living Hum","Outside Temp","AC",
  "Boiler","Stove","Gas Leak","Fire",
  "Front Door","Door Lock","Living Light","Fridge Temp",
  "CO2","Dust PM2.5","Power","Water",
  "Hot Water","Rain","Bath Hum","Pet Bowl"
};

// =============================================
// 4. 색상
// =============================================
#define C_BG       TFT_BLACK
#define C_TITLE_BG TFT_WHITE
#define C_CARD     TFT_DARKGREEN
#define C_CARD_WARN TFT_RED
#define C_LABEL    TFT_SILVER
#define C_VALUE    TFT_YELLOW
#define C_LINE     TFT_WHITE

// =============================================
// 5. 타이머
// =============================================
unsigned long lastScroll = 0;
unsigned long lastBuzzer = 0;
int scrollOffset = 0;
const int ITEMS_PER_PAGE = 4;
const int SCROLL_INTERVAL = 3000;
bool warningActive = false;

// =============================================
// 6. 터치 카드 영역 저장용
// =============================================
struct CardZone {
  int x, y, w, h, slotIdx;
};
CardZone cards[4];

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

  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  // TFT
  tft.init();
  tft.setRotation(1);
  tft.fillScreen(C_BG);
  tft.setTextWrap(false);

  // 백라이트 PWM (ESP32 전용)
  ledcSetup(0, 5000, 8);
  ledcAttachPin(TFT_BL, 0);
  ledcWrite(0, 200);

  // 터치
  ts.begin();
  ts.setRotation(1);

  // 부팅 로고
  tft.setTextColor(TFT_WHITE, C_BG);
  tft.drawString("ESP32 IoT Hub", 40, 140, 4);
  tft.drawString("iothub.co.kr", 60, 180, 2);
  delay(1000);

  // Wi-Fi AP+STA
  WiFi.mode(WIFI_AP_STA);
  WiFi.begin(ssid, password);

  tft.fillScreen(C_BG);
  tft.drawString("WiFi connecting...", 30, 100, 2);
  Serial.print("Connecting to WiFi");
  int dots = 0;
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
    tft.drawString(".", 180 + dots * 8, 100, 2);
    dots++;
  }
  Serial.println();
  Serial.print("STA IP: "); Serial.println(WiFi.localIP());

  WiFi.softAP(ap_ssid, ap_password);
  Serial.print("AP  IP: "); Serial.println(WiFi.softAPIP());

  // 웹 서버
  server.on("/set", handleSet);
  server.on("/", handleRoot);
  server.on("/status", handleStatus);
  server.onNotFound(handleNotFound);
  server.begin();

  // IP 표시
  tft.fillScreen(C_BG);
  tft.drawString("ESP32 IoT Hub Ready", 20, 30, 4);
  tft.drawString("LAN: " + WiFi.localIP().toString(), 10, 80, 2);
  tft.drawString("AP : " + WiFi.softAPIP().toString(), 10, 110, 2);
  delay(3000);
}

// =============================================
// loop
// =============================================
void loop() {
  server.handleClient();
  handleTouch();
  updateTFT();
  updateBuzzer();
}

// =============================================
// 경고 상태 확인
// =============================================
bool checkWarning() {
  for (int i = 0; i < SLOT_COUNT; i++) {
    if ((i == 6 || i == 7 || i == 8)
        && slot[i] != "0" && slot[i] != "--") return true;
    if (i == 19 && slot[i] != "--" && slot[i].toInt() < 50) return true;
  }
  return false;
}

// =============================================
// 부저 제어
// =============================================
void updateBuzzer() {
  warningActive = checkWarning();
  if (warningActive) {
    unsigned long now = millis();
    if (now - lastBuzzer < 500) return;
    lastBuzzer = now;
    digitalWrite(BUZZER_PIN, !digitalRead(BUZZER_PIN));
  } else {
    digitalWrite(BUZZER_PIN, LOW);
  }
}

// =============================================
// GET /set?no1=값&no2=값 ...
// =============================================
void handleSet() {
  int updated = 0;
  for (int i = 1; i <= SLOT_COUNT; i++) {
    String paramName = "no" + String(i);
    if (server.hasArg(paramName)) {
      slot[i - 1] = server.arg(paramName);
      updated++;
    }
  }
  server.send(200, "application/json", "{\"updated\":" + String(updated) + "}");
}

// =============================================
// GET / → HTML 대시보드
// =============================================
void handleRoot() {
  String html = "<!DOCTYPE html><html lang=\"ko\"><head>"
  "<meta charset=\"UTF-8\">"
  "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">"
  "<title>IoT Hub ESP32</title>"
  "<style>"
  "*{margin:0;padding:0;box-sizing:border-box;}"
  "body{font-family:system-ui,sans-serif;background:#1a1a2e;color:#eee;padding:16px;}"
  "h1{text-align:center;font-size:1.3rem;margin-bottom:4px;color:#e94560;}"
  ".sub{text-align:center;font-size:0.7rem;color:#555;margin-bottom:16px;}"
  ".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(185px,1fr));gap:10px;}"
  ".card{background:#16213e;border-radius:8px;padding:12px;border-left:4px solid #0f3460;}"
  ".card.warn{border-left-color:#e94560;background:#1c0e1a;}"
  ".card .no{font-size:0.6rem;color:#555;margin-bottom:2px;}"
  ".card .label{font-size:0.8rem;color:#a0a0b0;margin-bottom:4px;}"
  ".card .value{font-size:1.2rem;font-weight:700;}"
  ".footer{text-align:center;margin-top:16px;font-size:0.7rem;color:#444;}"
  "</style></head><body>"
  "<h1>IoT Hub</h1><div class=\"sub\">ESP32 + Touch TFT</div>"
  "<div class=\"grid\">";

  for (int i = 0; i < SLOT_COUNT; i++) {
    bool isWarn = false;
    String val = slot[i];
    if ((i == 6 || i == 7 || i == 8) && val != "0" && val != "--") isWarn = true;
    if (i == 19 && val != "--" && val.toInt() < 50) isWarn = true;
    html += "<div class=\"card" + String(isWarn ? " warn" : "") + "\">";
    html += "<div class=\"no\">#" + String(i + 1) + "</div>";
    html += "<div class=\"label\">" + String(label[i]) + "</div>";
    html += "<div class=\"value\">" + val + "</div></div>\n";
  }

  html += "</div><div class=\"footer\">ESP32 IoT Hub | iothub.co.kr</div></body></html>";
  server.send(200, "text/html; charset=utf-8", html);
}

// =============================================
// GET /status → JSON
// =============================================
void handleStatus() {
  String json = "{";
  for (int i = 0; i < SLOT_COUNT; i++) {
    if (i > 0) json += ",";
    json += "\"no" + String(i + 1) + "\":\"" + slot[i] + "\"";
  }
  json += ",\"warning\":" + String(warningActive ? "true" : "false") + "}";
  server.send(200, "application/json", json);
}

// =============================================
// 404
// =============================================
void handleNotFound() {
  server.send(404, "text/plain", "404 - Use /set?no1=value  or  /  or  /status");
}

// =============================================
// TFT 렌더링 (컬러 카드 4개)
// =============================================
void updateTFT() {
  unsigned long now = millis();
  if (now - lastScroll < SCROLL_INTERVAL) return;
  lastScroll = now;

  tft.fillScreen(C_BG);

  // 상단 타이틀 바
  tft.fillRect(0, 0, 320, 28, TFT_WHITE);
  tft.setTextColor(TFT_BLACK, TFT_WHITE);
  char buf[40];
  sprintf(buf, "IoT Hub  [%d/%d]",
    scrollOffset / ITEMS_PER_PAGE + 1,
    (SLOT_COUNT + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE);
  tft.drawString(buf, 4, 6, 2);

  for (int i = 0; i < ITEMS_PER_PAGE; i++) {
    int idx = scrollOffset + i;
    if (idx >= SLOT_COUNT) break;

    int y = 36 + i * 52;
    String val = slot[idx];

    // 경고 체크
    bool isWarn = false;
    uint16_t cardBg = C_CARD;
    if ((idx == 6 || idx == 7 || idx == 8) && val != "0" && val != "--") {
      isWarn = true; cardBg = C_CARD_WARN;
    }
    if (idx == 19 && val != "--" && val.toInt() < 50) {
      isWarn = true; cardBg = C_CARD_WARN;
    }

    // 카드 영역 저장 (터치용)
    cards[i] = {4, y, 312, 48, idx};

    // 카드 배경
    tft.fillRoundRect(4, y, 312, 48, 6, cardBg);

    // 라벨 + 단위
    tft.setTextColor(C_LABEL, cardBg);
    tft.drawString(label[idx], 14, y + 4, 2);

    // 구분선
    tft.drawLine(14, y + 24, 306, y + 24, C_LINE);

    // 값 (크게)
    tft.setTextColor(isWarn ? TFT_WHITE : C_VALUE, cardBg);
    tft.drawString(val, 18, y + 28, 4);

    // 단위 표시
    tft.setTextColor(C_LABEL, cardBg);
    tft.drawString(getUnit(idx), 250, y + 28, 2);

    // 터치 가능 표시 (제어 가능한 슬롯만)
    if (idx == 3 || idx == 6 || idx == 7 || idx == 8 || idx == 10 || idx == 19) {
      tft.drawString(":", 290, y + 4, 2);
    }
  }

  scrollOffset += ITEMS_PER_PAGE;
  if (scrollOffset >= SLOT_COUNT) scrollOffset = 0;
}

// =============================================
// 단위 반환
// =============================================
String getUnit(int idx) {
  switch (idx) {
    case 0: case 2: case 11: case 16: return "C";
    case 1: case 18: return "%";
    case 12: return "ppm";
    case 13: return "ug";
    case 14: return "W";
    case 15: return "L";
    case 19: return "g";
    default: return "";
  }
}

// =============================================
// 터치 처리
// =============================================
void handleTouch() {
  if (!ts.tirqTouched() && !ts.touched()) return;

  TS_Point p = ts.getPoint();
  int tx = map(p.x, 200, 3800, 0, 320);
  int ty = map(p.y, 200, 3800, 0, 240);

  for (int i = 0; i < 4; i++) {
    CardZone cz = cards[i];
    if (tx >= cz.x && tx <= cz.x + cz.w &&
        ty >= cz.y && ty <= cz.y + cz.h) {

      int idx = cz.slotIdx;
      if (idx < 0 || idx >= SLOT_COUNT) break;

      // AC(4번): ON/OFF 토글
      if (idx == 3) {
        String cur = slot[idx];
        slot[idx] = (cur == "ON") ? "OFF" : "ON";
        Serial.println("Touch: AC -> " + slot[idx]);
      }
      // 조명(11번): ON/OFF 토글
      else if (idx == 10) {
        String cur = slot[idx];
        slot[idx] = (cur == "ON") ? "OFF" : "ON";
        Serial.println("Touch: Light -> " + slot[idx]);
      }
      // 경고 슬롯: 리셋
      else if (idx == 6 || idx == 7 || idx == 8 || idx == 19) {
        slot[idx] = "0";
        Serial.println("Touch: Warn reset slot " + String(idx + 1));
      }

      lastScroll = 0; // 즉시 화면 갱신
      break;
    }
  }
}

터치 UX 설계

카드 4개 각각이 터치 버튼 역할을 한다. 카드 오른쪽 위에 : 표시가 있으면 터치 가능한 슬롯이다.

터치한 카드동작
AC (4번)ON↔OFF 토글
Living Light (11번)ON↔OFF 토글
Gas Leak / Fire / Front Door (7·8·9번)경고 확인 → 0으로 리셋, 부저 정지
Pet Bowl (20번)경고 확인 → 0으로 리셋, 부저 정지
그 외 슬롯읽기 전용 (반응 없음)

업로드 및 확인

1. 툴 → 보드 → ESP32 Dev Module, Flash Size 4MB. 2. USB 케이블로 ESP32 연결 후 업로드. ESP32는 업로드 속도 921600bps 가능. 3. TFT에 ESP32 IoT Hub 부팅 로고 → 4개 카드 그리드. 4. AC 카드 터치 → 시리얼 모니터 Touch: AC -> ON. 5. /set?no7=1로 가스 누출 켜고 7번 카드 터치 → 경고 리셋 확인.

안 될 때 체크포인트

증상원인해결
터치 전혀 안 됨SPI 배선 오류T_CS→GPIO5, T_CLK→GPIO17, T_DIN→GPIO22, T_DO→GPIO19 순서 재확인
터치 좌표가 엉뚱함map() 범위 불일치Serial.println(p.x)로 실제 터치 값 확인 후 map 범위 조정
ESP32 업로드 실패부트로더 진입 실패업로드 직전 BOOT 버튼을 누르고 있다가 Connecting...이 뜨면 뗀다
TFT 깜빡임전원 부족ESP32 USB 포트에 5V 2A 이상 충전기 사용. PC USB 포트 전류 부족 가능
Wi-Fi AP 안 뜸모드 설정 순서 오류WiFi.mode(WIFI_AP_STA)WiFi.begin()보다 먼저인지 확인

지금까지 구성한 흐름

  • ESP8266 두뇌를 ESP32로 교체. GPIO 24개, 듀얼코어, 블루투스 탑재.
  • 2.4인치 TFT + XPT2046 터치로 화면을 손가락으로 제어할 수 있는 컨트롤 패널로 진화.
  • 센서 노드 19대는 그대로 ESP8266. 두뇌만 바꿨을 뿐 IoT_Hub_Net 통신은 완벽 호환.
  • 11,000원으로 집 상태판이 터치스크린 대시보드가 됐다.