#include #include #include #include #include #include #include #include #include #include // ── Cấu hình chân TFT (ESP32-S3-N16R8) ─────────── // SCL = SCLK, SDA = MOSI (theo cách gọi của user) #define TFT_CS 16 #define TFT_DC 15 #define TFT_RST 7 #define TFT_MOSI 6 // SDA #define TFT_SCLK 5 // SCL // Màn hình nằm NGANG 320x240 #define SCREEN_WIDTH 320 #define SCREEN_HEIGHT 240 #define TFT_ROTATION 3 // đổi thành 3 nếu hình bị lộn ngược #define COLOR_BG ST77XX_BLACK #define COLOR_TEXT ST77XX_WHITE #define COLOR_CYAN ST77XX_CYAN #define COLOR_GREEN ST77XX_GREEN #define COLOR_YELLOW ST77XX_YELLOW #define COLOR_ORANGE 0xFD20 #define COLOR_RED ST77XX_RED #define COLOR_HEADER 0x1082 #define AP_SSID "ESP32-S3-Setup" #define DNS_PORT 53 // ── LED onboard (RGB) ────────────────────────── #define LED_PIN 48 #define NUMPIXELS 1 // Khởi tạo đối tượng NeoPixel Adafruit_NeoPixel pixels(NUMPIXELS, LED_PIN, NEO_GRB + NEO_KHZ800); Adafruit_ST7789 tft = Adafruit_ST7789(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCLK, TFT_RST); Preferences prefs; WebServer server(80); DNSServer dnsServer; // ── Cấu hình ───────────────────────────────────── char g_ssid[64] = "Tenda_FF1220"; char g_password[64] = "1234567890"; char g_api_url[160] = "http://192.168.1.3:8320/api/summary?range=today"; // ── Dữ liệu ────────────────────────────────────── struct AccountStat { char name[40]; int requests; long total_tokens; int failed; }; struct ModelStat { char name[24]; int requests; long total_tokens; int failed; }; struct HourStat { char hour[20]; int requests; long total_tokens; }; long g_requests = 0; long g_total_tokens = 0; long g_input_tokens = 0; long g_output_tokens = 0; long g_reasoning_tokens= 0; long g_cached_tokens = 0; long g_failed = 0; AccountStat g_accounts[8]; int g_accountCount = 0; ModelStat g_models[6]; int g_modelCount = 0; HourStat g_hours[8]; int g_hourCount = 0; // ── State flags ─────────────────────────────────── volatile bool g_dataReady = false; volatile bool g_apMode = false; int g_page = 0; #define TOTAL_PAGES 4 SemaphoreHandle_t g_tftMutex; // ═════════════════════════════════════════════════ // Preferences // ═════════════════════════════════════════════════ void loadPrefs() { prefs.begin("cfg", true); prefs.getString("ssid", g_ssid, sizeof(g_ssid)); prefs.getString("pass", g_password,sizeof(g_password)); prefs.getString("api_url", g_api_url, sizeof(g_api_url)); prefs.end(); Serial.printf("[PREFS] ssid=%s api=%s\n", g_ssid, g_api_url); } void savePrefs() { prefs.begin("cfg", false); prefs.putString("ssid", g_ssid); prefs.putString("pass", g_password); prefs.putString("api_url", g_api_url); prefs.end(); Serial.println("[PREFS] saved"); } // ═════════════════════════════════════════════════ // TFT helpers — tất cả dùng mutex // ═════════════════════════════════════════════════ void tftLock() { xSemaphoreTake(g_tftMutex, portMAX_DELAY); } void tftUnlock() { xSemaphoreGive(g_tftMutex); } void drawHeader(const char* subtitle, int page, int total) { tft.fillRect(0, 0, SCREEN_WIDTH, 40, COLOR_HEADER); tft.setTextColor(COLOR_CYAN); tft.setTextSize(2); tft.setCursor(8, 4); tft.print("CLIProxyAPI"); tft.setTextColor(COLOR_ORANGE); tft.setTextSize(1); tft.setCursor(8, 24); tft.print("Dashboard"); tft.setTextColor(COLOR_YELLOW); tft.setCursor(190, 8); tft.print(subtitle); if (total > 0) { char buf[8]; snprintf(buf, sizeof(buf), "%d/%d", page+1, total); tft.setTextColor(COLOR_GREEN); tft.setCursor(SCREEN_WIDTH - (int)strlen(buf)*6 - 6, 24); tft.print(buf); } tft.drawFastHLine(0, 40, SCREEN_WIDTH, COLOR_ORANGE); } void tft_showAPMode() { tftLock(); tft.fillScreen(COLOR_BG); drawHeader("SETUP MODE", 0, 0); tft.setTextColor(COLOR_YELLOW); tft.setTextSize(1); tft.setCursor(8,50); tft.println("Ket noi WiFi:"); tft.setTextColor(COLOR_GREEN); tft.setTextSize(2); tft.setCursor(8,64); tft.println(AP_SSID); tft.setTextColor(COLOR_TEXT); tft.setTextSize(1); tft.setCursor(8,88); tft.println("(Khong can mat khau)"); tft.drawFastHLine(0, 102, SCREEN_WIDTH, COLOR_HEADER); tft.setTextColor(COLOR_CYAN); tft.setCursor(8,112); tft.println("Mo trinh duyet:"); tft.setTextColor(COLOR_ORANGE); tft.setTextSize(2); tft.setCursor(8,128); tft.println("192.168.4.1"); tft.setTextColor(COLOR_TEXT); tft.setTextSize(1); tft.setCursor(8,154); tft.println("de cau hinh WiFi & API"); tftUnlock(); } void tft_showConnecting(const char* ssid) { tftLock(); tft.fillScreen(COLOR_BG); drawHeader("CONNECTING...", 0, 0); tft.setTextColor(COLOR_YELLOW); tft.setTextSize(1); tft.setCursor(8,50); tft.print("SSID: "); tft.println(ssid); tft.setTextColor(COLOR_TEXT); tft.setCursor(8,66); tft.println("Vui long cho..."); tftUnlock(); } void tft_showConnected() { tftLock(); tft.fillScreen(COLOR_BG); tft.fillRect(0, 0, SCREEN_WIDTH, 50, ST77XX_GREEN); tft.setTextColor(ST77XX_WHITE); tft.setTextSize(2); tft.setCursor(15,8); tft.println("Network"); tft.setCursor(15,28); tft.println("Connected!"); tft.drawFastHLine(0, 52, SCREEN_WIDTH, COLOR_GREEN); tft.setTextSize(1); tft.setTextColor(COLOR_CYAN); tft.setCursor(8,66); tft.print("IP : "); tft.setTextColor(COLOR_GREEN); tft.println(WiFi.localIP().toString()); tft.setTextColor(COLOR_CYAN); tft.setCursor(8,82); tft.print("Cfg : http://"); tft.setTextColor(COLOR_ORANGE); tft.println(WiFi.localIP().toString()); tft.setTextColor(COLOR_YELLOW); tft.setCursor(8,100); tft.println("Dang lay du lieu..."); tftUnlock(); } void tft_showWiFiFailed() { tftLock(); tft.fillScreen(COLOR_BG); tft.fillRect(0, 0, SCREEN_WIDTH, 50, ST77XX_RED); tft.setTextColor(ST77XX_WHITE); tft.setTextSize(2); tft.setCursor(10,8); tft.println("WiFi"); tft.setCursor(10,28); tft.println("FAILED!"); tft.setTextSize(1); tft.setTextColor(COLOR_YELLOW); tft.setCursor(8,64); tft.println("Ket noi WiFi: " AP_SSID); tft.setCursor(8,80); tft.println("Truy cap 192.168.4.1"); tft.setCursor(8,96); tft.println("de cau hinh lai"); tftUnlock(); } void tft_showFetchError() { tftLock(); tft.fillRect(0, SCREEN_HEIGHT-16, SCREEN_WIDTH, 16, COLOR_BG); tft.setTextSize(1); tft.setTextColor(COLOR_RED); tft.setCursor(6, SCREEN_HEIGHT-12); tft.print("Fetch loi! Kiem tra API URL"); tftUnlock(); } // ── Vẽ 4 trang dữ liệu ─────────────────────────── void drawPage0() { // Tổng quan tft.fillScreen(COLOR_BG); drawHeader("Total", 0, TOTAL_PAGES); char buf[40]; // Cột trái: Requests lớn tft.setTextSize(1); tft.setTextColor(COLOR_CYAN); tft.setCursor(8,48); tft.print("Requests"); tft.setTextSize(4); tft.setTextColor(COLOR_GREEN); snprintf(buf,sizeof(buf),"%ld",g_requests); tft.setCursor(8,62); tft.print(buf); tft.setTextSize(1); tft.setTextColor(COLOR_CYAN); tft.setCursor(8,108); tft.print("Failed"); tft.setTextSize(2); tft.setTextColor(g_failed > 0 ? COLOR_RED : COLOR_TEXT); snprintf(buf,sizeof(buf),"%ld",g_failed); tft.setCursor(8,120); tft.print(buf); // Cột phải: token breakdown int x = 165, y = 48; tft.drawFastVLine(x-12, 46, SCREEN_HEIGHT-46-10, COLOR_HEADER); tft.setTextSize(1); tft.setTextColor(COLOR_YELLOW); tft.setCursor(x,y); tft.print("Total Tokens:"); y+=14; tft.setTextSize(2); tft.setTextColor(COLOR_ORANGE); snprintf(buf,sizeof(buf),"%ld",g_total_tokens); tft.setCursor(x,y); tft.print(buf); y+=24; tft.setTextSize(1); tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Input : "); tft.setTextColor(COLOR_TEXT); snprintf(buf,sizeof(buf),"%ld",g_input_tokens); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Output : "); tft.setTextColor(COLOR_TEXT); snprintf(buf,sizeof(buf),"%ld",g_output_tokens); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Reasoning: "); tft.setTextColor(COLOR_TEXT); snprintf(buf,sizeof(buf),"%ld",g_reasoning_tokens); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Cached : "); tft.setTextColor(COLOR_TEXT); snprintf(buf,sizeof(buf),"%ld",g_cached_tokens); tft.println(buf); } void drawPage1() { // Accounts tft.fillScreen(COLOR_BG); drawHeader("Account", 1, TOTAL_PAGES); int y=48; char buf[48]; for (int i=0; i 6 ? g_hourCount - 6 : 0; // 6 giờ gần nhất for (int i=start; i SCREEN_HEIGHT - 28) break; } if (g_hourCount == 0) { tft.setTextColor(COLOR_TEXT); tft.setCursor(6,y); tft.print("(khong co du lieu)"); } } void drawCurrentPage() { tftLock(); switch (g_page) { case 0: drawPage0(); break; case 1: drawPage1(); break; case 2: drawPage2(); break; case 3: drawPage3(); break; } tftUnlock(); } // ═════════════════════════════════════════════════ // Fetch API // ═════════════════════════════════════════════════ bool fetchAndParseData() { Serial.printf("[FETCH] GET %s\n", g_api_url); HTTPClient http; http.begin(g_api_url); http.setTimeout(10000); int code = http.GET(); Serial.printf("[FETCH] HTTP code: %d\n", code); if (code != 200) { http.end(); return false; } String payload = http.getString(); http.end(); Serial.printf("[FETCH] Payload size: %d bytes\n", payload.length()); // Buffer 24KB đủ cho summary + accounts + models + hours DynamicJsonDocument doc(24576); DeserializationError err = deserializeJson(doc, payload); if (err) { Serial.printf("[FETCH] JSON error: %s\n", err.c_str()); Serial.printf("[FETCH] Payload preview: %s\n", payload.substring(0, 200).c_str()); return false; } JsonObject summary = doc["summary"]; g_requests = summary["requests"] | 0L; g_total_tokens = summary["total_tokens"] | 0L; g_input_tokens = summary["input_tokens"] | 0L; g_output_tokens = summary["output_tokens"] | 0L; g_reasoning_tokens = summary["reasoning_tokens"] | 0L; g_cached_tokens = summary["cached_tokens"] | 0L; g_failed = summary["failed"] | 0L; // Parse accounts[] g_accountCount = 0; if (doc.containsKey("accounts")) { for (JsonObject acc : doc["accounts"].as()) { if (g_accountCount >= 8) break; const char* name = acc["account"] | "?"; strncpy(g_accounts[g_accountCount].name, name, 39); g_accounts[g_accountCount].name[39] = '\0'; g_accounts[g_accountCount].requests = acc["requests"] | 0; g_accounts[g_accountCount].total_tokens = acc["total_tokens"] | 0L; g_accounts[g_accountCount].failed = acc["failed"] | 0; g_accountCount++; } } // Parse models[] g_modelCount = 0; if (doc.containsKey("models")) { for (JsonObject m : doc["models"].as()) { if (g_modelCount >= 6) break; const char* name = m["model"] | "?"; strncpy(g_models[g_modelCount].name, name, 23); g_models[g_modelCount].name[23] = '\0'; g_models[g_modelCount].requests = m["requests"] | 0; g_models[g_modelCount].total_tokens = m["total_tokens"] | 0L; g_models[g_modelCount].failed = m["failed"] | 0; g_modelCount++; } } // Parse hours[] g_hourCount = 0; if (doc.containsKey("hours")) { for (JsonObject h : doc["hours"].as()) { if (g_hourCount >= 8) break; const char* hr = h["hour"] | "?"; // Chỉ lấy phần giờ "HH:00" để gọn (bỏ ngày) const char* sp = strchr(hr, ' '); const char* shown = sp ? sp+1 : hr; strncpy(g_hours[g_hourCount].hour, shown, 19); g_hours[g_hourCount].hour[19] = '\0'; g_hours[g_hourCount].requests = h["requests"] | 0; g_hours[g_hourCount].total_tokens = h["total_tokens"] | 0L; g_hourCount++; } } Serial.printf("[FETCH] OK — reqs=%ld tokens=%ld accounts=%d models=%d hours=%d\n", g_requests, g_total_tokens, g_accountCount, g_modelCount, g_hourCount); return true; } // ═════════════════════════════════════════════════ // HTML // ═════════════════════════════════════════════════ const char HTML_SETUP[] PROGMEM = R"rawliteral( ESP32 Setup

⚙ ESP32-S3 Setup



)rawliteral"; const char HTML_CONFIG[] PROGMEM = R"rawliteral( ESP32 Config

⚙ ESP32-S3 Config

IP: %LOCAL_IP%  |  SSID: %CURRENT_SSID%

Doi API URL

Doi WiFi

Reset

)rawliteral"; // ── Web handlers ────────────────────────────────── void handleRoot() { if (g_apMode) { String p = String(HTML_SETUP); p.replace("%API_URL%", g_api_url); server.send(200, "text/html", p); } else { String p = String(HTML_CONFIG); p.replace("%LOCAL_IP%", WiFi.localIP().toString()); p.replace("%CURRENT_SSID%", g_ssid); p.replace("%API_URL%", g_api_url); server.send(200, "text/html", p); } } void handleScan() { int n = WiFi.scanNetworks(); String json = "["; for (int i=0; i0) json += ","; json += "{\"ssid\":\"" + WiFi.SSID(i) + "\",\"rssi\":" + WiFi.RSSI(i) + ",\"open\":" + (WiFi.encryptionType(i)==WIFI_AUTH_OPEN?"true":"false") + "}"; } json += "]"; server.send(200, "application/json", json); } void handleSave() { if (server.hasArg("ssid")) strncpy(g_ssid, server.arg("ssid").c_str(), sizeof(g_ssid)-1); if (server.hasArg("pass")) strncpy(g_password, server.arg("pass").c_str(), sizeof(g_password)-1); if (server.hasArg("api")) strncpy(g_api_url, server.arg("api").c_str(), sizeof(g_api_url)-1); savePrefs(); server.send(200, "text/plain", "Da luu! Dang khoi dong lai..."); delay(800); ESP.restart(); } void handleUpdate() { if (server.hasArg("api")) strncpy(g_api_url, server.arg("api").c_str(), sizeof(g_api_url)-1); savePrefs(); server.send(200, "text/plain", "API URL da cap nhat!"); } void handleReset() { prefs.begin("cfg", false); prefs.clear(); prefs.end(); server.send(200, "text/plain", "Dang reset..."); delay(800); ESP.restart(); } void startWebServer() { server.on("/", HTTP_GET, handleRoot); server.on("/scan", HTTP_GET, handleScan); server.on("/save", HTTP_POST, handleSave); server.on("/update", HTTP_POST, handleUpdate); server.on("/reset", HTTP_GET, handleReset); server.begin(); Serial.println("[WEB] Server started"); } // ═════════════════════════════════════════════════ // Task: Web server // ═════════════════════════════════════════════════ void web_task(void* pv) { while (1) { if (g_apMode) dnsServer.processNextRequest(); server.handleClient(); vTaskDelay(5 / portTICK_PERIOD_MS); } } // ═════════════════════════════════════════════════ // Task: Fetch dữ liệu - mỗi 60s // ═════════════════════════════════════════════════ void fetch_task(void* pv) { vTaskDelay(1000 / portTICK_PERIOD_MS); while (1) { if (WiFi.status() == WL_CONNECTED) { bool ok = fetchAndParseData(); if (ok) { g_dataReady = true; g_page = 0; drawCurrentPage(); Serial.println("Đèn Blue"); pixels.setPixelColor(0, pixels.Color(0, 0, 255)); // (Chỉ số LED, R, G, B) pixels.show(); // Cập nhật hiển thị lên đèn } else { tft_showFetchError(); } } vTaskDelay(60000 / portTICK_PERIOD_MS); } } // ═════════════════════════════════════════════════ // Task: Chuyển trang - mỗi 10s // ═════════════════════════════════════════════════ void display_task(void* pv) { while (!g_dataReady) vTaskDelay(500 / portTICK_PERIOD_MS); vTaskDelay(10000 / portTICK_PERIOD_MS); while (1) { if (g_dataReady) { g_page = (g_page + 1) % TOTAL_PAGES; drawCurrentPage(); Serial.println("Đèn Xanh"); pixels.setPixelColor(0, pixels.Color(0, 255, 0)); // (Chỉ số LED, R, G, B) pixels.show(); // Cập nhật hiển thị lên đèn } vTaskDelay(10000 / portTICK_PERIOD_MS); } } // ═════════════════════════════════════════════════ // Setup // ═════════════════════════════════════════════════ void setup() { Serial.begin(115200); delay(500); g_tftMutex = xSemaphoreCreateMutex(); pixels.begin(); // Khởi động thư viện NeoPixel pixels.setBrightness(50); // Đặt độ sáng vừa phải (0-255) để tránh chói mắt và tiết kiệm điện Serial.println("Tắt đèn"); pixels.clear(); // Xóa toàn bộ màu (đưa về 0, 0, 0) pixels.show(); delay(1000); tft.init(SCREEN_HEIGHT, SCREEN_WIDTH); // init theo kích thước "dọc gốc" của panel ST7789 tft.setRotation(TFT_ROTATION); // xoay sang ngang 320x240 tft.fillScreen(COLOR_BG); tft.fillRect(0, 0, SCREEN_WIDTH, 44, COLOR_HEADER); tft.setTextColor(COLOR_CYAN); tft.setTextSize(2); tft.setCursor(20,12); tft.println("ESP32-S3"); tft.setTextColor(COLOR_TEXT); tft.setTextSize(1); tft.setCursor(6,56); tft.println("Dang khoi dong..."); loadPrefs(); if (strlen(g_ssid) == 0) { Serial.println("[MAIN] No config -> AP mode"); g_apMode = true; WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID); delay(500); dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); startWebServer(); tft_showAPMode(); xTaskCreatePinnedToCore(web_task, "Web", 4096, NULL, 5, NULL, 0); } else { Serial.printf("[MAIN] Connecting to %s\n", g_ssid); g_apMode = false; WiFi.mode(WIFI_STA); WiFi.begin(g_ssid, g_password); tft_showConnecting(g_ssid); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 24) { delay(500); attempts++; Serial.print("."); } Serial.println(); if (WiFi.status() != WL_CONNECTED) { Serial.println("[MAIN] WiFi FAILED -> AP fallback"); tft_showWiFiFailed(); g_apMode = true; WiFi.mode(WIFI_AP); WiFi.softAP(AP_SSID); delay(500); dnsServer.start(DNS_PORT, "*", WiFi.softAPIP()); startWebServer(); xTaskCreatePinnedToCore(web_task, "Web", 4096, NULL, 5, NULL, 0); } else { Serial.printf("[MAIN] WiFi OK - IP: %s\n", WiFi.localIP().toString().c_str()); tft_showConnected(); startWebServer(); // ESP32-S3 có 2 core thật (dual-core), tận dụng pin task xTaskCreatePinnedToCore(web_task, "Web", 4096, NULL, 5, NULL, 0); xTaskCreatePinnedToCore(fetch_task, "Fetch", 8192, NULL, 4, NULL, 1); xTaskCreatePinnedToCore(display_task, "Display", 4096, NULL, 3, NULL, 1); } } } void loop() { vTaskDelay(1000 / portTICK_PERIOD_MS); }