Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
# ESP32-S3 CLIProxyAPI Dashboard
|
||||||
|
|
||||||
|
Hiển thị thống kê sử dụng CLIProxyAPI trên màn hình ST7789 320x240 gắn với ESP32-S3 (N16R8).
|
||||||
|
|
||||||
|
## 📦 Phần cứng cần thiết
|
||||||
|
|
||||||
|
| Thành phần | Mô tả |
|
||||||
|
|------------|-------|
|
||||||
|
| **ESP32-S3** | Board có PSRAM (N16R8 khuyến nghị) – ví dụ: ESP32-S3-DevKitC-1, ESP32-S3-N16R8 |
|
||||||
|
| **Màn hình ST7789** | 320×240 SPI, driver ST7789, không touch (hoặc có touch nhưng không dùng) |
|
||||||
|
| **Dây nối** | 6–7 dây jumper (mẹ-đực hoặc hàn trực tiếp) |
|
||||||
|
| **Nguồn** | USB-C (5 V) cấp cho ESP32-S3 |
|
||||||
|
|
||||||
|
> ⚠️ ESP32-S3 chỉ chịu 3.3 V trên GPIO. Màn hình ST7789 thông thường hoạt động 3.3 V logic – **không cần level-shifter** nếu màn hình đã có LDO 3.3 V onboard (phần lớn module ST7789 bán sẵn đều có).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Sơ đồ đấu nối (theo code hiện tại)
|
||||||
|
|
||||||
|
| Màn hình ST7789 | ESP32-S3 (GPIO) | Chú thích |
|
||||||
|
|----------------|----------------|----------|
|
||||||
|
| **VCC** | 3V3 | Cấp 3.3 V (hoặc 5V nếu module có LDO) |
|
||||||
|
| **GND** | GND | |
|
||||||
|
| **SCL / SCK** | **GPIO 5** | SPI Clock |
|
||||||
|
| **SDA / MOSI** | **GPIO 6** | SPI MOSI |
|
||||||
|
| **RST** | **GPIO 7** | Reset (active low) |
|
||||||
|
| **DC / RS** | **GPIO 15** | Data/Command select |
|
||||||
|
| **CS** | **GPIO 16** | Chip Select |
|
||||||
|
| **BLK / BL** | 3V3 (hoặc GPIO tùy chỉnh) | Backlight – nối 3V3 để luôn bật, hoặc gắn GPIO để PWM dimming |
|
||||||
|
|
||||||
|
> Nếu màn hình của bạn có chân **MISO** – **không cần nối** (driver Adafruit_ST7789 chỉ dùng chế độ write-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Chuẩn bị môi trường Arduino IDE
|
||||||
|
|
||||||
|
1. **Cài Arduino IDE** ≥ 2.x
|
||||||
|
2. **Boards Manager** → tìm **"esp32"** → cài **esp32 by Espressif Systems** ≥ 3.0.0
|
||||||
|
3. **Tools → Board** → **ESP32S3 Dev Module** (hoặc board ESP32-S3 cụ thể bạn dùng)
|
||||||
|
- **USB CDC On Boot**: Enabled
|
||||||
|
- **Flash Mode**: QIO 80MHz
|
||||||
|
- **Flash Size**: 16 MB (128 Mb)
|
||||||
|
- **Partition Scheme**: 16M Flash (3M APP / 9.9M FATFS) hoặc tuỳ chọn
|
||||||
|
- **PSRAM**: OPI PSRAM
|
||||||
|
4. **Library Manager** cài các thư viện sau:
|
||||||
|
- `Adafruit GFX Library` (by Adafruit)
|
||||||
|
- `Adafruit ST7735 and ST7789 Library` (by Adafruit)
|
||||||
|
- `Adafruit NeoPixel` (by Adafruit)
|
||||||
|
- `ArduinoJson` ≥ 7.x (by Benoit Blanchon)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Cấu hình code trước khi nạp
|
||||||
|
|
||||||
|
Mở file `esp32-s3-cliproxy-dashboard.ino`, sửa các dòng sau nếu cần:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ── Cấu hình chân TFT ── (đổi nếu bạn đấu nối khác)
|
||||||
|
#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 ngang 320x240
|
||||||
|
#define SCREEN_WIDTH 320
|
||||||
|
#define SCREEN_HEIGHT 240
|
||||||
|
#define TFT_ROTATION 3 // 0,1,2,3 – xoay cho đúng hướng
|
||||||
|
|
||||||
|
// ── WiFi mặc định (sẽ ghi đè qua giao diện web sau khi nạp) ──
|
||||||
|
char g_ssid[64] = "TEN_WIFI_CUA_BAN";
|
||||||
|
char g_password[64] = "MAT_KHAU_WIFI";
|
||||||
|
|
||||||
|
// ── API URL trỏ về dashboard server (máy chạy usage-dashboard.py) ──
|
||||||
|
// Mặc định: http://<IP_MAY_CHU>:8320/api/summary?range=today
|
||||||
|
char g_api_url[160] = "http://192.168.1.3:8320/api/summary?range=today";
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Lưu ý**: `g_api_url` phải trỏ đến endpoint `/api/summary` của `usage-dashboard.py` đang chạy trên máy chủ (PC/Server) cùng mạng LAN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Nạp firmware
|
||||||
|
|
||||||
|
1. Kết nối ESP32-S3 với PC qua USB-C.
|
||||||
|
2. Chọn đúng **Port** (COMx / /dev/ttyUSBx).
|
||||||
|
3. Nhấn **Upload** (mũi tên phải) trong Arduino IDE.
|
||||||
|
4. Mở **Serial Monitor** (115200 baud) để theo dõi log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Cấu hình WiFi & API qua giao diện Web (AP Mode)
|
||||||
|
|
||||||
|
Lần đầu nạp (hoặc khi reset cấu hình):
|
||||||
|
|
||||||
|
1. ESP32 phát Access Point **`ESP32-S3-Setup`** (không mật khẩu).
|
||||||
|
2. Kết nối điện thoại / laptop vào WiFi này.
|
||||||
|
3. Mở trình duyệt truy cập **`http://192.168.4.1`**.
|
||||||
|
4. Trang web sẽ hiện:
|
||||||
|
- Nút **"Quét WiFi"** → chọn mạng nhà bạn.
|
||||||
|
- Nhập **Mật khẩu WiFi**.
|
||||||
|
- Kiểm tra/sửa **API URL** (IP máy chạy dashboard server + port 8320).
|
||||||
|
5. Nhấn **"Lưu và Kết nối"** → ESP32 restart, kết nối WiFi nhà bạn.
|
||||||
|
|
||||||
|
Sau khi đã có cấu hình:
|
||||||
|
- Truy cập **`http://<IP_ESP32_TRONG_MANG_LAN>`** để đổi API URL, đổi WiFi, hoặc Reset về AP Mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Giao diện màn hình (4 trang tự xoay 10s/lần)
|
||||||
|
|
||||||
|
| Trang | Nội dung |
|
||||||
|
|-------|----------|
|
||||||
|
| **0 – Total** | Tổng Requests, Failed, Total Tokens, Input / Output / Reasoning / Cached |
|
||||||
|
| **1 – Account** | Top 5 tài khoản: requests, tokens, failed |
|
||||||
|
| **2 – Model** | Top 6 model: requests, tokens, failed |
|
||||||
|
| **3 – Hourly** | 6 giờ gần nhất: requests, tokens |
|
||||||
|
|
||||||
|
- **Đèn RGB onboard (GPIO 48)**:
|
||||||
|
- 🔵 **Xanh dương** = fetch dữ liệu thành công
|
||||||
|
- 🟢 **Xanh lá** = chuyển trang hiển thị
|
||||||
|
- Nút **BOOT** (GPIO 0) trên board: *chưa gán chức năng trong code, dự trữ cho tương lai*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
| Vấn đề | Nguyên nhân / Khắc phục |
|
||||||
|
|--------|------------------------|
|
||||||
|
| Màn hình trắng / không sáng | Kiểm tra dây **BLK** (nối 3V3), kiểm tra `TFT_ROTATION` (thử 0–3), kiểm tra `init(240, 320)` vs `init(320, 240)`. |
|
||||||
|
| Kết nối WiFi thất bại -> rơi về AP | SSID/Pass sai, hoặc router chặn ESP32 (MAC filter, band 5 GHz – ESP32 chỉ 2.4 GHz). |
|
||||||
|
| Fetch lỗi ("Fetch loi! Kiem tra API URL") | API URL sai IP") | Kiểm tra IP server, port 8320, firewall, và `usage-dashboard.py` đang chạy (`start` hoặc `serve`). |
|
||||||
|
| JSON parse error | Đảm bảo server trả về đúng format `summary`, `accounts[]`, `models[]`, `hours[]` như code mong đợi. |
|
||||||
|
| Guru Meditation / Crash | Tăng stack size task `fetch_task` (hiện 8192) hoặc giảm `DynamicJsonDocument` size. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Cấu trúc thư mục
|
||||||
|
|
||||||
|
```
|
||||||
|
esp32-s3-cliproxy-dashboard/
|
||||||
|
├── esp32-s3-cliproxy-dashboard.ino # Firmware chính
|
||||||
|
├── README.md # File này
|
||||||
|
└── (các file build tự động)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
Copyright (c) 2026
|
||||||
|
[Tran Thanh Tan / TTAI Solutions Software]
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
No part of this software or its source code may be reproduced, distributed, or transmitted in any form or by any means, including photocopying, recording, or other electronic or mechanical methods, without the prior written permission of the copyright holder.
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user