#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; long g_tokens30d = 0; // tổng token 30 ngày (range=30d) 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; volatile bool g_fetchNow = false; // flag yêu cầu fetch ngay 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(); } // ── Footer hiển thị token 30 ngày + IP để truy cập trang Config ── void drawFooter() { int footerTop = SCREEN_HEIGHT - 32; tft.drawFastHLine(0, footerTop - 1, SCREEN_WIDTH, COLOR_HEADER); // Dòng 1: tổng token 30 ngày char buf[40]; tft.setTextSize(1); tft.setTextColor(COLOR_YELLOW); tft.setCursor(6, footerTop + 3); tft.print("30d Tokens: "); tft.setTextColor(COLOR_ORANGE); fmtNum(g_tokens30d, buf); tft.print(buf); // Dòng 2: IP config tft.setTextColor(COLOR_CYAN); tft.setCursor(6, footerTop + 15); tft.print("Config: http://"); tft.setTextColor(COLOR_ORANGE); tft.print(WiFi.localIP().toString()); } // ── Format số kiểu EU: dấu . phân cách hàng nghìn, không thập phân ── void fmtNum(long n, char* out) { char tmp[20]; bool neg = n < 0; if (neg) n = -n; int dig = 0, i = 0; if (n == 0) { tmp[i++] = '0'; dig = 1; } while (n > 0) { if (dig > 0 && dig % 3 == 0) tmp[i++] = '.'; tmp[i++] = '0' + (n % 10); n /= 10; dig++; } if (neg) tmp[i++] = '-'; int j = 0; while (i > 0) out[j++] = tmp[--i]; out[j] = '\0'; } // ── 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); fmtNum(g_requests, buf); 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); fmtNum(g_failed, buf); 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); fmtNum(g_total_tokens, buf); 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); fmtNum(g_input_tokens, buf); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Output : "); tft.setTextColor(COLOR_TEXT); fmtNum(g_output_tokens, buf); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Reasoning: "); tft.setTextColor(COLOR_TEXT); fmtNum(g_reasoning_tokens, buf); tft.println(buf); y+=14; tft.setTextColor(COLOR_CYAN); tft.setCursor(x,y); tft.print("Cached : "); tft.setTextColor(COLOR_TEXT); fmtNum(g_cached_tokens, buf); tft.println(buf); } void drawPage1() { // Accounts tft.fillScreen(COLOR_BG); drawHeader("Account", 1, TOTAL_PAGES); int y=48; char buf[48], nb[16], tb[16]; for (int i=0; i 6 ? g_hourCount - 6 : 0; // 6 giờ gần nhất for (int i=start; i SCREEN_HEIGHT - 36) 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; } if (!g_apMode) { drawFooter(); } 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; } // ═════════════════════════════════════════════════ // Fetch tổng token 30 ngày (range=30d) // ═════════════════════════════════════════════════ void fetch30dTokens() { // Tạo URL với range=30d từ g_api_url hiện tại String url30 = String(g_api_url); int q = url30.indexOf('?'); if (q >= 0) { // Có query string -> thay thế/thêm range=30d int r = url30.indexOf("range="); if (r >= 0) { int amp = url30.indexOf('&', r); if (amp >= 0) url30 = url30.substring(0, r) + "range=30d" + url30.substring(amp); else url30 = url30.substring(0, r) + "range=30d"; } else { url30 += "&range=30d"; } } else { url30 += "?range=30d"; } Serial.printf("[FETCH30] GET %s\n", url30.c_str()); HTTPClient http; http.begin(url30); http.setTimeout(10000); int code = http.GET(); if (code != 200) { http.end(); return; } String payload = http.getString(); http.end(); DynamicJsonDocument doc(4096); DeserializationError err = deserializeJson(doc, payload); if (err) { Serial.printf("[FETCH30] JSON error: %s\n", err.c_str()); return; } g_tokens30d = doc["summary"]["total_tokens"] | 0L; Serial.printf("[FETCH30] 30d tokens = %ld\n", g_tokens30d); } // ═════════════════════════════════════════════════ // 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(); g_fetchNow = true; // yêu cầu fetch ngay với API URL mới server.send(200, "text/plain", "API URL da cap nhat! Dang lay du lieu moi..."); } 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(); } // Lấy tổng token 30 ngày fetch30dTokens(); drawCurrentPage(); } // Nếu có yêu cầu fetch ngay (sau khi đổi API URL), không delay 60s if (g_fetchNow) { g_fetchNow = false; vTaskDelay(500 / portTICK_PERIOD_MS); // đợi ngắn rồi fetch tiếp } else { 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); }