LITTLE WEB SERVER TF CAPTIVE PORTAL

Web-Configurable Server - No Hardcoded Credentials

← BASIC VERSION (Hardcoded Config)

CAPTIVE PORTAL FEATURES

>
Web Configuration Configure WiFi, server port, FTP, and display 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
>
Display Brightness Adjustable backlight via PWM control
>
FTP Configuration Set custom FTP username and password

SETUP WORKFLOW

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

ARDUINO PROJECT SETUP

Create this folder structure for your project:

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

SD Card (FAT32):
├── index.html          <-- Your website homepage
├── style.css           <-- Optional: CSS styles
└── *.html, *.js, etc   <-- Additional web files
                

Note: Web files are served from SD card, not LittleFS. Format SD card as FAT32.

COMPONENTS NEEDED

  • ESP32-S3 Super Mini - Main microcontroller
  • GC9A01 Round Display - 240x240 1.28" TFT LCD (SPI)
  • MicroSD Card Module - For storing website files
  • MicroSD Card - FAT32 formatted (up to 32GB recommended)
  • Jumper Wires - For connections
  • USB-C Cable - For programming and power

WIRING DIAGRAM

Display and SD card use separate SPI buses to avoid conflicts.

GC9A01 Display (SPI)

Display Pin ESP32-S3
VCC3.3V
GNDGND
SCL (SCK)GPIO 12
SDA (MOSI)GPIO 11
CSGPIO 8
DCGPIO 9
RSTGPIO 10
BL (Backlight)GPIO 7

SD Card Module (HSPI)

SD Card Pin ESP32-S3
VCC3.3V
GNDGND
SCKGPIO 4
MISOGPIO 3
MOSIGPIO 2
CSGPIO 1

ARDUINO IDE 3.x BOARD SETTINGS

Tools menu configuration:

  • Board: ESP32S3 Dev Module
  • USB CDC On Boot: Enabled
  • USB Mode: Hardware CDC and JTAG
  • Flash Size: 4MB (or your board's size)
  • Partition Scheme: Default 4MB with spiffs
  • Upload Speed: 921600

TROUBLESHOOTING

  • SD Card not mounting: Must be FAT32. Use SD.begin(SD_CS, sdSPI, 4000000) with 4MHz speed.
  • Display stays black: SD card must initialize BEFORE display. Check code order.
  • Upload fails: Hold BOOT button during upload. Release after "Connecting..." appears.
  • Can't see serial output: Enable "USB CDC On Boot" in board settings.
  • WiFi won't connect: Hold BOOT button on power-up to re-enter config mode.
  • FTP not working: Check SimpleFTPServer library config (STORAGE_SD).
  • Files not serving: Verify files are on SD card root, not in subfolders (unless paths match).

ARDUINO SKETCH

LittleWebTF_Portal.ino
/*
 * Little Web Server TF Card - Captive Portal Version
 * ESP32-S3 Super Mini + GC9A01 Round Display + SD Card
 *
 * Features:
 * - Web-based configuration (no hardcoded credentials)
 * - WiFi network scanning
 * - Configurable web server port, FTP, display brightness
 * - SD card file serving with FTP upload
 * - Settings persist in flash memory
 *
 * Hardware:
 * - GC9A01 240x240 Round LCD (SPI): SCK=12, MOSI=11, CS=8, DC=9, RST=10, BL=7
 * - SD Card (HSPI): SCK=4, MISO=3, MOSI=2, CS=1
 *
 * 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 ==============
// Display pins (separate SPI bus from SD card)
#define TFT_SCK   12
#define TFT_MOSI  11
#define TFT_CS     8
#define TFT_DC     9
#define TFT_RST   10
#define TFT_BL     7

// SD Card pins (HSPI - separate from display)
#define SD_SCK     4
#define SD_MISO    3
#define SD_MOSI    2
#define SD_CS      1

#define BOOT_BTN   0    // GPIO0 is BOOT button

// ============== 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 DARKMAGENTA 0x4008

// ============== CONFIGURATION STRUCTURE ==============
struct Config {
  char ssid[33];
  char password[65];
  int webPort;
  char ftpUser[17];
  char ftpPass[17];
  int brightness;      // 0-255
  long gmtOffset;      // Timezone offset in seconds
  int dstOffset;       // DST offset in seconds
};

Config config;
Preferences prefs;

// ============== OBJECTS ==============
Arduino_DataBus *bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, TFT_SCK, TFT_MOSI, -1);
Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RST, 0, true);
SPIClass sdSPI = SPIClass(HSPI);
WebServer *server = nullptr;
DNSServer dnsServer;
FtpServer ftpSrv;

// ============== STATE VARIABLES ==============
bool configMode = false;
bool sdCardMounted = false;
unsigned long lastDisplayUpdate = 0;
unsigned long requestCount = 0;
int animFrame = 0;

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

// ============== LOAD/SAVE CONFIG ==============
void loadConfig() {
  prefs.begin("littleweb", 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());
  config.webPort = prefs.getInt("port", 80);
  strcpy(config.ftpUser, prefs.getString("ftpUser", "admin").c_str());
  strcpy(config.ftpPass, prefs.getString("ftpPass", "admin").c_str());
  config.brightness = prefs.getInt("bright", 255);
  config.gmtOffset = prefs.getLong("gmt", -21600);
  config.dstOffset = prefs.getInt("dst", 3600);

  prefs.end();
}

void saveConfig() {
  prefs.begin("littleweb", false);
  prefs.putString("ssid", config.ssid);
  prefs.putString("pass", config.password);
  prefs.putInt("port", config.webPort);
  prefs.putString("ftpUser", config.ftpUser);
  prefs.putString("ftpPass", config.ftpPass);
  prefs.putInt("bright", config.brightness);
  prefs.putLong("gmt", config.gmtOffset);
  prefs.putInt("dst", config.dstOffset);
  prefs.end();
}

// ============== 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;
}

// ============== CONFIG PAGE HTML ==============
const char* configPage = R"rawhtml(
<!DOCTYPE html>
<html>
<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>LittleWeb TF 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:#ff00ff;text-align:center;margin-bottom:20px;text-shadow:0 0 10px #ff00ff}
    .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 #00ffff;color:#fff;border-radius:4px;margin-bottom:10px;font-family:inherit}
    input:focus{outline:none;box-shadow:0 0 5px #00ffff}
    .row{display:flex;gap:10px}
    .row>*{flex:1}
    button{width:100%;padding:12px;background:#ff00ff;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 #ff00ff}
    .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}
    input[type="range"]{-webkit-appearance:none;height:8px;background:#1a1a2e;border:1px solid #00ffff}
    input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;background:#ff00ff;border-radius:50%;cursor:pointer}
    .bright-val{text-align:center;color:#ff00ff;margin-bottom:10px}
  </style>
</head>
<body>
  <h1>[LITTLEWEB 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>WEB SERVER</h2>
      <label>HTTP Port</label>
      <input type="number" name="port" value="80" min="1" max="65535">
    </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>DISPLAY</h2>
      <label>Brightness</label>
      <div class="bright-val" id="brightVal">100%</div>
      <input type="range" name="bright" id="bright" min="10" max="255" value="255" oninput="document.getElementById('brightVal').textContent=Math.round(this.value/255*100)+'%'">
    </div>

    <div class="card">
      <h2>TIMEZONE</h2>
      <label>GMT Offset (hours)</label>
      <select name="gmt">
        <option value="-43200">UTC-12</option>
        <option value="-39600">UTC-11</option>
        <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="-14400">UTC-4 (Atlantic)</option>
        <option value="-10800">UTC-3</option>
        <option value="0">UTC+0 (London)</option>
        <option value="3600">UTC+1 (Paris)</option>
        <option value="7200">UTC+2</option>
        <option value="10800">UTC+3 (Moscow)</option>
        <option value="19800">UTC+5:30 (India)</option>
        <option value="28800">UTC+8 (China)</option>
        <option value="32400">UTC+9 (Japan)</option>
        <option value="36000">UTC+10 (Sydney)</option>
      </select>
      <label>Daylight Saving</label>
      <select name="dst">
        <option value="0">No DST</option>
        <option value="3600" selected>+1 Hour DST</option>
      </select>
    </div>

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

  <script>
    function scanNetworks() {
      var box = document.getElementById('networks');
      box.innerHTML = '<div style="color:#00ffff">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();
}

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

  if (config.webPort < 1 || config.webPort > 65535) config.webPort = 80;
  if (config.brightness < 10) config.brightness = 10;

  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 = 8;
  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 drawTickMarks(int cx, int cy, int r, uint16_t color) {
  for (int i = 0; i < 12; i++) {
    float angle = i * 30 * PI / 180;
    int x1 = cx + cos(angle) * (r - 5);
    int y1 = cy + sin(angle) * (r - 5);
    int x2 = cx + cos(angle) * r;
    int y2 = cy + sin(angle) * r;
    gfx->drawLine(x1, y1, x2, y2, color);
  }
}

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

  // Outer ring decoration
  gfx->drawCircle(120, 120, 119, DARKCYAN);
  gfx->drawCircle(120, 120, 118, MAGENTA);
  gfx->drawCircle(120, 120, 117, DARKCYAN);
  drawTickMarks(120, 120, 115, DARKMAGENTA);

  // Title bar
  gfx->fillRect(30, 30, 180, 24, DARKMAGENTA);
  gfx->drawRect(30, 30, 180, 24, MAGENTA);
  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(40, 36);
  gfx->print("CONFIG MODE");

  // Divider
  gfx->drawFastHLine(35, 60, 170, CYAN);

  // Instructions box
  drawCornerBrackets(25, 70, 190, 100, CYAN);

  gfx->setTextColor(YELLOW);
  gfx->setTextSize(1);
  gfx->setCursor(40, 82);
  gfx->print("Connect WiFi to:");

  gfx->setTextColor(CYAN);
  gfx->setTextSize(1);
  gfx->setCursor(35, 100);
  gfx->print("LittleWeb-TF-Setup");

  gfx->setTextColor(DARKGRAY);
  gfx->setCursor(40, 125);
  gfx->print("Then open browser:");

  gfx->setTextColor(GREEN);
  gfx->setCursor(55, 143);
  gfx->print("192.168.4.1");

  // SD Card status
  gfx->setTextSize(1);
  gfx->setCursor(65, 185);
  if (sdCardMounted) {
    gfx->setTextColor(GREEN);
    gfx->print("[SD CARD OK]");
  } else {
    gfx->setTextColor(RED);
    gfx->print("[NO SD CARD]");
  }
}

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

  // Show on display
  drawConfigScreen();

  // Start AP
  WiFi.mode(WIFI_AP);
  WiFi.softAP("LittleWeb-TF-Setup");

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

  // Create config server on port 80
  if (server) delete server;
  server = new WebServer(80);

  // Setup routes
  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->drawCircle(120, 120, 118, CYAN);
  drawTickMarks(120, 120, 115, MAGENTA);

  gfx->setTextColor(CYAN);
  gfx->setTextSize(2);
  gfx->setCursor(40, 50);
  gfx->print("CONNECTING");

  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(40, 80);
  gfx->print("SSID: ");
  gfx->setTextColor(YELLOW);
  gfx->print(config.ssid);

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

  int attempts = 0;
  drawCornerBrackets(35, 100, 170, 30, CYAN);
  gfx->setCursor(40, 112);
  gfx->setTextColor(MAGENTA);
  gfx->print("[");

  while (WiFi.status() != WL_CONNECTED && attempts < 20) {
    delay(500);
    gfx->print("=");
    attempts++;
  }

  if (WiFi.status() == WL_CONNECTED) {
    gfx->print("]");
    gfx->setTextColor(GREEN);
    gfx->setCursor(70, 145);
    gfx->print("CONNECTED!");
    gfx->setTextColor(CYAN);
    gfx->setCursor(50, 165);
    gfx->print(WiFi.localIP());
    delay(1500);
    return true;
  }
  return false;
}

// ============== DRAW STATUS SCREEN ==============
void drawStatusScreen() {
  gfx->fillScreen(BLACK);
  gfx->drawCircle(120, 120, 119, DARKCYAN);
  gfx->drawCircle(120, 120, 118, CYAN);
  gfx->drawCircle(120, 120, 117, DARKCYAN);
  drawTickMarks(120, 120, 115, MAGENTA);
  gfx->drawCircle(120, 120, 108, DARKGRAY);

  // Title bar
  gfx->fillRect(30, 22, 180, 22, DARKMAGENTA);
  gfx->drawRect(30, 22, 180, 22, MAGENTA);
  gfx->setTextColor(WHITE);
  gfx->setTextSize(2);
  gfx->setCursor(60, 26);
  gfx->print("WEB SERVER");

  // Status indicators
  gfx->fillCircle(52, 55, 4, GREEN);
  gfx->setTextColor(GREEN);
  gfx->setTextSize(1);
  gfx->setCursor(62, 52);
  gfx->print("ON");

  if (sdCardMounted) {
    gfx->fillCircle(90, 55, 4, YELLOW);
    gfx->setTextColor(YELLOW);
  } else {
    gfx->fillCircle(90, 55, 4, RED);
    gfx->setTextColor(RED);
  }
  gfx->setCursor(100, 52);
  gfx->print("SD");

  gfx->fillCircle(130, 55, 4, CYAN);
  gfx->setTextColor(CYAN);
  gfx->setCursor(140, 52);
  gfx->print("FTP");

  drawCornerBrackets(28, 65, 184, 115, CYAN);
}

// ============== UPDATE DISPLAY ==============
void updateDisplay() {
  animFrame++;

  // Clear content area
  gfx->fillRect(30, 67, 180, 111, BLACK);

  // IP Address
  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(90, 72);
  gfx->print("NET.ADDR");

  String ip = WiFi.localIP().toString();
  if (config.webPort != 80) {
    ip += ":" + String(config.webPort);
  }
  int ipWidth = ip.length() * 6;
  gfx->setTextColor(CYAN);
  gfx->setCursor((240 - ipWidth) / 2, 84);
  gfx->print(ip);

  gfx->drawFastHLine(40, 96, 160, DARKGRAY);

  // Runtime and Requests
  unsigned long uptime = millis() / 1000;
  int hours = uptime / 3600;
  int mins = (uptime % 3600) / 60;
  int secs = uptime % 60;

  gfx->setTextColor(DARKGRAY);
  gfx->setCursor(65, 102);
  gfx->print("RUNTIME");
  gfx->setCursor(140, 102);
  gfx->print("REQ");

  char uptimeStr[12];
  sprintf(uptimeStr, "%02d:%02d:%02d", hours, mins, secs);
  gfx->setTextColor(YELLOW);
  gfx->setCursor(60, 114);
  gfx->print(uptimeStr);
  gfx->setTextColor(GREEN);
  gfx->setCursor(140, 114);
  gfx->print(requestCount);

  gfx->drawFastHLine(40, 126, 160, DARKGRAY);

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

    char timeStr[10];
    sprintf(timeStr, "%02d:%02d:%02d", hour12, timeinfo.tm_min, timeinfo.tm_sec);
    gfx->setTextColor(CYAN);
    gfx->setTextSize(2);
    gfx->setCursor(55, 135);
    gfx->print(timeStr);

    gfx->setTextSize(1);
    gfx->setTextColor(GREEN);
    gfx->setCursor(175, 145);
    gfx->print(timeinfo.tm_hour >= 12 ? "PM" : "AM");

    char dateStr[12];
    strftime(dateStr, sizeof(dateStr), "%m/%d/%Y", &timeinfo);
    gfx->setTextColor(YELLOW);
    gfx->setCursor(87, 160);
    gfx->print(dateStr);
  } else {
    gfx->setTextColor(DARKGRAY);
    gfx->setTextSize(1);
    gfx->setCursor(60, 145);
    gfx->print("SYNCING TIME...");
  }
}

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

  // Initialize SD card FIRST (before display)
  Serial.println("Initializing SD card...");
  pinMode(SD_CS, OUTPUT);
  digitalWrite(SD_CS, HIGH);
  sdSPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);

  if (!SD.begin(SD_CS, sdSPI, 4000000)) {
    Serial.println("SD Card mount failed!");
    sdCardMounted = false;
  } else {
    sdCardMounted = true;
    Serial.println("SD Card OK!");
    uint64_t cardSize = SD.cardSize() / (1024 * 1024);
    Serial.printf("SD Card Size: %lluMB\n", cardSize);
  }

  // Load saved config
  loadConfig();

  // Initialize display with brightness
  pinMode(TFT_BL, OUTPUT);
  analogWrite(TFT_BL, config.brightness);
  gfx->begin();
  gfx->setRotation(0);
  gfx->fillScreen(BLACK);

  // Setup BOOT button
  pinMode(BOOT_BTN, INPUT_PULLUP);

  // 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;
  }

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

  // Start FTP server
  ftpSrv.begin(config.ftpUser, config.ftpPass);
  Serial.println("FTP server started");
  Serial.printf("FTP User: %s\n", config.ftpUser);

  // Create web server on configured port
  server = new WebServer(config.webPort);
  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);

  drawStatusScreen();
  updateDisplay();
}

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

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

  if (millis() - lastDisplayUpdate > 1000) {
    lastDisplayUpdate = millis();
    if (WiFi.status() == WL_CONNECTED) {
      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>Little Web Server</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="container">
    <h1>LITTLE WEB SERVER</h1>
    <div class="status-box">
      <h2>System Status</h2>
      <div id="info">Loading...</div>
    </div>
  </div>
  <script>
    fetch('/api/info')
      .then(r => r.json())
      .then(data => {
        document.getElementById('info').innerHTML = `
          <p><strong>IP:</strong> ${data.ip}</p>
          <p><strong>Uptime:</strong> ${data.uptime}s</p>
          <p><strong>WiFi Signal:</strong> ${data.rssi} dBm</p>
          <p><strong>Free Heap:</strong> ${data.heap} bytes</p>
          <p><strong>SD Card:</strong> ${data.sdSize} MB</p>
        `;
      });
  </script>
</body>
</html>

SAMPLE STYLE.CSS

Copy to SD card root
/* Little Web 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: #00ffff;
  text-align: center;
  text-shadow: 0 0 10px #00ffff;
  margin-bottom: 20px;
  letter-spacing: 3px;
}

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

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

#info p {
  margin: 8px 0;
  font-size: 0.85rem;
}

#info strong {
  color: #00ffff;
}

SETUP INSTRUCTIONS

  • First Boot: ESP32 automatically enters config mode (no WiFi saved yet).
  • Force Config Mode: Hold BOOT button while powering on to enter setup anytime.
  • Connect: Join the "LittleWeb-TF-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" - ESP32 restarts with new config.

REQUIRED LIBRARIES

  • Arduino_GFX_Library - Display driver for GC9A01
  • SimpleFTPServer - FTP server for file uploads

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

SimpleFTPServer Config: Edit FtpServerKey.h in the library folder:

  • Set STORAGE_TYPE to STORAGE_SD
  • Set STORAGE_SD_FORCE_DISABLE_SEPORATECLASS to true

DIFFERENCES FROM BASIC VERSION

  • No hardcoded credentials - WiFi, FTP, and settings 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 port, FTP credentials, brightness, timezone
  • Display brightness - PWM control for backlight adjustment
  • Dynamic port - Web server port configurable (default 80)

HARDWARE CONNECTIONS

Uses separate SPI buses for display and SD card to avoid conflicts.

GC9A01 Display (SPI)

SCK → GPIO 12
MOSI → GPIO 11
CS → GPIO 8
DC → GPIO 9
RST → GPIO 10
BL → GPIO 7

SD Card (HSPI)

SCK → GPIO 4
MISO → GPIO 3
MOSI → GPIO 2
CS → GPIO 1

Important: SD card must initialize BEFORE display. The code handles this automatically.

BUILD CHECKLIST

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

  1. Create project folder: LittleWebTF_Portal/
  2. Copy Arduino sketch: Save code as LittleWebTF_Portal.ino
  3. Format SD card: FAT32 format (32GB or smaller recommended)
  4. Add web files to SD: Copy index.html and other files to SD card root
  5. Install libraries: Arduino_GFX_Library + SimpleFTPServer
  6. Configure SimpleFTPServer: Edit FtpServerKey.h (STORAGE_SD)
  7. Wire components: Connect display and SD card per wiring diagram
  8. Insert SD card: Place SD card in module before powering on
  9. Configure board: Set ESP32S3 Dev Module + USB CDC On Boot: Enabled
  10. Upload sketch: Compile and upload (hold BOOT if needed)
  11. First boot: Connect to "LittleWeb-TF-Setup" WiFi, configure at 192.168.4.1