CYBERMON WiFi CODE

ESP32 + 1.9" ST7789 TFT // Wireless UDP + Captive Portal

MONITORING MODE

STANDBY MODE

CYBERMON WiFi SKETCH

cybermon_19inch_wifi.ino
/*
  ╔═══════════════════════════════════════╗
  ║  C Y B E R M O N   1.9"  WiFi      ║
  ║  ST7789 170x320 TFT (Portrait)      ║
  ║  ESP32 + Adafruit_ST7789            ║
  ║  Receives data via UDP broadcast    ║
  ╚═══════════════════════════════════════╝
*/

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <time.h>
#include <SPI.h>

// ══════════════ DISPLAY PINS ══════════════
#define LCD_MOSI 23
#define LCD_SCLK 18
#define LCD_CS   15
#define LCD_DC    2
#define LCD_RST   4
#define LCD_BLK  32

// ══════════════ DISPLAY ══════════════
Adafruit_ST7789 tft = Adafruit_ST7789(LCD_CS, LCD_DC, LCD_RST);

#define SW 170
#define SH 320

// ══════════════ UDP ══════════════
#define UDP_PORT_DEFAULT 5555
int udpPort = UDP_PORT_DEFAULT;
WiFiUDP udp;

// ══════════════ WIFI CONFIG PORTAL ══════════════
#define AP_NAME "CyberMon-Setup"
Preferences prefs;
WebServer *configServer = NULL;
DNSServer *dnsServer = NULL;
bool portalActive = false;
String savedSSID = "";
String savedPass = "";

// ══════════════ CYBERPUNK PALETTE ══════════════
#define NEON_PURPLE   0xA01F
#define NEON_BLUE     0x249F
#define NEON_CYAN     0x07FF
#define NEON_GREEN    0x47E9
#define NEON_PINK     0xF81F
#define NEON_ORANGE   0xFD20
#define NEON_RED      0xF800

#define BG_DARK       0x0821
#define BG_PANEL      0x1083
#define BORDER_COLOR  0x3186
#define GRID_COLOR    0x1082

#define TXT_BRIGHT    0xFFFF
#define TXT_MED       0xAD75
#define TXT_DIM       0x6B6D
#define TXT_DARK      0x39E7

// ══════════════ SENSOR DATA ══════════════
struct SensorData {
  int cpuTemp, cpuFan, cpuLoad, cpuPower;
  int gpuTemp, gpuFan, gpuLoad;
  int gpuPower, gpuVramUsed, gpuVramTotal;
  int ramUsed, ramTotal;
  int uptime;
  int cpuClock, gpuClock;
  int netDown, netUp;
};

SensorData cur = {0}, prev = {0};
bool dataReceived = false;
bool firstUpdate = true;
int lastUpdate = 0;
bool standbyActive = false;

char cpuShortName[16] = "CPU";
char gpuShortName[16] = "GPU";
char boardShortName[16] = "BOARD";

char prevTimeStr[6] = "";
char prevDateStr[12] = "";

// ══════════════ ARC GAUGE CONSTANTS ══════════════
#define ARC_R        26
#define ARC_THICK     5
#define ARC_START   135.0f
#define ARC_END     405.0f

#define CPU_ARC_Y     68
#define GPU_ARC_Y    210
#define ARC_LEFT_CX   44
#define ARC_RIGHT_CX 126
#define ARC_CENTER_CX 85

// ══════════════ DRAWING HELPERS ══════════════

uint16_t tempColor(int t) {
  if (t < 50) return NEON_GREEN;
  if (t < 65) return NEON_BLUE;
  if (t < 73) return NEON_ORANGE;
  return NEON_RED;
}

uint16_t loadColor(int l) {
  if (l < 40) return NEON_GREEN;
  if (l < 70) return NEON_CYAN;
  if (l < 85) return NEON_ORANGE;
  return NEON_RED;
}

void fillArc(int cx, int cy, int r, int thickness, float startDeg, float endDeg, uint16_t color) {
  float rMid = r - thickness / 2.0f;
  int dotR = thickness / 2;
  float step = 2.0f;
  for (float a = startDeg; a <= endDeg; a += step) {
    float rad = (a - 90.0f) * DEG_TO_RAD;
    int x = cx + (int)(cosf(rad) * rMid);
    int y = cy + (int)(sinf(rad) * rMid);
    tft.fillCircle(x, y, dotR, color);
  }
}

void drawArcTicks(int cx, int cy, int r, float startDeg, float endDeg, int count, uint16_t color) {
  float range = endDeg - startDeg;
  for (int i = 0; i <= count; i++) {
    float deg = startDeg + (range * i / count);
    float rad = (deg - 90.0f) * DEG_TO_RAD;
    float ca = cosf(rad), sa = sinf(rad);
    int x1 = cx + (int)(ca * (r + 1));
    int y1 = cy + (int)(sa * (r + 1));
    int x2 = cx + (int)(ca * (r + 4));
    int y2 = cy + (int)(sa * (r + 4));
    tft.drawLine(x1, y1, x2, y2, color);
  }
}

void drawGlowDot(int cx, int cy, int r, int thickness, float angleDeg, uint16_t color) {
  float rMid = r - thickness / 2.0f;
  float rad = (angleDeg - 90.0f) * DEG_TO_RAD;
  int dx = cx + (int)(cosf(rad) * rMid);
  int dy = cy + (int)(sinf(rad) * rMid);
  tft.fillCircle(dx, dy, 4, color);
  tft.fillCircle(dx, dy, 2, TXT_BRIGHT);
}

void drawArcGauge(int cx, int cy, int value, int maxVal, uint16_t color,
                  const char* label, bool showDegree) {
  tft.fillRect(cx - ARC_R - 6, cy - ARC_R - 6, ARC_R * 2 + 12, ARC_R * 2 + 20, BG_DARK);
  drawArcTicks(cx, cy, ARC_R, ARC_START, ARC_END, 10, TXT_DARK);
  fillArc(cx, cy, ARC_R, ARC_THICK, ARC_START, ARC_END, 0x1082);

  float pct = constrain(value, 0, maxVal) / (float)maxVal;
  float endA = ARC_START + pct * (ARC_END - ARC_START);
  if (endA > ARC_START + 2) {
    fillArc(cx, cy, ARC_R, ARC_THICK, ARC_START, endA, color);
    if (endA > ARC_START + 20) {
      fillArc(cx, cy, ARC_R - 1, 3, endA - 15, endA, TXT_BRIGHT);
    }
    drawGlowDot(cx, cy, ARC_R, ARC_THICK, endA, color);
  }

  char buf[8];
  snprintf(buf, sizeof(buf), "%d", value);
  int len = strlen(buf);
  tft.setTextSize(2);
  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(cx - len * 6, cy - 7);
  tft.print(buf);

  if (showDegree) {
    tft.setTextSize(1);
    tft.setTextColor(color);
    tft.setCursor(cx + len * 6 + 2, cy - 7);
    tft.print("o");
  } else {
    tft.setTextSize(1);
    tft.setTextColor(color);
    tft.setCursor(cx + len * 6 + 2, cy - 3);
    tft.print("%");
  }

  tft.setTextSize(1);
  tft.setTextColor(color);
  int lw = strlen(label) * 6;
  tft.setCursor(cx - lw / 2, cy + ARC_R + 6);
  tft.print(label);
}

void drawNeonBar(int x, int y, int w, int h, float pct, uint16_t color) {
  tft.fillRoundRect(x, y, w, h, h / 2, 0x0841);
  int fw = (int)(w * pct);
  if (fw > h) {
    tft.fillRoundRect(x, y, fw, h, h / 2, color);
  } else if (fw > 2) {
    tft.fillCircle(x + h / 2, y + h / 2, h / 2, color);
  }
}

// ══════════════ BACKGROUND ══════════════

void drawCyberpunkBG() {
  tft.fillScreen(BG_DARK);
  for (int y = 0; y < SH; y += 40) tft.drawFastHLine(0, y, SW, GRID_COLOR);
  for (int x = 0; x < SW; x += 20) tft.drawFastVLine(x, 0, SH, GRID_COLOR);
}

// ══════════════ HEADER ══════════════

void drawHeader() {
  tft.fillRect(0, 0, SW, 20, BG_PANEL);
  tft.drawFastHLine(0, 20, SW, NEON_PURPLE);
  tft.setTextSize(1);
  tft.setTextColor(NEON_PURPLE);
  tft.setCursor(4, 3);
  tft.print("CYBER");
  tft.setTextColor(NEON_GREEN);
  tft.print("MON");

  // WiFi indicator instead of wired dot
  tft.setTextColor(NEON_CYAN);
  tft.setCursor(SW - 24, 3);
  tft.print("WiFi");

  tft.setTextColor(TXT_DARK);
  tft.setCursor(56, 7);
  tft.print(boardShortName);
}

// ══════════════ CPU SECTION ══════════════

void drawCPUStatic() {
  tft.setTextSize(1);
  tft.setTextColor(NEON_PURPLE);
  tft.setCursor(4, 24);
  tft.print("CPU");
  tft.setTextColor(TXT_DIM);
  tft.setCursor(24, 24);
  tft.print(cpuShortName);
  tft.drawFastHLine(4, 34, SW - 8, BORDER_COLOR);
}

void updateCPUSection() {
  char buf[24];

  if (cur.cpuTemp > 0) {
    if (firstUpdate || cur.cpuTemp != prev.cpuTemp)
      drawArcGauge(ARC_LEFT_CX, CPU_ARC_Y, cur.cpuTemp, 100, tempColor(cur.cpuTemp), "TEMP", true);
    if (firstUpdate || cur.cpuLoad != prev.cpuLoad)
      drawArcGauge(ARC_RIGHT_CX, CPU_ARC_Y, cur.cpuLoad, 100, loadColor(cur.cpuLoad), "LOAD", false);
  } else {
    if (firstUpdate || cur.cpuLoad != prev.cpuLoad)
      drawArcGauge(ARC_CENTER_CX, CPU_ARC_Y, cur.cpuLoad, 100, loadColor(cur.cpuLoad), "LOAD", false);
  }

  if (cur.cpuFan != prev.cpuFan || cur.cpuPower != prev.cpuPower) {
    tft.fillRect(0, 108, SW, 12, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(NEON_PURPLE);
    tft.setCursor(4, 109);
    snprintf(buf, sizeof(buf), "FAN %d", cur.cpuFan);
    tft.print(buf);
    tft.setTextColor(TXT_DIM);
    tft.print("rpm");
    tft.setTextColor(NEON_ORANGE);
    tft.setCursor(108, 109);
    snprintf(buf, sizeof(buf), "%dW", cur.cpuPower);
    tft.print(buf);
  }

  if (cur.cpuClock != prev.cpuClock) {
    tft.fillRect(0, 122, SW, 12, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(NEON_BLUE);
    tft.setCursor(4, 123);
    if (cur.cpuClock >= 1000)
      snprintf(buf, sizeof(buf), "CLK %.2fGHz", cur.cpuClock / 1000.0f);
    else
      snprintf(buf, sizeof(buf), "CLK %dMHz", cur.cpuClock);
    tft.print(buf);
  }

  if (cur.ramUsed != prev.ramUsed || cur.ramTotal != prev.ramTotal) {
    tft.fillRect(0, 136, SW, 20, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(TXT_DIM);
    tft.setCursor(4, 136);
    tft.print("RAM");
    tft.setTextColor(TXT_BRIGHT);
    tft.setCursor(28, 136);
    if (cur.ramTotal > 0)
      snprintf(buf, sizeof(buf), "%.1f/%.0fGB", cur.ramUsed / 1024.0f, cur.ramTotal / 1024.0f);
    else
      snprintf(buf, sizeof(buf), "---");
    tft.print(buf);
    float pct = (cur.ramTotal > 0) ? constrain(cur.ramUsed, 0, cur.ramTotal) / (float)cur.ramTotal : 0;
    drawNeonBar(4, 148, SW - 8, 5, pct, NEON_BLUE);
  }
}

// ══════════════ SEPARATOR ══════════════

void drawSeparator() {
  tft.drawFastHLine(10, 160, SW - 20, NEON_PURPLE);
  tft.drawFastHLine(10, 161, SW - 20, BORDER_COLOR);
}

// ══════════════ GPU SECTION ══════════════

void drawGPUStatic() {
  tft.setTextSize(1);
  tft.setTextColor(NEON_GREEN);
  tft.setCursor(4, 166);
  tft.print("GPU");
  tft.setTextColor(TXT_DIM);
  tft.setCursor(24, 166);
  tft.print(gpuShortName);
  tft.drawFastHLine(4, 176, SW - 8, BORDER_COLOR);
}

void updateGPUSection() {
  char buf[24];

  if (cur.gpuTemp > 0) {
    if (firstUpdate || cur.gpuTemp != prev.gpuTemp)
      drawArcGauge(ARC_LEFT_CX, GPU_ARC_Y, cur.gpuTemp, 100, tempColor(cur.gpuTemp), "TEMP", true);
    if (firstUpdate || cur.gpuLoad != prev.gpuLoad)
      drawArcGauge(ARC_RIGHT_CX, GPU_ARC_Y, cur.gpuLoad, 100, loadColor(cur.gpuLoad), "LOAD", false);
  } else {
    if (firstUpdate || cur.gpuLoad != prev.gpuLoad)
      drawArcGauge(ARC_CENTER_CX, GPU_ARC_Y, cur.gpuLoad, 100, loadColor(cur.gpuLoad), "LOAD", false);
  }

  if (cur.gpuFan != prev.gpuFan || cur.gpuPower != prev.gpuPower) {
    tft.fillRect(0, 244, SW, 12, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(NEON_GREEN);
    tft.setCursor(4, 245);
    snprintf(buf, sizeof(buf), "FAN %d", cur.gpuFan);
    tft.print(buf);
    tft.setTextColor(TXT_DIM);
    tft.print("rpm");
    tft.setTextColor(NEON_PINK);
    tft.setCursor(108, 245);
    snprintf(buf, sizeof(buf), "%dW", cur.gpuPower);
    tft.print(buf);
  }

  if (cur.gpuClock != prev.gpuClock) {
    tft.fillRect(0, 258, SW, 12, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(NEON_CYAN);
    tft.setCursor(4, 259);
    if (cur.gpuClock >= 1000)
      snprintf(buf, sizeof(buf), "CLK %.2fGHz", cur.gpuClock / 1000.0f);
    else
      snprintf(buf, sizeof(buf), "CLK %dMHz", cur.gpuClock);
    tft.print(buf);
  }

  if (cur.gpuVramUsed != prev.gpuVramUsed || cur.gpuVramTotal != prev.gpuVramTotal) {
    tft.fillRect(0, 272, SW, 20, BG_DARK);
    tft.setTextSize(1);
    tft.setTextColor(TXT_DIM);
    tft.setCursor(4, 272);
    tft.print("VRAM");
    tft.setTextColor(TXT_BRIGHT);
    tft.setCursor(32, 272);
    if (cur.gpuVramTotal > 0)
      snprintf(buf, sizeof(buf), "%.1f/%.0fGB", cur.gpuVramUsed / 1024.0f, cur.gpuVramTotal / 1024.0f);
    else
      snprintf(buf, sizeof(buf), "---");
    tft.print(buf);
    float pct = (cur.gpuVramTotal > 0) ? constrain(cur.gpuVramUsed, 0, cur.gpuVramTotal) / (float)cur.gpuVramTotal : 0;
    drawNeonBar(4, 284, SW - 8, 5, pct, NEON_CYAN);
  }
}

// ══════════════ BOTTOM BAR ══════════════

void updateBottomBar() {
  char buf[24];
  int y = 292;
  tft.fillRect(0, y, SW, SH - y, BG_PANEL);
  tft.drawFastHLine(0, y, SW, BORDER_COLOR);

  tft.setTextSize(1);
  tft.setTextColor(NEON_PURPLE);
  tft.setCursor(4, y + 4);
  tft.print("UP");
  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(20, y + 4);
  { int h = cur.uptime / 3600; int m = (cur.uptime % 3600) / 60;
    snprintf(buf, sizeof(buf), "%dh%02dm", h, m); }
  tft.print(buf);

  tft.setTextColor(NEON_CYAN);
  tft.setCursor(4, y + 14);
  tft.print("NET");
  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(24, y + 14);
  if (cur.netDown >= 1024 || cur.netUp >= 1024)
    snprintf(buf, sizeof(buf), "%.1f/%.1fM", cur.netDown / 1024.0f, cur.netUp / 1024.0f);
  else
    snprintf(buf, sizeof(buf), "%d/%dK", cur.netDown, cur.netUp);
  tft.print(buf);
}

// ══════════════ STATIC UI ══════════════

void drawStaticUI() {
  drawCyberpunkBG();
  drawHeader();
  drawCPUStatic();
  drawSeparator();
  drawGPUStatic();
}

void updateDisplay() {
  updateCPUSection();
  updateGPUSection();
  updateBottomBar();
  prev = cur;
  firstUpdate = false;
}

// ══════════════ WAITING / STANDBY ══════════════

void drawWaiting() {
  drawCyberpunkBG();
  tft.setTextSize(2);
  tft.setTextColor(NEON_PURPLE);
  tft.setCursor(11, 120);
  tft.print("CYBER");
  tft.setTextColor(NEON_GREEN);
  tft.print("MON");

  tft.drawFastHLine(20, 140, 60, NEON_PURPLE);
  tft.drawFastHLine(80, 140, 70, NEON_GREEN);
  tft.fillCircle(150, 140, 3, NEON_GREEN);

  tft.setTextSize(1);
  tft.setTextColor(NEON_CYAN);
  tft.setCursor(52, 155);
  tft.print("WiFi Mode");

  tft.setTextColor(TXT_MED);
  tft.setCursor(22, 175);
  tft.print("Listening on UDP");
  char portBuf[16];
  snprintf(portBuf, sizeof(portBuf), "port %d", udpPort);
  int pw = strlen(portBuf) * 6;
  tft.setCursor((SW - pw) / 2, 187);
  tft.print(portBuf);

  tft.setTextColor(TXT_DARK);
  tft.setCursor(10, 207);
  tft.print("Run CyberMon WiFi");
  tft.setCursor(34, 219);
  tft.print("on your PC");

  tft.drawRect(8, 112, SW - 16, 120, BORDER_COLOR);
}

// ══════════════ TERMINAL CLOCK PALETTE ══════════════
#define T_BG        0x0000
#define T_GRID_CLK  0x0120
#define T_DIM_CLK   0x02A4
#define T_MED_CLK   0x0569
#define T_BRIGHT_CLK 0x07E0
#define T_GLOW      0x07F4
#define T_ACCENT    0x04FF
#define T_FAINT     0x0182

// Terminal clock state
static int lastClockSec = -1;
static uint32_t clockFrame = 0;
static int waveform[80];
static int waveIdx = 0;
static bool clockBGDrawn = false;

void drawClockBG() {
  tft.fillScreen(T_BG);

  // Grid
  for (int y = 0; y < SH; y += 16) tft.drawFastHLine(0, y, SW, T_GRID_CLK);
  for (int x = 0; x < SW; x += 16) tft.drawFastVLine(x, 0, SH, T_GRID_CLK);

  // Top accent bar
  tft.fillRect(0, 0, SW, 2, T_ACCENT);
  tft.fillRect(0, 2, SW, 16, T_BG);
  tft.drawFastHLine(0, 18, SW, T_MED_CLK);

  // Header
  tft.setTextSize(1);
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(4, 4);
  tft.print("CYBER");
  tft.setTextColor(T_BRIGHT_CLK);
  tft.print("MON");
  tft.setTextColor(T_ACCENT);
  tft.setCursor(100, 4);
  tft.print("STANDBY");

  // Waveform box
  tft.drawRect(4, 20, SW - 8, 48, T_DIM_CLK);
  tft.setTextSize(1);
  tft.setTextColor(T_MED_CLK);
  tft.setCursor(8, 23);
  tft.print("SIGNAL");
  tft.drawFastHLine(8, 32, 40, T_DIM_CLK);
  tft.drawFastHLine(8, 46, SW - 16, T_FAINT);

  // WiFi info box
  tft.drawRect(4, 72, SW - 8, 40, T_DIM_CLK);
  tft.setTextSize(1);
  tft.setTextColor(T_MED_CLK);
  tft.setCursor(8, 75);
  tft.print("CONNECTION");
  tft.drawFastHLine(8, 84, 64, T_DIM_CLK);
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(8, 88);
  tft.print("MODE ....... WiFi");
  tft.setCursor(8, 98);
  // Show RSSI and IP
  char wifiBuf[28];
  if (WiFi.status() == WL_CONNECTED) {
    snprintf(wifiBuf, sizeof(wifiBuf), "RSSI .. %ddBm", WiFi.RSSI());
    tft.print(wifiBuf);
    tft.setTextColor(T_FAINT);
    tft.setCursor(8, 108);  // will be outside box but visible
  } else {
    tft.print("RSSI .. N/A");
  }

  // System status box
  tft.drawRect(4, 192, SW - 8, 96, T_DIM_CLK);
  tft.setTextSize(1);
  tft.setTextColor(T_MED_CLK);
  tft.setCursor(8, 195);
  tft.print("SYSTEM STATUS");
  tft.drawFastHLine(8, 204, 80, T_DIM_CLK);
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(8, 210);  tft.print("CPU TEMP ..... IDLE");
  tft.setCursor(8, 222);  tft.print("GPU TEMP ..... IDLE");
  tft.setCursor(8, 234);  tft.print("WiFi ......");
  if (WiFi.status() == WL_CONNECTED) {
    tft.setTextColor(T_ACCENT);
    tft.print(" ONLINE");
  } else {
    tft.setTextColor(T_DIM_CLK);
    tft.print(" OFFLINE");
  }
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(8, 246);  tft.print("DATALINK  STANDBY");
  tft.setCursor(8, 258);  tft.print("NTP ......");
  tft.setTextColor(T_ACCENT);
  tft.print(" ACTIVE");
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(8, 270);  tft.print("UPTIME ...");

  // Bottom bar
  tft.drawFastHLine(0, 296, SW, T_MED_CLK);
  tft.fillRect(0, 297, SW, 23, T_BG);
  tft.drawFastHLine(0, 318, SW, T_ACCENT);
  tft.setTextSize(1);
  tft.setTextColor(T_DIM_CLK);
  tft.setCursor(4, 300);
  tft.print("AWAITING DATALINK");
  tft.setTextColor(T_FAINT);
  tft.setCursor(4, 310);
  char hwBuf[24];
  snprintf(hwBuf, sizeof(hwBuf), "%s // %s", cpuShortName, gpuShortName);
  tft.print(hwBuf);

  for (int i = 0; i < 80; i++) waveform[i] = 0;
  waveIdx = 0;
  clockBGDrawn = true;
}

void drawClockTime(struct tm* t) {
  int cx = SW / 2;
  int cy = 145;

  tft.fillRect(8, 116, SW - 16, 66, T_BG);
  for (int y = 116; y < 182; y += 16) tft.drawFastHLine(8, y, SW - 16, T_GRID_CLK);
  for (int x = 16; x < SW; x += 16) tft.drawFastVLine(x, 116, 66, T_GRID_CLK);

  tft.drawFastHLine(10, 118, 12, T_ACCENT);
  tft.drawFastVLine(10, 118, 12, T_ACCENT);
  tft.drawFastHLine(SW - 22, 118, 12, T_ACCENT);
  tft.drawFastVLine(SW - 11, 118, 12, T_ACCENT);
  tft.drawFastHLine(10, 178, 12, T_ACCENT);
  tft.drawFastVLine(10, 167, 12, T_ACCENT);
  tft.drawFastHLine(SW - 22, 178, 12, T_ACCENT);
  tft.drawFastVLine(SW - 11, 167, 12, T_ACCENT);

  // Large time HH:MM:SS
  char buf[12];
  snprintf(buf, sizeof(buf), "%02d:%02d:%02d", t->tm_hour, t->tm_min, t->tm_sec);
  tft.setTextSize(3);
  tft.setTextColor(T_GLOW);
  int tw = strlen(buf) * 18;
  tft.setCursor(cx - tw / 2, cy - 10);
  tft.print(buf);

  // Glow line under time
  tft.drawFastHLine(cx - tw / 2, cy + 16, tw, T_DIM_CLK);

  // Date: 22 MAR 2026
  static const char* MONTHS[] = {"JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"};
  char datebuf[16];
  snprintf(datebuf, sizeof(datebuf), "%02d %s %d", t->tm_mday, MONTHS[t->tm_mon], t->tm_year + 1900);
  tft.setTextSize(1);
  tft.setTextColor(T_MED_CLK);
  int dw = strlen(datebuf) * 6;
  tft.setCursor(cx - dw / 2, cy + 22);
  tft.print(datebuf);

  // Day of week
  static const char* DAYS[] = {"SUNDAY","MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY"};
  tft.setTextColor(T_DIM_CLK);
  int dayW = strlen(DAYS[t->tm_wday]) * 6;
  tft.setCursor(cx - dayW / 2, cy + 34);
  tft.print(DAYS[t->tm_wday]);
}

void clockAnimate() {
  // Pulse top accent bar
  if ((clockFrame / 10) % 2 == 0)
    tft.drawFastHLine(0, 0, SW, T_ACCENT);
  else
    tft.drawFastHLine(0, 0, SW, T_MED_CLK);

  // Waveform animation
  int waveW = SW - 20;
  int wx = 8 + (waveIdx % waveW);

  tft.drawFastVLine(wx, 34, 30, T_BG);
  if (wx % 16 == 0) tft.drawFastVLine(wx, 34, 30, T_GRID_CLK);
  tft.drawPixel(wx, 46, T_FAINT);

  float phase = clockFrame * 0.1f;
  int sample = (int)(sinf(phase + wx * 0.06f) * 10.0f);
  if (random(25) == 0) sample += random(-12, 12);
  sample = constrain(sample, -12, 12);

  int py = 46 - sample;
  tft.drawFastVLine(wx, min(py, 46), abs(sample) + 1, T_BRIGHT_CLK);
  tft.drawPixel(wx, py, T_GLOW);

  int curX = 8 + ((waveIdx + 1) % waveW);
  tft.drawFastVLine(curX, 34, 30, T_MED_CLK);

  waveIdx++;

  // Uptime counter
  if (clockFrame % 20 == 0) {
    int secs = millis() / 1000;
    int h = secs / 3600;
    int m = (secs % 3600) / 60;
    int s = secs % 60;
    char uptxt[14];
    snprintf(uptxt, sizeof(uptxt), " %02d:%02d:%02d", h, m, s);
    tft.fillRect(68, 270, 90, 8, T_BG);
    tft.setTextSize(1);
    tft.setTextColor(T_DIM_CLK);
    tft.setCursor(68, 270);
    tft.print(uptxt);
  }
}

void drawStandbyClock() {
  if (!clockBGDrawn) {
    lastClockSec = -1;
    clockFrame = 0;
    randomSeed(analogRead(0) + millis());
    drawClockBG();
  }

  // 20 FPS cap
  static int lastFrameMs = 0;
  int now = millis();
  if (now - lastFrameMs < 50) return;
  lastFrameMs = now;

  struct tm ti;
  if (!getLocalTime(&ti, 0)) {
    clockFrame++;
    return;
  }

  // Animate
  clockAnimate();

  // Redraw time when seconds change
  if (ti.tm_sec != lastClockSec) {
    drawClockTime(&ti);
    lastClockSec = ti.tm_sec;
  }

  clockFrame++;
}

// ══════════════ WIFI CONFIG PORTAL ══════════════

const char CONFIG_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{font-family:sans-serif;background:#0a0a1a;color:#0ff;text-align:center;padding:20px}
h1{color:#a0f;font-size:24px}
input{width:80%;padding:12px;margin:8px 0;border:1px solid #0ff;background:#111;color:#0ff;font-size:16px;border-radius:4px}
button{width:80%;padding:14px;margin:16px 0;background:#a0f;color:#fff;border:none;font-size:18px;border-radius:4px;cursor:pointer}
button:hover{background:#c0f}
.ok{color:#0f0;font-size:20px}
</style></head><body>
<h1>CyberMon WiFi Setup</h1>
<form action="/save" method="POST">
<input name="ssid" placeholder="WiFi Network Name" required><br>
<input name="pass" type="password" placeholder="WiFi Password"><br>
<input name="port" type="number" placeholder="UDP Port (default 5555)" min="1024" max="65535"><br>
<button type="submit">Connect</button>
</form></body></html>
)rawliteral";

const char SAVED_PAGE[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{font-family:sans-serif;background:#0a0a1a;color:#0ff;text-align:center;padding:40px}
.ok{color:#0f0;font-size:24px}
</style></head><body>
<p class="ok">WiFi credentials saved!</p>
<p>CyberMon will now connect to your network.</p>
<p>This page will close automatically.</p>
</body></html>
)rawliteral";

void drawConfigPortal() {
  drawCyberpunkBG();
  tft.setTextSize(2);
  tft.setTextColor(NEON_PURPLE);
  tft.setCursor(11, 100);
  tft.print("CYBER");
  tft.setTextColor(NEON_GREEN);
  tft.print("MON");

  tft.setTextSize(1);
  tft.setTextColor(NEON_CYAN);
  tft.setCursor(28, 130);
  tft.print("WiFi Setup Mode");

  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(10, 160);
  tft.print("1. Connect to WiFi:");
  tft.setTextColor(NEON_GREEN);
  tft.setCursor(22, 175);
  tft.print("CyberMon-Setup");

  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(10, 200);
  tft.print("2. Open browser to:");
  tft.setTextColor(NEON_GREEN);
  tft.setCursor(22, 215);
  tft.print("http://192.168.4.1");

  tft.setTextColor(TXT_BRIGHT);
  tft.setCursor(10, 240);
  tft.print("3. Enter your WiFi");
  tft.setCursor(22, 255);
  tft.print("network credentials");

  tft.drawRect(8, 92, SW - 16, 180, BORDER_COLOR);
}

void startConfigPortal() {
  portalActive = true;

  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_NAME);
  delay(500);

  dnsServer = new DNSServer();
  dnsServer->start(53, "*", WiFi.softAPIP());

  configServer = new WebServer(80);

  configServer->on("/", HTTP_GET, []() {
    configServer->send(200, "text/html", CONFIG_PAGE);
  });

  configServer->on("/save", HTTP_POST, []() {
    savedSSID = configServer->arg("ssid");
    savedPass = configServer->arg("pass");
    String portStr = configServer->arg("port");
    if (portStr.length() > 0) {
      udpPort = portStr.toInt();
      if (udpPort < 1024 || udpPort > 65535) udpPort = UDP_PORT_DEFAULT;
    }

    // Save to flash
    prefs.begin("cybermon", false);
    prefs.putString("ssid", savedSSID);
    prefs.putString("pass", savedPass);
    prefs.putInt("port", udpPort);
    prefs.end();

    configServer->send(200, "text/html", SAVED_PAGE);
    delay(2000);

    // Restart to connect with new creds
    ESP.restart();
  });

  // Captive portal: redirect all requests to config page
  configServer->onNotFound([]() {
    configServer->sendHeader("Location", "http://192.168.4.1/");
    configServer->send(302, "text/plain", "");
  });

  configServer->begin();
  drawConfigPortal();
}

bool connectWiFi() {
  prefs.begin("cybermon", true);
  savedSSID = prefs.getString("ssid", "");
  savedPass = prefs.getString("pass", "");
  udpPort = prefs.getInt("port", UDP_PORT_DEFAULT);
  prefs.end();

  if (savedSSID.length() == 0) return false;

  // Show connecting screen
  drawCyberpunkBG();
  tft.setTextSize(1);
  tft.setTextColor(NEON_CYAN);
  tft.setCursor(28, 150);
  tft.print("Connecting to:");
  tft.setTextColor(NEON_GREEN);
  tft.setCursor(28, 165);
  tft.print(savedSSID);

  WiFi.mode(WIFI_STA);
  WiFi.begin(savedSSID.c_str(), savedPass.c_str());

  int attempts = 0;
  while (WiFi.status() != WL_CONNECTED && attempts < 30) {
    delay(500);
    attempts++;
    tft.fillCircle(28 + attempts * 4, 185, 2, NEON_CYAN);
  }

  if (WiFi.status() == WL_CONNECTED) {
    // Setup NTP for clock
    configTime(-5 * 3600, 3600, "pool.ntp.org");

    tft.setTextColor(NEON_GREEN);
    tft.setCursor(28, 200);
    tft.print("Connected!");
    tft.setCursor(28, 215);
    char ipBuf[20];
    snprintf(ipBuf, sizeof(ipBuf), "IP: %s", WiFi.localIP().toString().c_str());
    tft.print(ipBuf);
    delay(1500);
    return true;
  }

  return false;
}

// ══════════════ UDP PARSING ══════════════

void parseUDP(char* payload) {
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, payload);
  if (err) return;

  cur.cpuTemp      = doc["ct"]  | 0;
  cur.cpuFan       = doc["cf"]  | 0;
  cur.cpuLoad      = doc["cl"]  | 0;
  cur.cpuPower     = doc["cp"]  | 0;
  cur.gpuTemp      = doc["gt"]  | 0;
  cur.gpuFan       = doc["gf"]  | 0;
  cur.gpuLoad      = doc["gl"]  | 0;
  cur.gpuPower     = doc["gp"]  | 0;
  cur.gpuVramUsed  = doc["gmu"] | 0;
  cur.gpuVramTotal = doc["gmt"] | 0;
  cur.ramUsed      = doc["mu"]  | 0;
  cur.ramTotal     = doc["mt"]  | 0;
  cur.uptime       = doc["up"]  | 0;
  cur.cpuClock     = doc["cc"]  | 0;
  cur.gpuClock     = doc["gc"]  | 0;
  cur.netDown      = doc["nd"]  | 0;
  cur.netUp        = doc["nu"]  | 0;

  if (doc.containsKey("cn")) {
    strncpy(cpuShortName, doc["cn"] | "CPU", sizeof(cpuShortName) - 1);
    cpuShortName[sizeof(cpuShortName) - 1] = '\0';
  }
  if (doc.containsKey("gn")) {
    strncpy(gpuShortName, doc["gn"] | "GPU", sizeof(gpuShortName) - 1);
    gpuShortName[sizeof(gpuShortName) - 1] = '\0';
  }
  if (doc.containsKey("bn")) {
    strncpy(boardShortName, doc["bn"] | "BOARD", sizeof(boardShortName) - 1);
    boardShortName[sizeof(boardShortName) - 1] = '\0';
  }

  if (!dataReceived) {
    dataReceived = true;
    drawStaticUI();
  }

  lastUpdate = millis();
  if (standbyActive) {
    standbyActive = false;
    clockBGDrawn = false;
    prevTimeStr[0] = '\0';
    prevDateStr[0] = '\0';
    drawStaticUI();
    memset(&prev, 0, sizeof(prev));
    firstUpdate = true;
  }
  updateDisplay();
}

// ══════════════ SETUP ══════════════

void setup() {
  Serial.begin(115200);
  delay(100);

  tft.init(170, 320);
  tft.fillScreen(ST77XX_BLACK);

  // Try connecting with saved WiFi credentials
  if (connectWiFi()) {
    // Connected — start listening for UDP
    udp.begin(udpPort);
    drawWaiting();
    Serial.println("CyberMon WiFi ready. Listening on UDP port " + String(udpPort));
  } else {
    // No creds or connection failed — start config portal
    startConfigPortal();
    Serial.println("Config portal active at 192.168.4.1");
  }
}

// ══════════════ LOOP ══════════════

void loop() {
  // Handle config portal
  if (portalActive) {
    dnsServer->processNextRequest();
    configServer->handleClient();
    return;
  }

  // Check for UDP packets
  int packetSize = udp.parsePacket();
  if (packetSize > 0) {
    char buf[1024];
    int len = udp.read(buf, sizeof(buf) - 1);
    if (len > 0) {
      buf[len] = '\0';
      parseUDP(buf);
    }
  }

  // Standby clock after 30s of no data
  if (dataReceived && millis() - lastUpdate > 30000) {
    if (!standbyActive) {
      standbyActive = true;
      prevTimeStr[0] = '\0';
      prevDateStr[0] = '\0';
    }
    drawStandbyClock();
  }

  // Reconnect WiFi if disconnected
  if (WiFi.status() != WL_CONNECTED) {
    static int lastReconnect = 0;
    if (millis() - lastReconnect > 10000) {
      lastReconnect = millis();
      WiFi.reconnect();
    }
  }
}

SETUP INSTRUCTIONS

  • WiFi Setup: On first boot, the ESP32 creates a hotspot called CyberMon-Setup. Connect to it and open http://192.168.4.1 to enter your WiFi credentials. They are stored in flash and persist across reboots.
  • Libraries: Install via Arduino Library Manager:
    • Adafruit GFX Library
    • Adafruit ST7789
    • ArduinoJson
  • Board: Select "ESP32 Dev Module" in Arduino IDE.
  • UDP Port: Default is 5555. Configurable via the captive portal. Run the CyberMon WiFi sender on your PC to broadcast sensor data.
  • Standby Mode: After 30 seconds without data, the display switches to a retro terminal clock with NTP time sync.
  • Display: 1.9" ST7789 TFT 170x320, portrait orientation. Connects via SPI.

WIRING DIAGRAM

ST7789 Display ESP32
VCC 3.3V
GND GND
SCL (SCLK) GPIO 18
SDA (MOSI) GPIO 23
RES (RST) GPIO 4
DC GPIO 2
CS GPIO 15
BLK (Backlight) GPIO 32