시작은 우연히 알리에서 LVGL용 디스플레이를 발견했다. 아두이노 ESP32 기반에 2.8인치 디스플레이가 미리 만들어져있는(프리빌트) 그런 회로판인 것을 알고…
후딱 주문을~~

최근에 홈서버도 Proxmox로 재구성하기 시작했고, (비록 소소하지만 … ) 모니터링 할만큼의 부하가 오는건 아니지만, 그래도 궁금하달까…
뽀대가 있달까… +ㅁ+)b
결론적으로는 다음과 같은 화면이 만들어졌다.

엄청 삽질 하긴했지만 GPT넘의 도움으로 해결하긴했지만, 엄둥한 코드를 이것저것 던지는 바람에 더 해깔려서 오래 걸린거 같다.
아두이노 IDE 세팅까지는 물어봐도 될거 같고 핵심은 이녀석의 이름이다.
“싼 노란색 디스플레이” 즉, cheap yellow display 이걸로 구글에 검색하면 모든 설정 파일 및 이슈를 한번에 해결 할 수 있다. (참고: https://github.com/witnessmenow/ESP32-Cheap-Yellow-Display )
TFT_eSPI 설정 파일을 꼭 참조하도록 하자 …
그리고 PROXMOX의 API를 이용하는데 키를 생성하면 위 화면상의 값들을 RestAPI로 받을 수 있게된다.
postMan으로 미리보기 할 때 생성한 키를 Bearer Token에 세팅하면 끝…

난 그리는 함수 따위 모르니 GTP 녀석에게 부분 부분 케물어서 다음과 같은 소스 코드를 완성할 수 있다.
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#define ARRAY_SIZE(x) (sizeof(x) / sizeof((x)[0]))
TFT_eSPI tft = TFT_eSPI();
const char* ssid = "jusun";
const char* password = "와이파이비번";
const char* token = "Bearer PVEAPIToken=root@pam!LVGL=proxmox키이키키키";
void fnConnectWifi(){
WiFi.begin(ssid, password);
Serial.print("WiFi 연결 중");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi 연결 완료!");
Serial.print("IP 주소: ");
Serial.println(WiFi.localIP());
}
//공용변수
float g_cpu = 0;
uint64_t g_mem_used = 0;
uint64_t g_mem_total = 0;
unsigned long g_uptime = 0;
String g_loadavg1 = "";
String g_loadavg5 = "";
String g_loadavg15 = "";
void fnGetProxmoxStatus() {
if (WiFi.status() == WL_CONNECTED) {
WiFiClientSecure client;
client.setInsecure(); // 인증서 무시
HTTPClient https;
https.begin(client, "https://192.168.1.200:8006/api2/json/nodes/pve/status");
https.addHeader("Authorization", token);
int httpCode = https.GET();
if (httpCode > 0) {
String payload = https.getString();
Serial.println("응답:");
Serial.println(payload);
// JSON 파싱
StaticJsonDocument<2048> doc; // 크기 넉넉히 잡기
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
g_cpu = doc["data"]["cpu"].as<float>();
g_mem_used = doc["data"]["memory"]["used"].as<uint64_t>();
g_mem_total = doc["data"]["memory"]["total"].as<uint64_t>();
g_uptime = doc["data"]["uptime"].as<unsigned long>();
g_loadavg1 = doc["data"]["loadavg"][0].as<String>();
g_loadavg5 = doc["data"]["loadavg"][1].as<String>();
g_loadavg15 = doc["data"]["loadavg"][2].as<String>();
Serial.printf("CPU 사용률: %.2f%%\n", g_cpu * 100);
Serial.printf("메모리 사용량: %llu / %llu bytes\n", g_mem_used, g_mem_total);
Serial.printf("업타임: %lus\n", g_uptime);
} else {
Serial.print("JSON 파싱 실패: ");
Serial.println(error.c_str());
}
} else {
Serial.printf("HTTP 오류: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
}
}
struct VMInfo {
uint64_t vmid;
String name;
String status;
float cpu;
uint64_t mem;
uint64_t maxmem;
unsigned long uptime;
};
//qemu
VMInfo g_qemu_list[20];
int g_qemu_count = 0;
void fnGetProxmoxQEMUStatus() {
if (WiFi.status() == WL_CONNECTED) {
WiFiClientSecure client;
client.setInsecure(); // 인증서 무시
HTTPClient https;
https.begin(client, "https://192.168.1.200:8006/api2/json/nodes/pve/qemu");
https.addHeader("Authorization", token);
int httpCode = https.GET();
if (httpCode > 0) {
String payload = https.getString();
Serial.println("응답:");
Serial.println(payload);
// JSON 파싱
StaticJsonDocument<2048> doc; // 크기 넉넉히 잡기
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
JsonArray data = doc["data"];
g_qemu_count = min(20U, data.size());
for (int i = 0; i < g_qemu_count; i++) {
JsonObject vm = data[i];
g_qemu_list[i].vmid = vm["vmid"].as<uint64_t>();
g_qemu_list[i].name = vm["name"].as<String>();
g_qemu_list[i].status = vm["status"].as<String>();
g_qemu_list[i].cpu = vm["cpu"].as<float>();
g_qemu_list[i].mem = vm["mem"].as<uint64_t>();
g_qemu_list[i].maxmem = vm["maxmem"].as<uint64_t>();
g_qemu_list[i].uptime = vm["uptime"].as<unsigned long>();
}
} else {
Serial.print("JSON 파싱 실패: ");
Serial.println(error.c_str());
}
} else {
Serial.printf("HTTP 오류: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
}
}
//LXC
VMInfo g_lxc_list[20];
int g_lxc_count = 0;
void fnGetProxmoxLXCStatus() {
if (WiFi.status() == WL_CONNECTED) {
WiFiClientSecure client;
client.setInsecure(); // 인증서 무시
HTTPClient https;
https.begin(client, "https://192.168.1.200:8006/api2/json/nodes/pve/lxc");
https.addHeader("Authorization", token);
int httpCode = https.GET();
if (httpCode > 0) {
String payload = https.getString();
Serial.println("응답:");
Serial.println(payload);
// JSON 파싱
StaticJsonDocument<2048> doc; // 크기 넉넉히 잡기
DeserializationError error = deserializeJson(doc, payload);
if (!error) {
JsonArray data = doc["data"];
g_lxc_count = min(20U, data.size());
for (int i = 0; i < g_lxc_count; i++) {
JsonObject vm = data[i];
g_lxc_list[i].vmid = vm["vmid"].as<uint64_t>();
g_lxc_list[i].name = vm["name"].as<String>();
g_lxc_list[i].status = vm["status"].as<String>();
g_lxc_list[i].cpu = vm["cpu"].as<float>();
g_lxc_list[i].mem = vm["mem"].as<uint64_t>();
g_lxc_list[i].maxmem = vm["maxmem"].as<uint64_t>();
g_lxc_list[i].uptime = vm["uptime"].as<unsigned long>();
}
} else {
Serial.print("JSON 파싱 실패: ");
Serial.println(error.c_str());
}
} else {
Serial.printf("HTTP 오류: %s\n", https.errorToString(httpCode).c_str());
}
https.end();
}
}
void fnDrawScreen() {
static float last_cpu = -1;
static uint64_t last_mem_used = 0;
static uint64_t last_mem_total = 0;
static unsigned long last_uptime = 0;
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK); // 배경색 포함 덮어쓰기
if (g_cpu != last_cpu) {
tft.setCursor(6, 37);
tft.print("CPU: ");
tft.print(g_cpu * 100, 2);
tft.print("% ");
tft.setCursor(160, 40);
tft.setTextSize(1);
tft.print("Load: ");
tft.print(g_loadavg1);
tft.print(" ");
tft.print(g_loadavg5);
tft.print(" ");
tft.print(g_loadavg15);
last_cpu = g_cpu;
}
tft.setTextSize(2);
if (g_mem_used != last_mem_used || g_mem_total != last_mem_total) {
tft.setCursor(6, 57);
tft.printf("MEM: %llu / %llu MB ", g_mem_used / (1024 * 1024), g_mem_total / (1024 * 1024));
last_mem_used = g_mem_used;
last_mem_total = g_mem_total;
}
if (g_uptime != last_uptime) {
tft.setCursor(6, 77);
unsigned long days = g_uptime / 86400;
unsigned long hours = (g_uptime % 86400) / 3600;
unsigned long minutes = (g_uptime % 3600) / 60;
unsigned long seconds = g_uptime % 60;
tft.printf("UPT: %lud %02luh %02lum %02lus ", days, hours, minutes, seconds);
last_uptime = g_uptime;
}
}
//qemu list
void drawQemuList(){
int y = 125; // 시작 위치
tft.setTextSize(1);
int total_vm_slots = ARRAY_SIZE(g_qemu_list);
std::sort(g_qemu_list, g_qemu_list + g_qemu_count, [](const VMInfo &a, const VMInfo &b) {
return a.vmid < b.vmid;
});
for (int i = 0; i < total_vm_slots; i++) {
if(g_qemu_list[i].name.length() == 0){
continue;
}
tft.setCursor(4, y);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.print("[");
tft.print(g_qemu_list[i].name.c_str());
tft.print("] ");
if(g_qemu_list[i].status == "running"){
tft.print(g_qemu_list[i].cpu * 100);
}else{
tft.print("0.00");
}
tft.print("% ");
tft.print(" ");
//tft.print("/");
//tft.print(g_qemu_list[i].mem / 1024.0 / 1024.0);
if(g_qemu_list[i].status == "running"){
tft.fillCircle(144, y+3, 5, TFT_GREEN);
}else{
tft.fillCircle(144, y+3, 5, TFT_RED);
}
/*
tft.print(g_cpu * 100, 2);
tft.print("% ");
tft.printf("[%s] %s | CPU: %.1f%% | MEM: %.1fMB ",
g_qemu_list[i].name.c_str(),
g_qemu_list[i].status == "running" ? "ON " : "OFF",
g_qemu_list[i].cpu * 100,
g_qemu_list[i].mem / 1024.0 / 1024.0
);
*/
y += 16;
}
}
//qemu list
void drawLXCList(){
int y = 125; // 시작 위치
tft.setTextSize(1);
int total_vm_slots = ARRAY_SIZE(g_lxc_list);
std::sort(g_lxc_list, g_lxc_list + g_lxc_count, [](const VMInfo &a, const VMInfo &b) {
return a.vmid < b.vmid;
});
for (int i = 0; i < total_vm_slots; i++) {
if(g_lxc_list[i].name.length() == 0){
continue;
}
tft.setCursor(164, y);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.print("[");
tft.print(g_lxc_list[i].name.c_str());
tft.print("] ");
if(g_lxc_list[i].status == "running"){
tft.print(g_lxc_list[i].cpu * 100);
}else{
tft.print("0.00");
}
tft.print("% ");
tft.print(" ");
//tft.print("/");
//tft.print(g_qemu_list[i].mem / 1024.0 / 1024.0);
if(g_lxc_list[i].status == "running"){
tft.fillCircle(305, y+3, 5, TFT_GREEN);
}else{
tft.fillCircle(305, y+3, 5, TFT_RED);
}
y += 16;
}
}
void drawLayoutLine(){
tft.fillRect(0, 0, tft.width(), tft.height(), TFT_BLACK); //전체 배경
tft.fillRect(0, 0, tft.width(), 32, TFT_BLUE); //상단 배경
tft.drawLine(0,32,tft.width(),32,TFT_WHITE); //상단줄
tft.drawLine(0,32,tft.width(),32,TFT_WHITE); //상단줄
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLUE); // 배경색 포함 덮어쓰기
tft.setCursor(6, 10);
tft.printf("Jusun Lab. Proxmox Monitor");
tft.fillRect(0, 97, tft.width(), 117-97, TFT_BLUE); //중단 배경
tft.drawLine(0,97,tft.width(),97,TFT_WHITE); //중간줄
tft.setTextSize(1);
tft.setCursor(70, 104);
tft.printf("QEMU");
tft.setCursor(230, 104);
tft.printf("LXC");
tft.drawLine(0,117,tft.width(),117,TFT_WHITE); //중간줄2
tft.drawLine(tft.width()/2,97,tft.width()/2,tft.height(),TFT_WHITE); //세로줄
tft.drawRect(0, 0, tft.width(), tft.height(), TFT_WHITE); //틀
}
void setup() {
Serial.begin(115200);
//wifi Connect
fnConnectWifi();
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, HIGH); // 백라이트 켜기(중요!)
// Start the tft display and set it to black
tft.init();
tft.setRotation(1); //This is the display in landscape
drawLayoutLine();
}
void loop() {
static unsigned long lastRequest = 0;
if (millis() - lastRequest > 1000) {
fnGetProxmoxStatus();
fnGetProxmoxQEMUStatus();
fnGetProxmoxLXCStatus();
lastRequest = millis();
fnDrawScreen();
drawQemuList();
drawLXCList();
}
//delay(200);
}
Code language: PHP (php)
비워진 부분은 체워서 가동해보면 문제 없다면, 잘 작동할 것이다.
proxmox API 주소도 자신의 네트워크에 맞는걸로… 1초에 한번씩 고치는데 … 이걸 통해서 부하기 크게 생기진 않는것 같으니 적당히 고쳐서 쓰도록 하자 -ㅅ-)
할사람이 있을지 모르겠지만….
난 이 디스플레이가 너무 마음에 들어서 가지고 놀려고 하나더 주문함..
굳럭!