CYD SERVER CAPTIVE PORTAL

Web-Configurable CYD Server - No Hardcoded Credentials

← BASIC VERSION (Hardcoded Config)

CAPTIVE PORTAL FEATURES

>
Web Configuration Configure WiFi, port, and display settings via browser
>
WiFi Network Scan Automatically discovers available networks
>
Persistent Storage Settings saved to flash - survives reboot
>
Auto Config Mode Enters setup on first boot or BOOT button hold

SETUP WORKFLOW

1
Power on CYD
(hold BOOT for config)
2
Connect to
"CYD-Server-Setup" WiFi
3
Browser opens
config page
4
Save settings
CYD reboots

ARDUINO PROJECT SETUP

Create this folder structure for your project:

CYD_Server_Portal/
└── CYD_Server_Portal.ino    <-- Main sketch (copy from below)

SD Card (FAT32):
├── index.html               <-- Your website homepage
├── style.css                <-- Stylesheet
└── images/                  <-- Optional image folder
                

Note: Settings are stored in ESP32 flash (Preferences). Web files are served from SD card.

COMPONENTS NEEDED

  • ESP32-2432S028R (CYD) - Cheap Yellow Display board with built-in 3.5" ST7796 display
  • MicroSD Card - FAT32 formatted (up to 32GB recommended)
  • USB-C or Micro-USB Cable - For programming (check your board version)

All-in-One: CYD includes display, SD card slot, touch panel, RGB LED, and audio DAC. No external wiring required!

REQUIRED LIBRARIES

Install via Arduino Library Manager:

  • Arduino_GFX_Library - Display driver for ST7796
  • SimpleFTPServer - FTP server for wireless file upload

Note: WiFi, WebServer, DNSServer, Preferences, SD, and SPI are built into ESP32 core.

SimpleFTPServer Config: Edit FtpServerKey.h:

  • Set STORAGE_TYPE to STORAGE_SD
  • Set STORAGE_SD_FORCE_DISABLE_SEPORATECLASS to true

ARDUINO IDE 3.x BOARD SETTINGS

Tools menu configuration:

  • Board: ESP32 Dev Module
  • Upload Speed: 921600
  • Flash Frequency: 80MHz
  • Flash Mode: QIO
  • Flash Size: 4MB (32Mb)
  • Partition Scheme: Default 4MB with spiffs
  • PSRAM: Disabled

Note: CYD uses ESP32-WROOM-32E, not ESP32-S3. Select "ESP32 Dev Module".

TROUBLESHOOTING

  • Can't enter config mode: Hold BOOT button (GPIO 0) while powering on.
  • Display stays white/black: Verify ST7796 driver and pin configuration.
  • SD Card not mounting: Must be FAT32 formatted. Try cards 32GB or smaller.
  • WiFi won't connect: Re-enter config mode and verify SSID/password.
  • Upload fails: Hold BOOT button during upload on some CYD boards.
  • FTP connection refused: Check SimpleFTPServer library config.
  • Config page not loading: Try http://192.168.4.1 directly if captive portal fails.

ARDUINO SKETCH

CYD_Server_Portal.ino
/*
 * CYD Web Server - Captive Portal Version
 * ESP32-WROOM-32E + ST7796 3.5" TFT (320x480) + SD Card
 *
 * Features:
 * - Web-based configuration (no hardcoded credentials)
 * - WiFi network scanning
 * - Configurable web server port, display brightness
 * - Touch calibration settings
 * - Settings persist in flash memory
 *
 * Config Mode Triggers:
 * - First boot (no saved WiFi)
 * - Hold BOOT button during power-on
 */

#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <SD.h>
#include <SPI.h>
#include <SimpleFTPServer.h>
#include <Arduino_GFX_Library.h>
#include <time.h>

// ============== PIN DEFINITIONS (CYD Board) ==============
// Display pins (ST7796 - HSPI bus)
#define TFT_SCK   14
#define TFT_MOSI  13
#define TFT_MISO  12
#define TFT_CS    15
#define TFT_DC     2
#define TFT_RST   -1   // Not connected (use software reset)
#define TFT_BL    27   // Backlight control

// SD Card (built-in slot, shares HSPI)
#define SD_CS      5

// Touch pins (XPT2046)
#define TOUCH_MISO 39
#define TOUCH_MOSI 32
#define TOUCH_CLK  25
#define TOUCH_CS   33
#define TOUCH_IRQ  36

// RGB LED pins
#define LED_RED    4
#define LED_GREEN 16
#define LED_BLUE  17

// Boot button
#define BOOT_BTN   0

// ============== DISPLAY DIMENSIONS ==============
#define SCREEN_WIDTH  320
#define SCREEN_HEIGHT 480

// ============== COLORS (RGB565) ==============
#define BLACK       0x0000
#define WHITE       0xFFFF
#define CYAN        0x07FF
#define MAGENTA     0xF81F
#define YELLOW      0xFFE0
#define GREEN       0x07E0
#define RED         0xF800
#define DARKGRAY    0x4208
#define DARKCYAN    0x0345
#define DARKYELLOW  0x8400

// ============== CONFIGURATION STRUCTURE ==============
struct Config {
  char ssid[33];
  char password[65];
  char ftp_user[17];
  char ftp_pass[17];
  int webPort;
  int brightness;
  long gmtOffset;
  int dstOffset;
};

Config config;
Preferences prefs;

// ============== OBJECTS ==============
// Hardware SPI for display (HSPI bus)
Arduino_DataBus *bus = new Arduino_ESP32SPI(
  TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, TFT_MISO, HSPI, 10000000
);
Arduino_GFX *gfx = new Arduino_ST7796(bus, TFT_RST, 0, false);

WebServer *server = nullptr;
DNSServer dnsServer;
FtpServer ftpSrv;

// ============== STATE VARIABLES ==============
bool configMode = false;
bool sdCardMounted = false;
unsigned long requestCount = 0;
unsigned long lastDisplayUpdate = 0;
int lastDisplayedHour = -1;
int lastDisplayedMin = -1;

// ============== DEFAULT CONFIGURATION ==============
void setDefaults() {
  memset(&config, 0, sizeof(config));
  strcpy(config.ftp_user, "admin");
  strcpy(config.ftp_pass, "admin");
  config.webPort = 80;
  config.brightness = 100;  // 0-255
  config.gmtOffset = -21600; // CST = UTC-6
  config.dstOffset = 3600;   // DST offset
}

// ============== LOAD/SAVE CONFIG ==============
void loadConfig() {
  prefs.begin("cyd", true);

  if (!prefs.isKey("ssid")) {
    prefs.end();
    setDefaults();
    return;
  }

  strcpy(config.ssid, prefs.getString("ssid", "").c_str());
  strcpy(config.password, prefs.getString("pass", "").c_str());
  strcpy(config.ftp_user, prefs.getString("ftpuser", "admin").c_str());
  strcpy(config.ftp_pass, prefs.getString("ftppass", "admin").c_str());
  config.webPort = prefs.getInt("port", 80);
  config.brightness = prefs.getInt("bright", 100);
  config.gmtOffset = prefs.getLong("gmt", -21600);
  config.dstOffset = prefs.getInt("dst", 3600);

  prefs.end();
}

void saveConfig() {
  prefs.begin("cyd", false);
  prefs.putString("ssid", config.ssid);
  prefs.putString("pass", config.password);
  prefs.putString("ftpuser", config.ftp_user);
  prefs.putString("ftppass", config.ftp_pass);
  prefs.putInt("port", config.webPort);
  prefs.putInt("bright", config.brightness);
  prefs.putLong("gmt", config.gmtOffset);
  prefs.putInt("dst", config.dstOffset);
  prefs.end();
}

// ============== CONFIG PAGE HTML ==============
const char* configPage = R"rawhtml(
<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>CYD Server Setup</title>
  <style>
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:'Courier New',monospace;background:#0a0a1a;color:#b0b0b0;padding:20px}
    h1{color:#ffe000;text-align:center;margin-bottom:20px;text-shadow:0 0 10px #ffe000}
    .card{background:#12121f;border:1px solid #333;border-radius:8px;padding:15px;margin-bottom:15px}
    .card h2{color:#00ffff;font-size:0.9rem;margin-bottom:10px;letter-spacing:2px}
    label{display:block;color:#888;font-size:0.75rem;margin-bottom:3px}
    input,select{width:100%;padding:8px;background:#1a1a2e;border:1px solid #ffe000;color:#fff;border-radius:4px;margin-bottom:10px;font-family:inherit}
    input:focus{outline:none;box-shadow:0 0 5px #ffe000}
    .row{display:flex;gap:10px}
    .row>*{flex:1}
    button{width:100%;padding:12px;background:#ffe000;border:none;color:#000;font-weight:bold;cursor:pointer;border-radius:4px;font-family:inherit;letter-spacing:2px;margin-top:10px}
    button:hover{box-shadow:0 0 15px #ffe000}
    .scan-btn{background:transparent;border:1px solid #00ffff;color:#00ffff;padding:8px;margin-bottom:10px}
    .scan-btn:hover{background:#00ffff;color:#000}
    #networks{max-height:150px;overflow-y:auto;border:1px solid #333;border-radius:4px;margin-bottom:10px}
    .net{padding:8px;cursor:pointer;border-bottom:1px solid #222}
    .net:hover{background:#1a1a2e}
    .net .rssi{float:right;color:#666}
    .slider-container{display:flex;align-items:center;gap:10px;margin-bottom:10px}
    .slider-container input[type=range]{flex:1}
    .slider-val{color:#ffe000;min-width:40px;text-align:right}
  </style>
</head>
<body>
  <h1>[CYD SERVER SETUP]</h1>
  <form action="/save" method="POST">
    <div class="card">
      <h2>WiFi NETWORK</h2>
      <button type="button" class="scan-btn" onclick="scanNetworks()">SCAN NETWORKS</button>
      <div id="networks"></div>
      <label>SSID</label>
      <input type="text" name="ssid" id="ssid" maxlength="32" required>
      <label>PASSWORD</label>
      <input type="password" name="pass" maxlength="64">
    </div>

    <div class="card">
      <h2>FTP SERVER</h2>
      <div class="row">
        <div><label>Username</label><input type="text" name="ftpuser" value="admin" maxlength="16"></div>
        <div><label>Password</label><input type="text" name="ftppass" value="admin" maxlength="16"></div>
      </div>
    </div>

    <div class="card">
      <h2>WEB SERVER</h2>
      <label>HTTP Port</label>
      <input type="number" name="port" value="80" min="1" max="65535">
    </div>

    <div class="card">
      <h2>DISPLAY SETTINGS</h2>
      <label>Backlight Brightness</label>
      <div class="slider-container">
        <input type="range" name="bright" min="10" max="255" value="100" oninput="document.getElementById('brightVal').textContent=this.value">
        <span class="slider-val" id="brightVal">100</span>
      </div>
    </div>

    <div class="card">
      <h2>TIMEZONE</h2>
      <label>GMT Offset (seconds)</label>
      <select name="gmt">
        <option value="-36000">UTC-10 (Hawaii)</option>
        <option value="-32400">UTC-9 (Alaska)</option>
        <option value="-28800">UTC-8 (Pacific)</option>
        <option value="-25200">UTC-7 (Mountain)</option>
        <option value="-21600" selected>UTC-6 (Central)</option>
        <option value="-18000">UTC-5 (Eastern)</option>
        <option value="0">UTC+0 (London)</option>
        <option value="3600">UTC+1 (Paris)</option>
        <option value="7200">UTC+2 (Cairo)</option>
        <option value="10800">UTC+3 (Moscow)</option>
        <option value="28800">UTC+8 (Singapore)</option>
        <option value="32400">UTC+9 (Tokyo)</option>
        <option value="36000">UTC+10 (Sydney)</option>
      </select>
      <div class="row">
        <div>
          <label>DST Offset (seconds)</label>
          <input type="number" name="dst" value="3600" min="0" max="7200">
        </div>
      </div>
    </div>

    <button type="submit">SAVE & REBOOT</button>
  </form>

  <script>
    function scanNetworks() {
      var box = document.getElementById('networks');
      box.innerHTML = '<div style="color:#ffe000">Scanning...</div>';
      var xhr = new XMLHttpRequest();
      xhr.open('GET', '/scan', true);
      xhr.onreadystatechange = function() {
        if (xhr.readyState === 4) {
          if (xhr.status === 200) {
            try {
              var nets = JSON.parse(xhr.responseText);
              if (!nets || nets.length === 0) {
                box.innerHTML = '<div style="color:#888">No networks found</div>';
                return;
              }
              var html = '';
              for (var i = 0; i < nets.length; i++) {
                var n = nets[i];
                html += '<div class="net" onclick="document.getElementById(\'ssid\').value=\'' + n.ssid.replace(/'/g, '') + '\'">' + n.ssid + '<span class="rssi">' + n.rssi + ' dBm</span></div>';
              }
              box.innerHTML = html;
            } catch (e) {
              box.innerHTML = '<div style="color:#f00">Parse error: ' + e.message + '</div>';
            }
          } else {
            box.innerHTML = '<div style="color:#f00">HTTP error: ' + xhr.status + '</div>';
          }
        }
      };
      xhr.onerror = function() {
        box.innerHTML = '<div style="color:#f00">Network error</div>';
      };
      xhr.send();
    }
  </script>
</body>
</html>
)rawhtml";

// ============== WEB SERVER HANDLERS ==============
void handleRoot() {
  server->send(200, "text/html", configPage);
}

String escapeJson(String s) {
  s.replace("\\", "\\\\");
  s.replace("\"", "\\\"");
  return s;
}

void handleScan() {
  Serial.println("Scanning WiFi networks...");
  int n = WiFi.scanNetworks();
  Serial.printf("Found %d networks\n", n);

  if (n < 0) {
    server->send(500, "application/json", "[]");
    return;
  }

  String json = "[";
  for (int i = 0; i < n; i++) {
    if (i > 0) json += ",";
    json += "{\"ssid\":\"" + escapeJson(WiFi.SSID(i)) + "\",\"rssi\":" + WiFi.RSSI(i) + "}";
  }
  json += "]";
  server->send(200, "application/json", json);
  WiFi.scanDelete();  // Free memory
}

void handleSave() {
  strcpy(config.ssid, server->arg("ssid").c_str());
  strcpy(config.password, server->arg("pass").c_str());
  strcpy(config.ftp_user, server->arg("ftpuser").c_str());
  strcpy(config.ftp_pass, server->arg("ftppass").c_str());
  config.webPort = server->arg("port").toInt();
  config.brightness = server->arg("bright").toInt();
  config.gmtOffset = server->arg("gmt").toInt();
  config.dstOffset = server->arg("dst").toInt();

  if (config.webPort < 1) config.webPort = 80;
  if (config.brightness < 10) config.brightness = 100;

  saveConfig();

  server->send(200, "text/html", "<h1 style='color:#0f0;text-align:center;font-family:monospace'>SAVED! Rebooting...</h1>");
  delay(1000);
  ESP.restart();
}

// ============== DISPLAY HELPER FUNCTIONS ==============
void drawCornerBrackets(int x, int y, int w, int h, uint16_t color) {
  int len = 12;
  gfx->drawFastHLine(x, y, len, color);
  gfx->drawFastVLine(x, y, len, color);
  gfx->drawFastHLine(x + w - len, y, len, color);
  gfx->drawFastVLine(x + w - 1, y, len, color);
  gfx->drawFastHLine(x, y + h - 1, len, color);
  gfx->drawFastVLine(x, y + h - len, len, color);
  gfx->drawFastHLine(x + w - len, y + h - 1, len, color);
  gfx->drawFastVLine(x + w - 1, y + h - len, len, color);
}

void drawHorizontalDivider(int y, uint16_t color) {
  gfx->drawFastHLine(20, y, SCREEN_WIDTH - 40, color);
}

// ============== CONFIG MODE DISPLAY ==============
void drawConfigScreen() {
  gfx->fillScreen(BLACK);

  // Border frame
  gfx->drawRect(5, 5, SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10, DARKYELLOW);
  gfx->drawRect(10, 10, SCREEN_WIDTH - 20, SCREEN_HEIGHT - 20, YELLOW);
  gfx->drawRect(15, 15, SCREEN_WIDTH - 30, SCREEN_HEIGHT - 30, DARKYELLOW);

  // Title bar
  gfx->fillRect(20, 30, SCREEN_WIDTH - 40, 50, DARKYELLOW);
  gfx->drawRect(20, 30, SCREEN_WIDTH - 40, 50, YELLOW);

  gfx->setTextColor(BLACK);
  gfx->setTextSize(3);
  gfx->setCursor(45, 42);
  gfx->print("CONFIG MODE");

  drawHorizontalDivider(100, YELLOW);

  // Instructions
  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(50, 130);
  gfx->print("Connect WiFi to:");

  // AP Name box
  drawCornerBrackets(30, 160, SCREEN_WIDTH - 60, 60, CYAN);
  gfx->setTextColor(CYAN);
  gfx->setTextSize(2);
  gfx->setCursor(45, 180);
  gfx->print("CYD-Server-Setup");

  drawHorizontalDivider(240, DARKGRAY);

  // Then open browser
  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(45, 270);
  gfx->print("Then open browser:");

  // IP box
  drawCornerBrackets(30, 300, SCREEN_WIDTH - 60, 60, YELLOW);
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(3);
  gfx->setCursor(55, 318);
  gfx->print("192.168.4.1");

  drawHorizontalDivider(380, DARKGRAY);

  // Footer info
  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(70, 400);
  gfx->print("Hold BOOT button during power-on");
  gfx->setCursor(85, 415);
  gfx->print("to return to config mode");

  gfx->setTextColor(YELLOW);
  gfx->setCursor(90, 445);
  gfx->print("CYD Server // Captive Portal");
}

// ============== START CONFIG MODE ==============
void startConfigMode() {
  configMode = true;
  Serial.println("Starting Config Mode...");

  // Set LED to yellow (red+green)
  digitalWrite(LED_RED, HIGH);
  digitalWrite(LED_GREEN, HIGH);
  digitalWrite(LED_BLUE, LOW);

  // Show config screen
  drawConfigScreen();

  // Start AP + STA mode (allows WiFi scanning while AP is active)
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP("CYD-Server-Setup");

  // Start DNS (captive portal)
  dnsServer.start(53, "*", WiFi.softAPIP());

  // Create config web server on port 80
  server = new WebServer(80);
  server->on("/", handleRoot);
  server->on("/scan", handleScan);
  server->on("/save", HTTP_POST, handleSave);
  server->onNotFound(handleRoot);  // Redirect all to config
  server->begin();

  Serial.println("AP IP: 192.168.4.1");
}

// ============== CONNECT WiFi ==============
bool connectWiFi() {
  if (strlen(config.ssid) == 0) return false;

  gfx->fillScreen(BLACK);
  gfx->drawRect(5, 5, SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10, YELLOW);

  gfx->fillRect(20, 30, SCREEN_WIDTH - 40, 50, DARKYELLOW);
  gfx->setTextColor(BLACK);
  gfx->setTextSize(3);
  gfx->setCursor(35, 42);
  gfx->print("CONNECTING...");

  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(30, 120);
  gfx->print("SSID: ");
  gfx->setTextColor(CYAN);
  gfx->print(config.ssid);

  WiFi.mode(WIFI_STA);
  WiFi.begin(config.ssid, config.password);

  // Progress bar
  int barX = 40, barY = 200, barW = SCREEN_WIDTH - 80, barH = 30;
  gfx->drawRect(barX, barY, barW, barH, YELLOW);

  int attempts = 0;
  while (WiFi.status() != WL_CONNECTED && attempts < 30) {
    delay(500);
    int fillW = ((barW - 4) * attempts) / 30;
    gfx->fillRect(barX + 2, barY + 2, fillW, barH - 4, YELLOW);
    digitalWrite(LED_BLUE, attempts % 2);
    attempts++;
  }

  if (WiFi.status() == WL_CONNECTED) {
    gfx->fillRect(barX + 2, barY + 2, barW - 4, barH - 4, GREEN);
    gfx->setTextColor(GREEN);
    gfx->setTextSize(2);
    gfx->setCursor(100, 260);
    gfx->print("CONNECTED!");
    gfx->setTextColor(CYAN);
    gfx->setCursor(70, 300);
    gfx->print(WiFi.localIP());
    delay(1500);
    return true;
  }
  return false;
}

// ============== CONTENT TYPES ==============
String getContentType(String filename) {
  if (filename.endsWith(".html")) return "text/html";
  else if (filename.endsWith(".css")) return "text/css";
  else if (filename.endsWith(".js")) return "application/javascript";
  else if (filename.endsWith(".png")) return "image/png";
  else if (filename.endsWith(".jpg")) return "image/jpeg";
  else if (filename.endsWith(".gif")) return "image/gif";
  else if (filename.endsWith(".ico")) return "image/x-icon";
  else if (filename.endsWith(".json")) return "application/json";
  return "text/plain";
}

// ============== FILE HANDLER ==============
bool handleFileRead(String path) {
  Serial.println("Request: " + path);
  requestCount++;

  if (path.endsWith("/")) path += "index.html";

  if (!sdCardMounted) {
    server->send(503, "text/plain", "SD Card not mounted");
    return false;
  }

  String contentType = getContentType(path);

  if (SD.exists(path)) {
    File file = SD.open(path, FILE_READ);
    if (file) {
      server->streamFile(file, contentType);
      file.close();
      return true;
    }
  }
  return false;
}

// ============== STATUS SCREEN ==============
void drawStatusScreen() {
  gfx->fillScreen(BLACK);

  // Border frame
  gfx->drawRect(5, 5, SCREEN_WIDTH - 10, SCREEN_HEIGHT - 10, DARKYELLOW);
  gfx->drawRect(10, 10, SCREEN_WIDTH - 20, SCREEN_HEIGHT - 20, DARKGRAY);

  // Title bar
  gfx->fillRect(20, 15, SCREEN_WIDTH - 40, 35, DARKYELLOW);
  gfx->drawRect(20, 15, SCREEN_WIDTH - 40, 35, YELLOW);
  gfx->setTextColor(BLACK);
  gfx->setTextSize(3);
  gfx->setCursor(55, 20);
  gfx->print("CYD SERVER");

  // Status indicators
  drawHorizontalDivider(55, YELLOW);

  int indicatorY = 65;

  // Power ON indicator
  gfx->fillCircle(50, indicatorY + 6, 6, GREEN);
  gfx->setTextColor(GREEN);
  gfx->setTextSize(1);
  gfx->setCursor(62, indicatorY + 2);
  gfx->print("PWR");

  // SD Card indicator
  if (sdCardMounted) {
    gfx->fillCircle(120, indicatorY + 6, 6, YELLOW);
    gfx->setTextColor(YELLOW);
  } else {
    gfx->fillCircle(120, indicatorY + 6, 6, RED);
    gfx->setTextColor(RED);
  }
  gfx->setCursor(132, indicatorY + 2);
  gfx->print("SD");

  // FTP indicator
  gfx->fillCircle(180, indicatorY + 6, 6, CYAN);
  gfx->setTextColor(CYAN);
  gfx->setCursor(192, indicatorY + 2);
  gfx->print("FTP");

  // WiFi indicator
  gfx->fillCircle(240, indicatorY + 6, 6, CYAN);
  gfx->setTextColor(CYAN);
  gfx->setCursor(252, indicatorY + 2);
  gfx->print("WIFI");

  drawHorizontalDivider(85, DARKGRAY);

  // Main content area frame
  drawCornerBrackets(15, 90, SCREEN_WIDTH - 30, 120, YELLOW);

  // Network address label
  gfx->setTextColor(WHITE);
  gfx->setTextSize(1);
  gfx->setCursor(115, 97);
  gfx->print("NETWORK ADDRESS");

  // IP Address
  String ip = WiFi.localIP().toString();
  gfx->setTextColor(CYAN);
  gfx->setTextSize(2);
  int ipWidth = ip.length() * 12;
  gfx->setCursor((SCREEN_WIDTH - ipWidth) / 2, 120);
  gfx->print(ip);

  // Port info
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(1);
  gfx->setCursor(110, 150);
  gfx->print("HTTP Port: ");
  gfx->print(config.webPort);

  gfx->setCursor(110, 165);
  gfx->print("FTP Port: 21");

  drawHorizontalDivider(220, DARKGRAY);

  // Runtime and requests labels
  gfx->setTextColor(WHITE);
  gfx->setTextSize(1);
  gfx->setCursor(55, 230);
  gfx->print("RUNTIME");
  gfx->setCursor(210, 230);
  gfx->print("REQUESTS");

  drawHorizontalDivider(280, DARKGRAY);

  // Footer
  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(55, 450);
  gfx->print("CYD Server // Captive Portal Version");
}

// ============== UPDATE DISPLAY ==============
void updateDisplay() {
  // Runtime
  unsigned long uptime = millis() / 1000;
  int uptimeHours = uptime / 3600;
  int uptimeMins = (uptime % 3600) / 60;

  gfx->fillRect(40, 245, 110, 18, BLACK);
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(2);
  if (uptimeHours > 0) {
    gfx->setCursor(40, 245);
    gfx->printf("%02d:%02d hr", uptimeHours, uptimeMins);
  } else {
    gfx->setCursor(52, 245);
    gfx->printf("%02d min", uptimeMins);
  }

  // Requests
  gfx->fillRect(220, 245, 80, 18, BLACK);
  gfx->setTextColor(GREEN);
  gfx->setTextSize(2);
  gfx->setCursor(220, 245);
  gfx->print(requestCount);

  // Time display
  struct tm timeinfo;
  if (getLocalTime(&timeinfo)) {
    int hour12 = timeinfo.tm_hour % 12;
    if (hour12 == 0) hour12 = 12;

    if (hour12 != lastDisplayedHour || timeinfo.tm_min != lastDisplayedMin) {
      gfx->fillRect(80, 300, 160, 50, BLACK);
      gfx->setTextColor(CYAN);
      gfx->setTextSize(4);
      gfx->setCursor(80, 310);
      gfx->printf("%02d:%02d", hour12, timeinfo.tm_min);

      gfx->setTextSize(2);
      gfx->setCursor(200, 325);
      gfx->print(timeinfo.tm_hour >= 12 ? "PM" : "AM");

      lastDisplayedHour = hour12;
      lastDisplayedMin = timeinfo.tm_min;
    }

    // Date
    gfx->fillRect(95, 370, 130, 18, BLACK);
    char dateStr[15];
    strftime(dateStr, sizeof(dateStr), "%m/%d/%Y", &timeinfo);
    gfx->setTextColor(YELLOW);
    gfx->setTextSize(2);
    gfx->setCursor(95, 370);
    gfx->print(dateStr);
  }

  // Free heap and RSSI
  gfx->fillRect(25, 420, 270, 12, BLACK);
  gfx->setTextColor(WHITE);
  gfx->setTextSize(1);
  gfx->setCursor(25, 420);
  gfx->print("HEAP:");
  gfx->setTextColor(CYAN);
  gfx->printf("%dK", ESP.getFreeHeap() / 1024);

  gfx->setTextColor(WHITE);
  gfx->setCursor(130, 420);
  gfx->print("RSSI:");
  gfx->setTextColor(YELLOW);
  gfx->printf("%ddBm", WiFi.RSSI());

  gfx->setTextColor(WHITE);
  gfx->setCursor(220, 420);
  gfx->print("BL:");
  gfx->setTextColor(YELLOW);
  gfx->printf("%d%%", (config.brightness * 100) / 255);
}

// ============== SETUP ==============
void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("\n=== CYD Server - Captive Portal Version ===\n");

  // Initialize RGB LEDs
  pinMode(LED_RED, OUTPUT);
  pinMode(LED_GREEN, OUTPUT);
  pinMode(LED_BLUE, OUTPUT);
  digitalWrite(LED_RED, HIGH);     // Red on during boot
  digitalWrite(LED_GREEN, LOW);
  digitalWrite(LED_BLUE, LOW);

  // BOOT button
  pinMode(BOOT_BTN, INPUT_PULLUP);

  // Set chip selects as outputs
  pinMode(TFT_CS, OUTPUT);
  pinMode(SD_CS, OUTPUT);
  digitalWrite(TFT_CS, HIGH);
  digitalWrite(SD_CS, HIGH);

  // Load saved config
  loadConfig();

  // Turn on backlight using saved brightness
  ledcAttach(TFT_BL, 5000, 8);
  ledcWrite(TFT_BL, config.brightness);
  Serial.printf("Backlight at %d%%\n", (config.brightness * 100) / 255);

  // Initialize display
  Serial.println("Initializing display...");
  gfx->begin();
  gfx->fillScreen(BLACK);

  // Initialize SD card
  Serial.println("Initializing SD card...");
  digitalWrite(TFT_CS, HIGH);
  if (!SD.begin(SD_CS, SPI, 4000000)) {
    Serial.println("  SD Card mount failed!");
    sdCardMounted = false;
  } else {
    sdCardMounted = true;
    Serial.println("  SD Card OK!");
    digitalWrite(LED_GREEN, HIGH);
  }

  // Check if BOOT button held (force config mode)
  bool forceConfig = (digitalRead(BOOT_BTN) == LOW);
  delay(100);
  forceConfig = forceConfig && (digitalRead(BOOT_BTN) == LOW);

  // Enter config if no WiFi saved OR button held
  if (forceConfig || strlen(config.ssid) == 0) {
    startConfigMode();
    return;
  }

  // Try to connect to saved WiFi
  if (!connectWiFi()) {
    startConfigMode();
    return;
  }

  // WiFi connected - set LED to blue
  digitalWrite(LED_RED, LOW);
  digitalWrite(LED_BLUE, HIGH);

  // Configure NTP time
  configTime(config.gmtOffset, config.dstOffset, "pool.ntp.org");

  // Start FTP server
  ftpSrv.begin(config.ftp_user, config.ftp_pass);
  Serial.printf("FTP server started (user: %s)\n", config.ftp_user);

  // Create web server on configured port
  server = new WebServer(config.webPort);

  // API endpoint for live system data
  server->on("/api/info", []() {
    String json = "{";
    json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
    json += "\"ssid\":\"" + String(config.ssid) + "\",";
    json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
    json += "\"port\":" + String(config.webPort) + ",";
    json += "\"uptime\":" + String(millis() / 1000) + ",";
    json += "\"heap\":" + String(ESP.getFreeHeap()) + ",";
    json += "\"sdSize\":" + String(sdCardMounted ? SD.cardSize() / (1024 * 1024) : 0) + ",";
    json += "\"requests\":" + String(requestCount);
    json += "}";
    server->send(200, "application/json", json);
  });

  server->onNotFound([]() {
    if (!handleFileRead(server->uri())) {
      server->send(404, "text/plain", "404: Not Found");
    }
  });
  server->begin();
  Serial.printf("Web server started on port %d\n", config.webPort);

  // Draw status screen
  drawStatusScreen();
}

// ============== LOOP ==============
void loop() {
  if (configMode) {
    dnsServer.processNextRequest();
    server->handleClient();
    return;
  }

  if (WiFi.status() != WL_CONNECTED) {
    if (!connectWiFi()) {
      startConfigMode();
      return;
    }
  }

  server->handleClient();
  ftpSrv.handleFTP();

  if (millis() - lastDisplayUpdate > 1000) {
    lastDisplayUpdate = millis();
    updateDisplay();
  }
}

SAMPLE INDEX.HTML

Copy to SD card root
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CYD Server</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>CYD WEB SERVER</h1>
    <p class="subtitle">ESP32-2432S028R + ST7796 3.5" Display</p>

    <!-- Status Indicators -->
    <div class="indicators">
      <span class="indicator green">PWR</span>
      <span class="indicator" id="wifi-ind">WiFi</span>
      <span class="indicator yellow">HTTP</span>
      <span class="indicator" id="sd-ind">SD</span>
    </div>

    <!-- Device Info -->
    <div class="status-box">
      <h2>Device Info</h2>
      <div class="info-grid">
        <div class="info-item">
          <span class="label">Board</span>
          <span class="value">ESP32-WROOM-32E</span>
        </div>
        <div class="info-item">
          <span class="label">Display</span>
          <span class="value">ST7796 320x480</span>
        </div>
        <div class="info-item">
          <span class="label">Storage</span>
          <span class="value">MicroSD Card</span>
        </div>
        <div class="info-item">
          <span class="label">CPU</span>
          <span class="value">Dual-Core 240MHz</span>
        </div>
      </div>
    </div>

    <!-- Network Status -->
    <div class="status-box">
      <h2>Network Status</h2>
      <div id="network-info">Loading...</div>
    </div>

    <!-- System Stats -->
    <div class="status-box">
      <h2>System Stats</h2>
      <div id="system-info">Loading...</div>
    </div>

    <p class="footer">Auto-refresh: <span id="countdown">5</span>s</p>
  </div>

  <script>
    function updateInfo() {
      fetch('/api/info')
        .then(r => r.json())
        .then(data => {
          // Update WiFi indicator color based on signal
          var wifiInd = document.getElementById('wifi-ind');
          wifiInd.className = 'indicator ' + (data.rssi > -60 ? 'green' : data.rssi > -75 ? 'yellow' : 'red');

          // Update SD indicator
          var sdInd = document.getElementById('sd-ind');
          sdInd.className = 'indicator ' + (data.sdSize > 0 ? 'green' : 'red');

          // Format uptime
          var hrs = Math.floor(data.uptime / 3600);
          var mins = Math.floor((data.uptime % 3600) / 60);
          var secs = data.uptime % 60;
          var uptimeStr = hrs > 0 ? hrs + 'h ' + mins + 'm' : mins + 'm ' + secs + 's';

          document.getElementById('network-info').innerHTML = `
            <div class="info-grid">
              <div class="info-item">
                <span class="label">IP Address</span>
                <span class="value highlight">${data.ip}</span>
              </div>
              <div class="info-item">
                <span class="label">SSID</span>
                <span class="value">${data.ssid || 'Connected'}</span>
              </div>
              <div class="info-item">
                <span class="label">Signal Strength</span>
                <span class="value">${data.rssi} dBm</span>
              </div>
              <div class="info-item">
                <span class="label">HTTP Port</span>
                <span class="value">${data.port || 80}</span>
              </div>
            </div>
          `;

          document.getElementById('system-info').innerHTML = `
            <div class="info-grid">
              <div class="info-item">
                <span class="label">Uptime</span>
                <span class="value">${uptimeStr}</span>
              </div>
              <div class="info-item">
                <span class="label">Free Heap</span>
                <span class="value">${(data.heap / 1024).toFixed(1)} KB</span>
              </div>
              <div class="info-item">
                <span class="label">SD Card</span>
                <span class="value">${data.sdSize} MB</span>
              </div>
              <div class="info-item">
                <span class="label">Requests</span>
                <span class="value">${data.requests || 0}</span>
              </div>
            </div>
          `;
        })
        .catch(e => console.log('API error:', e));
    }

    // Initial load
    updateInfo();

    // Auto-refresh every 5 seconds
    var count = 5;
    setInterval(function() {
      count--;
      document.getElementById('countdown').textContent = count;
      if (count <= 0) {
        count = 5;
        updateInfo();
      }
    }, 1000);
  </script>
</body>
</html>

SAMPLE STYLE.CSS

Copy to SD card root
/* CYD Server - Cyberpunk Theme */
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: 'Courier New', monospace;
  background: #0a0a1a;
  color: #e0e0e0;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}

.container {
  max-width: 400px;
  padding: 20px;
}

h1 {
  color: #ffff00;
  text-align: center;
  text-shadow: 0 0 10px #ffff00;
  margin-bottom: 20px;
  letter-spacing: 3px;
}

.status-box {
  background: #12121f;
  border: 2px solid #ffff00;
  border-radius: 10px;
  padding: 20px;
}

.status-box h2 {
  color: #00ffff;
  font-size: 0.9rem;
  letter-spacing: 2px;
  margin-bottom: 15px;
  border-bottom: 1px solid #333;
  padding-bottom: 10px;
}

.subtitle {
  text-align: center;
  color: #888;
  margin-bottom: 20px;
  font-size: 0.85rem;
}

.indicators {
  display: flex;
  justify-content: center;
  gap: 15px;
  margin-bottom: 20px;
}

.indicator {
  padding: 5px 12px;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: bold;
  background: #1a1a2a;
  border: 1px solid #333;
}

.indicator.green { color: #00ff00; border-color: #00ff00; box-shadow: 0 0 5px #00ff00; }
.indicator.yellow { color: #ffff00; border-color: #ffff00; box-shadow: 0 0 5px #ffff00; }
.indicator.red { color: #ff0000; border-color: #ff0000; box-shadow: 0 0 5px #ff0000; }

.status-box + .status-box {
  margin-top: 15px;
}

.info-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.info-item {
  display: flex;
  flex-direction: column;
}

.label {
  font-size: 0.7rem;
  color: #888;
  text-transform: uppercase;
}

.value {
  color: #ffff00;
  font-size: 0.9rem;
}

.value.highlight {
  color: #00ffff;
  font-weight: bold;
}

.footer {
  text-align: center;
  margin-top: 20px;
  color: #555;
  font-size: 0.8rem;
}

#info strong {
  color: #ffff00;
}

SETUP INSTRUCTIONS

  • First Boot: CYD automatically enters config mode (no WiFi saved yet).
  • Force Config Mode: Hold BOOT button while powering on to enter setup anytime.
  • Connect: Join the "CYD-Server-Setup" WiFi network from your phone/laptop.
  • Configure: Browser should auto-open config page, or go to 192.168.4.1
  • Scan Networks: Click "SCAN NETWORKS" to find available WiFi networks.
  • Save: Fill in settings and click "SAVE & REBOOT" - CYD restarts with new config.

DIFFERENCES FROM BASIC VERSION

  • No hardcoded credentials - WiFi, FTP, and port configured via web interface
  • Persistent storage - Settings saved to ESP32 flash memory (NVS)
  • Captive portal - Auto-redirects to config page when in AP mode
  • Network scanning - Discovers available WiFi networks
  • Configurable settings - Web server port, display brightness, timezone
  • Dynamic web server - Port configurable (not hardcoded to 80)

CYD BOARD PINOUT

ESP32-WROOM-32E with built-in 3.5" ST7796 display:

Display (HSPI):
  MOSI=13, SCK=14, CS=15, DC=2, BL=27

Touch (XPT2046):
  MISO=39, MOSI=32, CLK=25, CS=33, IRQ=36

SD Card: CS=5 (shares HSPI)

RGB LED: R=4, G=16, B=17

BOOT Button: GPIO0

BUILD CHECKLIST

Follow these steps to build your CYD Web Server with Captive Portal:

  1. Create project folder: CYD_Server_Portal/
  2. Copy Arduino sketch: Save code as CYD_Server_Portal.ino
  3. Format SD card: FAT32 format (32GB or smaller)
  4. Copy web files to SD: Add index.html and style.css
  5. Install libraries: Arduino_GFX_Library + SimpleFTPServer
  6. Configure SimpleFTPServer: Edit FtpServerKey.h (STORAGE_SD)
  7. Insert SD card: Place SD card in CYD's built-in slot
  8. Select board: ESP32 Dev Module (NOT ESP32-S3)
  9. Upload sketch: Compile and upload (hold BOOT if needed)
  10. First boot: Connect to "CYD-Setup" WiFi network
  11. Configure: Open 192.168.4.1 in browser, enter WiFi settings
  12. Test: After reboot, access web server at displayed IP