Home IoT Hub Series · 24

[24편] AI 집사 Method B - 고물 PC로 안전한 AI 브로커 만들기

[24편] AI 집사 Method B - 고물 PC로 안전한 AI 브로커 만들기 대표 이미지

23편에서는 ESP32가 AI API를 직접 호출하는 구조를 만들었습니다. 동작 자체는 확인했지만 운영 관점에서는 위험 요소가 분명했습니다. API 키가 ESP32 플래시에 평문으로 남고, TLS 검증을 제대로 다루기 어렵고, AI 응답도 PC에 USB로 연결해야만 확인할 수 있었습니다.

이번 편에서 해결할 것:

Method A의 문제Method B의 해결
API 키가 ESP32에 노출API 키는 PC에만 보관하고, ESP32에는 노출하지 않음
외부에서 응답 확인이 어려움텔레그램 봇으로 스마트폰 알림 전송
AI 분석 결과가 실제 동작으로 이어지지 않음텔레그램 명령을 받아 실제 액추에이터 제어로 연결
TLS 인증서 처리 부담Python requests로 HTTPS 통신을 안정적으로 처리

준비물과 비용

준비물비용설명
집에 있는 아무 PC/노트북0원Windows 10, macOS, Ubuntu 등 대부분 가능합니다. 10년 된 장비도 충분히 활용할 수 있습니다.
파이썬 3.8 이상0원python.org 에서 무료 설치
텔레그램 계정 + 앱0원이미 스마트폰에 설치되어 있다면 그대로 사용할 수 있습니다.
AI API 키종량제23편과 동일. GPT-3.5-turbo 기준 $0.0015/1K토큰
ESP32 (두뇌)22편에서 이미 준비한 장치
추가 비용0원

시작하기 전에

처음 구성하는 경우 아래 도구들이 설치되어 있어야 이 글을 따라 할 수 있습니다. 누락된 항목이 있으면 먼저 설치한 뒤 진행합니다. 기본 도구는 무료로 준비할 수 있습니다.
필요한 것설치 가이드확인 방법
Arduino IDE설치 방법실행 후 빈 코드 창이 뜨는지 확인합니다.
ESP32 보드 설정설치 방법Tools → BoardESP32 Dev Module이 보이는지 확인합니다.
Python 3.8+설치 방법터미널에서 python --version이 실행되는지 확인합니다.
텔레그램 + 봇설치 방법@MyHomeAIBot/start에 응답하는지 확인합니다.
필수 라이브러리설치 방법TFT_eSPI, Adafruit SSD1306 설치 여부를 확인합니다.
위 항목이 준비되었다면 본격적인 구성 단계로 진행합니다. 아래부터는 실제 ESP32 코드와 PC 브로커 서버를 연결하는 과정입니다.

이번 편 개념 — 어려운 말, 쉬운 말

이번 편에서 새로 등장하는 핵심 용어를 먼저 정리합니다. 개념만 잡으면 구조는 어렵지 않습니다.

용어실무적으로 이해할 의미
FlaskPython으로 작은 웹 서버를 만드는 도구입니다. /data 같은 주소로 데이터가 들어오면 요청을 받아 저장하거나 처리하는 역할을 합니다.
브로커 (Broker)ESP32, AI API, 텔레그램 사이에서 데이터를 중계하는 서버입니다. ESP32에서 값을 받고, AI에 분석을 요청하고, 결과를 텔레그램으로 전달하는 역할을 맡습니다.
REST APIHTTP 주소와 메서드로 데이터를 주고받는 방식입니다. ESP32가 정해진 주소로 센서값을 보내면 서버가 이를 받아 처리합니다.
텔레그램 봇프로그램이 자동으로 메시지를 보내는 텔레그램 계정입니다. 사용자는 일반 채팅처럼 명령을 보내고 알림을 받을 수 있습니다.
나머지 용어는 개발 소프트웨어 글에서 함께 정리했습니다. 익숙하지 않은 단어가 나오면 해당 글을 참고하면 흐름을 이해하기 쉽습니다.

전체 구조

구조를 단순화하면 ESP32는 집 안의 온도, 습도, 문 열림 같은 값을 수집하는 현장 장치이고, PC 서버는 그 값을 받아 AI 분석과 알림 전송을 담당하는 중간 서버입니다. 텔레그램은 분석 결과와 경고를 사용자의 스마트폰으로 전달하는 알림 채널 역할을 합니다.

이를 구조도로 나타내면 다음과 같습니다.

┌──────────┐  HTTP POST   ┌─────────────┐  HTTPS      ┌──────────┐
│  ESP32   │ ────────────→│  고물 PC      │ ──────────→│ AI API    │
│ (두뇌)    │←──────────── │  Flask 서버   │←──────────│ (GPT 등)   │
│          │  HTTP GET    │  (Python)    │  JSON 응답  │           │
│ 센서 20개 │  /command    │              │            │           │
│ IR LED   │             │  AI 응답을    │            │           │
│ 부저     │             │  분석해서     │            │           │
└──────────┘             │  행동 결정    │            │           │
                          │              │            │           │
                          │  텔레그램 연동 │  ──→  📱 스마트폰 알림   │
                          │              │  ←──  "에어컨 꺼줘"    │
                          └─────────────┘

흐름: 1. ESP32가 30초마다 20개 센서값을 PC로 HTTP POST (/data) 2. PC의 Flask 서버가 받아서 저장하고, 5분마다 AI API로 분석 요청 3. AI 응답을 파싱해서 텔레그램으로 전송 4. 사용자가 텔레그램에서 "에어컨 꺼줘"라고 입력하면 Flask 서버가 이를 해석해 /command에 저장 5. ESP32가 주기적으로 /command를 확인해 IR 송출 등을 실행합니다.

🚨 ESP32 코드 업로드

아래는 22편 코드에 24편 신규 기능을 합친 전체 코드입니다. Arduino IDE에 붙여넣은 뒤 PC_URL만 사용 중인 PC IP로 변경하고 업로드합니다.

22편 대비 새로 추가된 부분sendDataToPC(), checkCommand(), executeCommand() 세 함수와 loop() 안의 타이머 호출 두 줄입니다. 나머지 구조는 22편과 동일하게 유지합니다.

[24편] AI 집사 Method B - 고물 PC로 안전한 AI 브로커 만들기 - 전체 코드
#include <WiFi.h>
#include <WebServer.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <HTTPClient.h>

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

// =============================================
// PC 브로커 URL (직접 입력!)
// =============================================
const char* PC_URL = "http://192.168.0.100:5000"; // ← 사용 중인 PC IP로 바꿀 것

// =============================================
// 하드웨어 핀
// =============================================
#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);

// =============================================
// 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"
};

// =============================================
// 색상
// =============================================
#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

// =============================================
// 타이머
// =============================================
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;

// PC 통신 타이머
unsigned long lastCmdCheck = 0;
unsigned long lastDataSend = 0;
#define CMD_INTERVAL  5000
#define DATA_INTERVAL 30000

// =============================================
// 터치 카드 영역
// =============================================
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.init();
  tft.setRotation(1);
  tft.fillScreen(C_BG);
  tft.setTextWrap(false);

  ledcSetup(0, 5000, 8);
  ledcAttachPin(TFT_BL, 0);
  ledcWrite(0, 200);

  ts.begin();
  ts.setRotation(1);

  tft.setTextColor(TFT_WHITE, C_BG);
  tft.drawString("IoT Hub + AI", 40, 140, 4);
  tft.drawString("iothub.co.kr", 60, 180, 2);
  delay(1000);

  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("\nSTA IP: " + WiFi.localIP().toString());

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

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

  tft.fillScreen(C_BG);
  tft.drawString("IoT Hub Ready", 20, 30, 4);
  tft.drawString("LAN: " + WiFi.localIP().toString(), 10, 80, 2);
  tft.drawString("PC : " + String(PC_URL), 10, 110, 2);
  delay(3000);
}

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

  unsigned long now = millis();

  // 30초마다 센서 데이터를 PC로 전송
  if (now - lastDataSend > DATA_INTERVAL) {
    lastDataSend = now;
    sendDataToPC();
  }

  // 5초마다 PC에 새 명령 있는지 확인
  if (now - lastCmdCheck > CMD_INTERVAL) {
    lastCmdCheck = now;
    checkCommand();
  }
}

// =============================================
// 경고 상태 확인
// =============================================
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 + AI</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 + AI</h1><div class=\"sub\">Method B - PC Broker</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\">IoT Hub + AI Broker | 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 렌더링 (22편과 동일)
// =============================================
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, TFT_WHITE);
    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;
      if (idx == 3) { slot[idx] = (slot[idx] == "ON") ? "OFF" : "ON"; }
      else if (idx == 10) { slot[idx] = (slot[idx] == "ON") ? "OFF" : "ON"; }
      else if (idx == 6 || idx == 7 || idx == 8 || idx == 19) { slot[idx] = "0"; }
      lastScroll = 0;
      break;
    }
  }
}

// =============================================
// 🆕 20개 센서값을 PC로 전송 (24편 신규)
// =============================================
void sendDataToPC() {
  WiFiClient client;
  HTTPClient http;

  String url = String(PC_URL) + "/data";
  String body = "{";
  for (int i = 0; i < SLOT_COUNT; i++) {
    if (i > 0) body += ",";
    body += "\"no" + String(i + 1) + "\":\"" + slot[i] + "\"";
  }
  body += "}";

  http.begin(client, url);
  http.addHeader("Content-Type", "application/json");
  int code = http.POST(body);
  http.end();

  Serial.print("Data sent: HTTP ");
  Serial.print(code);
  Serial.print(" ("); Serial.print(body.length());
  Serial.println(" bytes)");
}

// =============================================
// 🆕 PC에서 명령 가져오기 (24편 신규)
// =============================================
void checkCommand() {
  WiFiClient client;
  HTTPClient http;

  String url = String(PC_URL) + "/command";
  http.begin(client, url);
  int code = http.GET();

  if (code == 200) {
    String resp = http.getString();
    if (resp != "{}" && resp != "") {
      Serial.print("Command received: ");
      Serial.println(resp);
      executeCommand(resp);
    }
  }
  http.end();
}

// =============================================
// 🆕 명령 실행 (24편 신규)
// =============================================
void executeCommand(String json) {
  for (int i = 1; i <= SLOT_COUNT; i++) {
    String key = "no" + String(i);
    int pos = json.indexOf("\"" + key + "\":\"");
    if (pos < 0) continue;

    int start = pos + key.length() + 4;
    int end = json.indexOf("\"", start);
    String val = json.substring(start, end);

    slot[i - 1] = val;
    Serial.printf("  Slot %d (%s) <- %s\n", i, label[i - 1], val.c_str());
  }
  lastScroll = 0;
}
22편 코드와 다른 점은 명확합니다. loop()에 두 줄을 추가하고, sendDataToPC, checkCommand, executeCommand 함수 세 개와 상단 PC_URL 정의를 더합니다. 기존 TFT/터치/부저/웹서버/경고 기능은 그대로 유지합니다.
TFT는 영어, 텔레그램은 한글로 표시하는 이유 ESP32의 TFT 라이브러리는 한글 폰트 처리가 까다로워 화면 표시는 영문으로 구성했습니다. 반면 텔레그램과 AI 프롬프트는 한국어를 자유롭게 사용할 수 있습니다. 따라서 TFT에는 "Living Temp"가 보이고, 스마트폰 텔레그램에는 "거실 온도"가 표시되는 구조입니다.

Python Flask 서버 (PC에 설치)

이 단계가 Method B의 핵심입니다. PC에는 Python 3.8 이상이 설치되어 있어야 합니다.

라이브러리 설치

터미널(맥/리눅스) 또는 명령 프롬프트(윈도우)에서:

[24편] AI 집사 Method B - 고물 PC로 안전한 AI 브로커 만들기 - 전체 코드
pip install flask requests

전체 코드 (server.py)

이 파일 하나를 PC에 저장해 실행합니다. OPENAI_API_KEY, TELEGRAM_BOT_TOKEN, YOUR_CHAT_ID 세 값만 자신의 환경에 맞게 변경합니다.

[24편] AI 집사 Method B - 고물 PC로 안전한 AI 브로커 만들기 - 전체 코드
from flask import Flask, request, jsonify
import requests
import threading
import time
import json
import urllib.request
import urllib.parse

# =============================================
# 1. 설정 (직접 입력!)
# =============================================
OPENAI_API_KEY = "sk-your-api-key-here"
OPENAI_URL     = "https://api.openai.com/v1/chat/completions"
AI_MODEL       = "gpt-3.5-turbo"

TELEGRAM_BOT_TOKEN = "123456:ABC-DEF1234ghijk"
YOUR_CHAT_ID = 123456789

# =============================================
# 2. Flask 앱
# =============================================
app = Flask(__name__)

latest_data = {}
pending_commands = {}
last_ai_time = 0
AI_INTERVAL = 300

LABELS = [
    "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"
]

# =============================================
# 3. ESP32 → PC: 센서 데이터 수신
# =============================================
@app.route('/data', methods=['POST'])
def receive_data():
    global latest_data
    data = request.get_json()
    if data:
        latest_data = data
        print(f"[DATA] Received {len(data)} sensor values")
    return jsonify({"status": "ok"})

# =============================================
# 4. ESP32 ← PC: 명령 전달
# =============================================
@app.route('/command', methods=['GET'])
def send_command():
    global pending_commands
    if pending_commands:
        cmd = json.dumps(pending_commands)
        pending_commands = {}
        print(f"[CMD] Sent: {cmd}")
        return cmd
    return "{}"

# =============================================
# 5. 텔레그램 명령 처리
# =============================================
def handle_telegram_command(text):
    global pending_commands
    tl = text.lower().strip()

    if "에어컨" in text or "ac" in tl:
        if "켜" in text or "on" in tl:
            pending_commands["no4"] = "ON"
            return "에어컨 ON"
        elif "꺼" in text or "off" in tl:
            pending_commands["no4"] = "OFF"
            return "에어컨 OFF"

    if "조명" in text or "불" in text or "light" in tl:
        if "켜" in text or "on" in tl:
            pending_commands["no11"] = "ON"
            return "조명 ON"
        elif "꺼" in text or "off" in tl:
            pending_commands["no11"] = "OFF"
            return "조명 OFF"

    if "상태" in text or "집" in text or "status" in tl:
        return build_status_report()

    return None

def build_status_report():
    if not latest_data:
        return "아직 센서 데이터가 없습니다."
    report = "집 상태 보고\n\n"
    warnings = []
    for i in range(20):
        val = latest_data.get(f"no{i+1}", "--")
        report += f"  {LABELS[i]}: {val}\n"
        if i == 6 and val not in ("0", "--"): warnings.append("가스 누출!")
        if i == 7 and val not in ("0", "--"): warnings.append("화재 감지!")
        if i == 8 and val not in ("0", "--"): warnings.append("현관문 열림!")
        if i == 19 and val != "--":
            try:
                if int(val) < 50: warnings.append("반려견 밥 보충!")
            except: pass
    if warnings:
        report += "\n" + "\n".join(warnings)
    return report

# =============================================
# 6. AI 분석 (백그라운드)
# =============================================
def ai_analysis_loop():
    global last_ai_time, pending_commands
    while True:
        time.sleep(30)
        if not latest_data: continue
        now = time.time()
        if now - last_ai_time < AI_INTERVAL: continue
        last_ai_time = now

        try:
            data_text = "\n".join(
                f"{LABELS[i]}: {latest_data.get(f'no{i+1}', '--')}"
                for i in range(20)
            )
            system_prompt = (
                "You are an AI home assistant. Analyze the sensor data "
                "and provide a brief English summary. "
                "1) Any warnings? 2) Any anomalies? 3) Recommended actions. "
                "Keep it under 4 sentences."
            )
            resp = requests.post(
                OPENAI_URL,
                headers={
                    "Authorization": f"Bearer {OPENAI_API_KEY}",
                    "Content-Type": "application/json"
                },
                json={
                    "model": AI_MODEL,
                    "messages": [
                        {"role": "system", "content": system_prompt},
                        {"role": "user", "content": data_text}
                    ],
                    "max_tokens": 200
                },
                timeout=30
            )
            if resp.status_code == 200:
                content = resp.json()["choices"][0]["message"]["content"]
                print(f"[AI] {content}")
                # 텔레그램으로 AI 분석 결과 전송
                send_telegram(YOUR_CHAT_ID, "[AI 분석]
" + content)
            else:
                print(f"[AI] API error: {resp.status_code}")
        except Exception as e:
            print(f"[AI] Error: {e}")

# =============================================
# 7. 텔레그램 봇 (폴링)
# =============================================
def telegram_loop():
    base_url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
    last_update_id = 0
    print("[TELEGRAM] Bot started. Send /start to begin.")

    while True:
        time.sleep(3)
        try:
            url = f"{base_url}/getUpdates?offset={last_update_id + 1}&timeout=10"
            resp = requests.get(url, timeout=15).json()
            for update in resp.get("result", []):
                last_update_id = update["update_id"]
                if "message" not in update: continue
                msg = update["message"]
                chat_id = msg["chat"]["id"]
                text = msg.get("text", "")
                print(f"[TELEGRAM] From {chat_id}: {text}")

                if text == "/start":
                    send_telegram(chat_id,
                        "AI 집사입니다.\n"
                        "'상태', '에어컨 켜줘/꺼줘', '조명 켜줘/꺼줘'")
                    continue

                reply = handle_telegram_command(text)
                if reply:
                    send_telegram(chat_id, reply)
                else:
                    send_telegram(chat_id,
                        "'상태', '에어컨 켜줘/꺼줘', '조명 켜줘/꺼줘' 중에 말해주세요.")
        except Exception as e:
            print(f"[TELEGRAM] Error: {e}")

def send_telegram(chat_id, text):
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode()
    urllib.request.urlopen(url, data=data)

# =============================================
# 8. 실행
# =============================================
if __name__ == "__main__":
    threading.Thread(target=ai_analysis_loop, daemon=True).start()
    threading.Thread(target=telegram_loop, daemon=True).start()
    print("=" * 50)
    print("  AI 집사 서버 시작")
    print("=" * 50)
    app.run(host="0.0.0.0", port=5000, debug=False)

텔레그램 설정

텔레그램은 어떤 도구인가요? 카카오톡과 비슷한 무료 메신저입니다. 앱스토어에서 Telegram을 검색해 설치하고 전화번호로 가입하면 준비가 끝납니다. 보통 1분 정도면 충분합니다.

봇 만들기 (1분)

1. 텔레그램 앱에서 @BotFather를 검색하고 대화를 시작합니다. 2. /newbot을 입력합니다. 3. 봇 이름은 예시로 우리집 AI 집사처럼 입력합니다. 4. 봇 사용자명은 예시로 MyHomeAIBot처럼 입력합니다. 사용자명은 중복될 수 없고 반드시 bot으로 끝나야 합니다. 5. BotFather가 발급한 토큰을 복사해 TELEGRAM_BOT_TOKEN에 붙여넣습니다.

내 Chat ID 확인하기 (30초)

Chat ID가 필요한 이유 봇은 "어느 대상에게 메시지를 보내야 하는지"를 Chat ID로 구분합니다. 이 번호가 없으면 봇이 메시지를 보낼 대상을 특정할 수 없습니다. Chat ID는 메시지 수신 대상을 지정하는 식별자이므로 외부에 공개하지 않습니다. 봇 토큰처럼 신중하게 관리해야 합니다.

1. 텔레그램에서 @userinfobot을 검색하고 대화를 시작합니다. 2. /start를 입력합니다. 3. 응답에 표시되는 Id: 123456789 형식의 숫자를 YOUR_CHAT_ID에 붙여넣습니다.

실행 방법

PC에서 서버 실행

1. 위 server.py 코드를 PC에 server.py라는 파일명으로 저장합니다. 2. 터미널(macOS/Linux) 또는 명령 프롬프트(Windows)에서 server.py가 있는 폴더로 이동합니다. 3. 최초 1회 pip install flask requests로 라이브러리를 설치합니다. 4. python server.py로 서버를 실행합니다. 5. 아래 출력이 나오면 서버가 정상으로 시작된 상태입니다.

==================================================
  AI 집사 서버 시작
==================================================
[TELEGRAM] Bot started. Send /start to begin.
이 터미널 창을 닫으면 서버도 종료됩니다. PC를 재부팅한 뒤에는 서버를 다시 실행해야 합니다.

6. PC의 IP 주소를 확인합니다. macOS/Linux에서는 ifconfig | grep inet, Windows에서는 ipconfig 실행 후 IPv4 Address 값을 확인합니다.

ESP32에 업로드

1. Arduino IDE를 엽니다. 설치가 필요하면 Arduino IDE 설치 글을 먼저 진행합니다. 2. 위 ESP32 전체 코드를 복사해 Arduino IDE 빈 화면에 붙여넣습니다. 3. YOUR_SSIDYOUR_PASSWORD를 사용 중인 공유기 이름과 비밀번호로 변경합니다. 큰따옴표는 유지합니다. 4. PC_URL을 사용 중인 PC IP로 변경합니다. 예: "http://192.168.0.100:5000". PC IP 확인 방법은 바로 위 PC 서버 실행 6번을 참고합니다. 5. ESP32를 USB 데이터 케이블로 PC에 연결합니다. 충전 전용 케이블은 업로드에 사용할 수 없습니다. 6. Arduino IDE에서 툴 → 보드 → ESP32 Dev Module, 툴 → Flash Size → 4MB를 선택합니다. 7. 툴 → 포트에서 ESP32가 연결된 포트를 선택합니다. Windows는 COM3 형식, macOS는 /dev/cu.usbserial-XXXX 형식으로 보입니다. 8. 업로드 버튼(→)을 누릅니다. 아래 검은 창에 "Done uploading"이 나오면 업로드가 완료된 상태입니다. 9. 툴 → 시리얼 모니터를 열고 우측 하단 속도를 115200으로 맞춥니다. 30초 후 Data sent: HTTP 200이 찍히면 ESP32와 PC 서버 통신이 정상입니다.

확인할 것

아래 5가지를 순서대로 확인합니다. 하나라도 실패하면 바로 아래 "안 될 때 체크포인트"에서 원인을 좁힙니다.

1) ESP32가 PC에 데이터를 보내는지 확인합니다.

  • ESP32 시리얼 모니터에 Data sent: HTTP 200이 출력되는지 확인합니다.

2) PC가 데이터를 받았는지 확인합니다.

  • server.py를 실행한 터미널에 [DATA] Received 20 sensor values가 출력되는지 확인합니다.

3) 텔레그램 봇이 응답하는지 확인합니다.

  • 스마트폰에서 텔레그램 앱을 열고 @MyHomeAIBot에게 /start를 보낸 뒤 응답이 오는지 확인합니다.
  • /start에 응답이 없으면 PC에서 server.py가 실행 중인지 먼저 확인합니다.

4) AI 분석이 작동하는지 확인합니다.

  • 5분 정도 기다린 뒤 server.py 터미널에 [AI]로 시작하는 메시지가 출력되는지 확인합니다.
  • 동시에 텔레그램으로 AI 분석 메시지가 도착하는지 확인합니다.

5) 스마트폰 명령이 ESP32까지 전달되는지 확인합니다.

  • 텔레그램 봇에게 "에어컨 켜줘"라고 보냅니다.
  • 봇이 "에어컨 ON"이라고 응답하는지 확인합니다.
  • ESP32 시리얼 모니터에 Command received가 출력되는지 확인합니다.

안 될 때 체크포인트

증상원인해결
ESP32 HTTP -1PC IP 틀림PC IP를 재확인하고, ESP32와 PC가 같은 공유기에 연결되어 있는지 확인합니다.
pip install 실패Python 미설치python.org에서 Python 3.8 이상을 설치하고, Windows에서는 "Add to PATH"를 체크합니다.
텔레그램 봇 무응답Chat ID 불일치@userinfobot으로 Chat ID를 다시 확인합니다.
[AI] API error: 401API 키 오류OpenAI 대시보드에서 API 키 상태를 확인합니다.
import requests 에러라이브러리 미설치pip install flask requests를 다시 실행합니다.

지금까지 구성한 흐름

  • ESP32: 센서 수신, TFT 표시, PC 통신을 담당합니다.
  • PC 브로커: API 키를 안전하게 보관하고 AI 호출과 텔레그램 연동을 처리합니다.
  • 텔레그램: 외부에서도 AI 분석 결과를 확인하고 명령을 보낼 수 있는 알림 채널입니다.

이 단계까지 구성하면 Physical AI의 핵심 루프인 감지→판단→개입 흐름이 완성됩니다.

다음 편 예고

Method C — 라즈베리파이로 완전 통합. 다음 편에서는 Raspberry Pi 하나로 ESP32, PC 브로커, 텔레그램 연동을 통합하는 구조를 다룹니다. Python 코드 약 100줄, 전력 약 3W 수준으로 24시간 무중단 운영에 가까운 구성을 목표로 합니다.