Proxmox VE 모니터링용 디스플레이

시작은 우연히 알리에서 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초에 한번씩 고치는데 … 이걸 통해서 부하기 크게 생기진 않는것 같으니 적당히 고쳐서 쓰도록 하자 -ㅅ-)

할사람이 있을지 모르겠지만….

난 이 디스플레이가 너무 마음에 들어서 가지고 놀려고 하나더 주문함..

굳럭!

관련 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다