701 lines
29 KiB
Arduino
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>⚙ ESP32-S3 Setup</h2>
|
|
<button class="scan" onclick="scanWifi()">📶 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()">💾 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>⚙ ESP32-S3 Config</h2>
|
|
<div class="info">IP: %LOCAL_IP% | 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()">💾 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()">📶 Doi WiFi va Khoi dong lai</button>
|
|
<h3>Reset</h3>
|
|
<button class="danger" onclick="if(confirm('Reset?'))reset()">🚫 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);
|
|
}
|