Files
CLIProxyAPI-auto-install-on…/esp32-s3-cliproxy-dashboard/esp32-s3-cliproxy-dashboard.ino
T
2026-07-05 10:58:33 +07:00

701 lines
29 KiB
Arduino

#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <WebServer.h>
#include <DNSServer.h>
// ── 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<g_accountCount && i<5; i++) {
char sn[36]; strncpy(sn,g_accounts[i].name,35); sn[35]='\0';
tft.setTextSize(1); tft.setTextColor(COLOR_YELLOW);
tft.setCursor(6,y); tft.print(sn);
tft.setTextColor(COLOR_TEXT); tft.setCursor(6,y+12);
snprintf(buf,sizeof(buf)," %d reqs %ld tokens fail:%d",
g_accounts[i].requests, g_accounts[i].total_tokens, g_accounts[i].failed);
tft.print(buf);
tft.drawFastHLine(0,y+26,SCREEN_WIDTH,COLOR_HEADER);
y+=30;
}
if (g_accountCount == 0) {
tft.setTextColor(COLOR_TEXT); tft.setCursor(6,y); tft.print("(khong co du lieu)");
}
}
void drawPage2() {
// Models
tft.fillScreen(COLOR_BG);
drawHeader("Model", 2, TOTAL_PAGES);
int y=48; char buf[48];
for (int i=0; i<g_modelCount && i<6; i++) {
tft.setTextSize(1); tft.setTextColor(COLOR_GREEN);
tft.setCursor(6,y); tft.print(g_models[i].name);
tft.setTextColor(COLOR_TEXT); tft.setCursor(6,y+12);
snprintf(buf,sizeof(buf)," %d reqs %ld tokens fail:%d",
g_models[i].requests, g_models[i].total_tokens, g_models[i].failed);
tft.print(buf);
tft.drawFastHLine(0,y+26,SCREEN_WIDTH,COLOR_HEADER);
y+=30;
}
if (g_modelCount == 0) {
tft.setTextColor(COLOR_TEXT); tft.setCursor(6,y); tft.print("(khong co du lieu)");
}
}
void drawPage3() {
// Hours (hoạt động theo giờ gần nhất)
tft.fillScreen(COLOR_BG);
drawHeader("Hourly", 3, TOTAL_PAGES);
int y=48; char buf[48];
int start = g_hourCount > 6 ? g_hourCount - 6 : 0; // 6 giờ gần nhất
for (int i=start; i<g_hourCount; i++) {
tft.setTextSize(1); tft.setTextColor(COLOR_CYAN);
tft.setCursor(6,y); tft.print(g_hours[i].hour);
tft.setTextColor(COLOR_TEXT);
snprintf(buf,sizeof(buf)," %d reqs %ld tok", g_hours[i].requests, g_hours[i].total_tokens);
tft.setCursor(6,y+12); tft.print(buf);
tft.drawFastHLine(0,y+26,SCREEN_WIDTH,COLOR_HEADER);
y+=30;
if (y > 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<JsonArray>()) {
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<JsonArray>()) {
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<JsonArray>()) {
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(
<!DOCTYPE html><html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 Setup</title>
<style>
body{font-family:sans-serif;background:#111;color:#eee;max-width:420px;margin:auto;padding:16px}
h2{color:#0cf} input,select{width:100%;padding:10px;margin:8px 0;background:#222;color:#eee;border:1px solid #444;border-radius:6px;box-sizing:border-box;font-size:15px}
button{width:100%;padding:12px;background:#0a0;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;margin-top:8px}
button.scan{background:#005f8f} .msg{padding:10px;border-radius:6px;margin-top:12px}
.ok{background:#0a0} .err{background:#900} label{color:#aaa;font-size:13px}
</style></head><body>
<h2>&#9881; ESP32-S3 Setup</h2>
<button class="scan" onclick="scanWifi()">&#128246; Quet WiFi</button><br><br>
<label>Chon mang WiFi</label>
<select id="ssid_sel" onchange="document.getElementById('ssid').value=this.value">
<option value="">-- Quet truoc --</option></select>
<label>Hoac nhap thu cong</label>
<input id="ssid" type="text" placeholder="Ten WiFi">
<label>Mat khau</label>
<input id="pass" type="password" placeholder="Mat khau WiFi">
<label>API URL</label>
<input id="api" type="text" value="%API_URL%">
<button onclick="save()">&#128190; Luu va Ket noi</button>
<div id="msg"></div>
<script>
function scanWifi(){
document.getElementById('msg').innerHTML='<div class="msg">Dang quet...</div>';
fetch('/scan').then(r=>r.json()).then(nets=>{
let sel=document.getElementById('ssid_sel');
sel.innerHTML='<option value="">-- Chon mang --</option>';
nets.forEach(n=>{let o=document.createElement('option');o.value=n.ssid;
o.text=n.ssid+' ('+n.rssi+'dBm'+(n.open?', Open':'')+')';sel.appendChild(o);});
document.getElementById('msg').innerHTML='';
}).catch(()=>{document.getElementById('msg').innerHTML='<div class="msg err">Loi quet</div>';});
}
function save(){
let ssid=document.getElementById('ssid').value||document.getElementById('ssid_sel').value;
let pass=document.getElementById('pass').value;
let api=document.getElementById('api').value;
if(!ssid){document.getElementById('msg').innerHTML='<div class="msg err">Nhap SSID</div>';return;}
document.getElementById('msg').innerHTML='<div class="msg">Dang luu va khoi dong lai...</div>';
fetch('/save',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'ssid='+encodeURIComponent(ssid)+'&pass='+encodeURIComponent(pass)+'&api='+encodeURIComponent(api)})
.then(r=>r.text()).then(t=>{document.getElementById('msg').innerHTML='<div class="msg ok">'+t+'</div>';})
.catch(()=>{document.getElementById('msg').innerHTML='<div class="msg err">Loi</div>';});
}
</script></body></html>)rawliteral";
const char HTML_CONFIG[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32 Config</title>
<style>
body{font-family:sans-serif;background:#111;color:#eee;max-width:420px;margin:auto;padding:16px}
h2{color:#0cf} h3{color:#fa0;margin-top:20px}
input{width:100%;padding:10px;margin:8px 0;background:#222;color:#eee;border:1px solid #444;border-radius:6px;box-sizing:border-box;font-size:15px}
button{width:100%;padding:12px;background:#0a0;color:#fff;border:none;border-radius:6px;font-size:16px;cursor:pointer;margin-top:8px}
button.danger{background:#900} .msg{padding:10px;border-radius:6px;margin-top:12px}
.ok{background:#0a0} .err{background:#900}
.info{background:#005f8f;padding:10px;border-radius:6px;margin-bottom:12px;font-size:13px}
label{color:#aaa;font-size:13px}
</style></head><body>
<h2>&#9881; ESP32-S3 Config</h2>
<div class="info">IP: %LOCAL_IP% &nbsp;|&nbsp; SSID: %CURRENT_SSID%</div>
<h3>Doi API URL</h3>
<label>API URL hien tai</label>
<input id="api" type="text" value="%API_URL%">
<button onclick="saveApi()">&#128190; Cap nhat API URL</button>
<h3>Doi WiFi</h3>
<label>SSID moi</label><input id="ssid" type="text" placeholder="Ten WiFi moi">
<label>Mat khau moi</label><input id="pass" type="password" placeholder="Mat khau">
<button onclick="saveWifi()">&#128246; Doi WiFi va Khoi dong lai</button>
<h3>Reset</h3>
<button class="danger" onclick="if(confirm('Reset?'))reset()">&#128683; Reset ve AP Mode</button>
<div id="msg"></div>
<script>
function saveApi(){
fetch('/update',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'api='+encodeURIComponent(document.getElementById('api').value)})
.then(r=>r.text()).then(t=>{document.getElementById('msg').innerHTML='<div class="msg ok">'+t+'</div>';})
.catch(()=>{document.getElementById('msg').innerHTML='<div class="msg err">Loi</div>';});
}
function saveWifi(){
let ssid=document.getElementById('ssid').value;
if(!ssid){document.getElementById('msg').innerHTML='<div class="msg err">Nhap SSID</div>';return;}
fetch('/save',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'ssid='+encodeURIComponent(ssid)+'&pass='+encodeURIComponent(document.getElementById('pass').value)+'&api='+encodeURIComponent(document.getElementById('api').value)})
.then(r=>r.text()).then(t=>{document.getElementById('msg').innerHTML='<div class="msg ok">'+t+'</div>';})
.catch(()=>{document.getElementById('msg').innerHTML='<div class="msg err">Loi</div>';});
}
function reset(){fetch('/reset').then(()=>{document.getElementById('msg').innerHTML='<div class="msg ok">Dang reset...</div>';});}
</script></body></html>)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; i<n; i++) {
if (i>0) 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);
}