From a3b3f417af28fce81a224a7b1a432e3f74900c65 Mon Sep 17 00:00:00 2001 From: Tony Tran Date: Sun, 5 Jul 2026 10:58:33 +0700 Subject: [PATCH] add esp32-s3 dashboard code --- .../esp32-s3-cliproxy-dashboard.ino | 700 ++++++++++++++++++ 1 file changed, 700 insertions(+) create mode 100644 esp32-s3-cliproxy-dashboard/esp32-s3-cliproxy-dashboard.ino diff --git a/esp32-s3-cliproxy-dashboard/esp32-s3-cliproxy-dashboard.ino b/esp32-s3-cliproxy-dashboard/esp32-s3-cliproxy-dashboard.ino new file mode 100644 index 0000000..8204e24 --- /dev/null +++ b/esp32-s3-cliproxy-dashboard/esp32-s3-cliproxy-dashboard.ino @@ -0,0 +1,700 @@ +#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); +}