LITTLE WEB SERVER CAPTIVE PORTAL

Web-Configurable Server - No Hardcoded Credentials

← BASIC VERSION (Hardcoded Config)

CAPTIVE PORTAL FEATURES

>
Web Configuration Configure WiFi, server port, and brightness 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 ESP32
(hold BOOT for config)
2
Connect to
"LittleWeb-Setup" WiFi
3
Browser opens
config page
4
Save settings
ESP32 reboots

ARDUINO PROJECT SETUP

Create this folder structure for your project:

LittleWeb_Portal/
├── LittleWeb_Portal.ino    <-- Main sketch (copy from below)
└── data/                   <-- Web files folder
    ├── index.html          <-- Your website homepage
    ├── style.css           <-- Optional: CSS styles
    └── *.html, *.js, etc   <-- Additional web files
                

COMPONENTS NEEDED

  • ESP32-S3 Super Mini - Main microcontroller
  • GC9A01 Round Display - 240x240 1.28" TFT LCD (SPI)
  • Jumper Wires - For connections
  • USB-C Cable - For programming and power

WIRING DIAGRAM

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

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 (or with LittleFS)
  • Upload Speed: 921600

TROUBLESHOOTING

  • Display stays black: Check wiring, especially DC and RST pins. Verify backlight pin is connected.
  • 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.
  • LittleFS upload fails: Make sure you have the ESP32 LittleFS plugin installed.
  • Web files not serving: Ensure files are uploaded via LittleFS tool, not SD card.

ARDUINO SKETCH

LittleWeb_Portal.ino
/*
 * Little Web Server - Captive Portal Version
 * ESP32-S3 Super Mini + GC9A01 240x240 Round LCD
 *
 * Features:
 * - Web-based configuration (no hardcoded credentials)
 * - WiFi network scanning
 * - Configurable server port and display brightness
 * - 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 <LittleFS.h>
#include <Arduino_GFX_Library.h>
#include <time.h>

// ============== PIN DEFINITIONS ==============
#define TFT_SCK   12
#define TFT_MOSI  11
#define TFT_CS    8
#define TFT_DC    9
#define TFT_RST   10
#define TFT_BL    7
#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

// ============== DISPLAY 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);

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

Config config;
Preferences prefs;
WebServer* webServer = nullptr;
DNSServer dnsServer;

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

// ============== DEFAULT CONFIGURATION ==============
void setDefaults() {
  memset(&config, 0, sizeof(config));
  config.serverPort = 80;
  config.brightness = 255;
  config.gmtOffset = -21600;  // CST = UTC-6
  config.dstOffset = 3600;    // DST offset
}

// ============== 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.serverPort = prefs.getInt("port", 80);
  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.serverPort);
  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>LittleWeb 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}
    .slider-container{display:flex;align-items:center;gap:10px}
    .slider-container input[type="range"]{flex:1}
    .slider-value{color:#00ffff;min-width:40px;text-align:right}
  </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>SERVER SETTINGS</h2>
      <div class="row">
        <div>
          <label>Web Server Port</label>
          <input type="number" name="port" value="80" min="1" max="65535">
        </div>
        <div>
          <label>GMT Offset (sec)</label>
          <input type="number" name="gmt" value="-21600">
        </div>
      </div>
      <label>DST Offset (sec)</label>
      <input type="number" name="dst" value="3600">
    </div>

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

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

  <script>
    function scanNetworks() {
      var box = document.getElementById('networks');
      box.innerHTML = '<div style="color:#ff00ff">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 handleConfigRoot() {
  webServer->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) {
    webServer->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 += "]";
  webServer->send(200, "application/json", json);
  WiFi.scanDelete();
}

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

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

  saveConfig();

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

// ============== FILE SERVING (Normal Mode) ==============
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(".ico")) return "image/x-icon";
  return "text/plain";
}

bool handleFileRead(String path) {
  Serial.println("Request: " + path);
  requestCount++;
  if (path.endsWith("/")) path += "index.html";
  String contentType = getContentType(path);
  if (LittleFS.exists(path)) {
    File file = LittleFS.open(path, "r");
    webServer->streamFile(file, contentType);
    file.close();
    return true;
  }
  return false;
}

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

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 drawSignalBars(int x, int y, int rssi) {
  int bars = 0;
  if (rssi > -50) bars = 4;
  else if (rssi > -60) bars = 3;
  else if (rssi > -70) bars = 2;
  else if (rssi > -80) bars = 1;
  for (int i = 0; i < 4; i++) {
    int barHeight = 4 + (i * 3);
    uint16_t color = (i < bars) ? CYAN : DARKGRAY;
    gfx->fillRect(x + (i * 6), y + (12 - barHeight), 4, barHeight, color);
  }
}

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

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

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

  // Instructions
  gfx->setTextColor(CYAN);
  gfx->setTextSize(1);
  gfx->setCursor(48, 70);
  gfx->print("Connect WiFi to:");

  // AP Name
  gfx->setTextColor(YELLOW);
  gfx->setTextSize(2);
  gfx->setCursor(30, 90);
  gfx->print("LittleWeb");
  gfx->setCursor(60, 110);
  gfx->print("-Setup");

  // IP Address
  drawCornerBrackets(35, 140, 170, 35, CYAN);
  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(65, 145);
  gfx->print("Open browser to:");
  gfx->setTextColor(GREEN);
  gfx->setTextSize(1);
  gfx->setCursor(67, 160);
  gfx->print("192.168.4.1");

  // Bottom chevrons
  gfx->setTextColor(DARKMAGENTA);
  gfx->setCursor(55, 190);
  gfx->print(">> WAITING FOR CONFIG <<");
}

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

  // Header
  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("WEBSERVER");

  // Status indicator
  gfx->drawFastHLine(35, 48, 170, CYAN);
  gfx->fillCircle(60, 58, 4, GREEN);
  gfx->drawCircle(60, 58, 6, GREEN);
  gfx->setTextColor(GREEN);
  gfx->setTextSize(1);
  gfx->setCursor(70, 54);
  gfx->print("ONLINE");
  gfx->setTextColor(DARKGRAY);
  gfx->setCursor(130, 54);
  gfx->print("SIGNAL");

  drawCornerBrackets(28, 68, 184, 110, CYAN);
}

// ============== UPDATE DISPLAY ==============
void updateDisplay() {
  animFrame++;
  gfx->fillRect(30, 70, 180, 106, BLACK);
  gfx->drawFastHLine(35, 69, 170, DARKGRAY);
  gfx->drawFastHLine(35, 177, 170, DARKGRAY);

  int rssi = WiFi.RSSI();
  drawSignalBars(165, 52, rssi);

  // Scan line effect
  int scanY = 70 + (animFrame % 20) * 5;
  if (scanY < 175) gfx->drawFastHLine(32, scanY, 176, DARKCYAN);

  // IP Address
  gfx->setTextColor(DARKGRAY);
  gfx->setTextSize(1);
  gfx->setCursor(90, 76);
  gfx->print("NET.ADDR");
  String ip = WiFi.localIP().toString();
  int ipWidth = ip.length() * 6;
  gfx->setTextColor(CYAN);
  gfx->setCursor((240 - ipWidth) / 2, 88);
  gfx->print(ip);

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

  // Uptime 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(69, 106); gfx->print("RUNTIME");
  gfx->setCursor(135, 106); gfx->print("REQ");

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

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

  // Time display
  struct tm timeinfo;
  if (getLocalTime(&timeinfo)) {
    char timeStr[10];
    strftime(timeStr, sizeof(timeStr), "%I:%M %p", &timeinfo);
    gfx->setTextColor(CYAN);
    gfx->setTextSize(2);
    gfx->setCursor(65, 140);
    gfx->print(timeStr);

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

  // Bottom status
  gfx->setTextColor((animFrame % 2) ? MAGENTA : DARKMAGENTA);
  gfx->setTextSize(1);
  gfx->setCursor(55, 195);
  gfx->print(">> LITTLEWEB 1.0 <<");
}

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

  drawConfigScreen();

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

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

  // Create and configure web server
  webServer = new WebServer(80);
  webServer->on("/", handleConfigRoot);
  webServer->on("/scan", handleScan);
  webServer->on("/save", HTTP_POST, handleSave);
  webServer->onNotFound(handleConfigRoot);  // Redirect all to config
  webServer->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, 119, DARKCYAN);
  gfx->drawCircle(120, 120, 118, CYAN);
  drawTickMarks(120, 120, 115, MAGENTA);

  gfx->fillRect(30, 40, 180, 22, DARKCYAN);
  gfx->setTextColor(BLACK);
  gfx->setTextSize(2);
  gfx->setCursor(45, 44);
  gfx->print("CONNECTING");

  gfx->setTextColor(CYAN);
  gfx->setTextSize(1);
  gfx->setCursor(55, 80);
  gfx->print("Network: ");
  gfx->print(config.ssid);

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

  int attempts = 0;
  int barX = 50;
  gfx->drawRect(48, 110, 144, 14, CYAN);

  while (WiFi.status() != WL_CONNECTED && attempts < 20) {
    delay(500);
    gfx->fillRect(barX, 112, 6, 10, MAGENTA);
    barX += 7;
    attempts++;
  }

  if (WiFi.status() == WL_CONNECTED) {
    gfx->setTextColor(GREEN);
    gfx->setTextSize(2);
    gfx->setCursor(65, 145);
    gfx->print("CONNECTED");
    gfx->setTextColor(YELLOW);
    gfx->setTextSize(1);
    gfx->setCursor(75, 175);
    gfx->print(WiFi.localIP());
    delay(1500);
    return true;
  }
  return false;
}

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

  // Init display
  pinMode(TFT_BL, OUTPUT);
  pinMode(BOOT_BTN, INPUT_PULLUP);

  // Load saved config
  loadConfig();

  // Set brightness
  analogWrite(TFT_BL, config.brightness);

  gfx->begin();
  gfx->setRotation(0);

  // Boot screen
  gfx->fillScreen(BLACK);
  gfx->drawCircle(120, 120, 119, CYAN);
  drawTickMarks(120, 120, 115, MAGENTA);
  gfx->setTextColor(CYAN);
  gfx->setTextSize(2);
  gfx->setCursor(60, 50);
  gfx->print("LITTLEWEB");
  gfx->setTextColor(MAGENTA);
  gfx->setTextSize(1);
  gfx->setCursor(70, 85);
  gfx->print("PORTAL VERSION");

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

  // Initialize LittleFS
  if (!LittleFS.begin(true)) {
    Serial.println("LittleFS Mount Failed!");
    gfx->setTextColor(RED);
    gfx->setCursor(50, 120);
    gfx->print("FILESYSTEM ERROR");
    return;
  }

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

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

  // Create web server on configured port
  webServer = new WebServer(config.serverPort);
  webServer->onNotFound([]() {
    if (!handleFileRead(webServer->uri())) {
      webServer->send(404, "text/plain", "404: Not Found");
    }
  });
  webServer->begin();

  Serial.print("Web server started on port ");
  Serial.println(config.serverPort);

  drawStatusScreen();
  updateDisplay();
}

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

  webServer->handleClient();

  if (millis() - lastDisplayUpdate > 1000) {
    lastDisplayUpdate = millis();
    if (WiFi.status() == WL_CONNECTED) {
      updateDisplay();
    } else {
      // Lost connection - enter config mode
      startConfigMode();
    }
  }
}

SAMPLE INDEX.HTML

Upload to LittleFS data folder
<!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>
    <p class="subtitle">ESP32-S3 Super Mini + GC9A01 Round Display</p>

    <!-- Status Indicators -->
    <div class="indicators">
      <span class="indicator green">PWR</span>
      <span class="indicator" id="wifi-ind">WiFi</span>
      <span class="indicator cyan">HTTP</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-S3 Super Mini</span>
        </div>
        <div class="info-item">
          <span class="label">Display</span>
          <span class="value">GC9A01 240x240 Round</span>
        </div>
        <div class="info-item">
          <span class="label">Storage</span>
          <span class="value">LittleFS (Internal Flash)</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');

          // 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">Requests</span>
                <span class="value">${data.requests || 0}</span>
              </div>
              <div class="info-item">
                <span class="label">Chip Temp</span>
                <span class="value">${data.temp ? data.temp + '°C' : 'N/A'}</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

Upload to LittleFS data folder
/* 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;
}

.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; }
.indicator.cyan { color: #00ffff; border-color: #00ffff; box-shadow: 0 0 5px #00ffff; }

.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: #00ffff;
  font-size: 0.9rem;
}

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

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

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-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

Note: WiFi, WebServer, DNSServer, Preferences, and LittleFS are built into ESP32 Arduino core - no install needed.

UPLOADING WEB FILES

  • Create Data Folder: Create a data folder next to your .ino file.
  • Add Files: Put your index.html and any other web files inside.
  • Upload: Use LittleFS upload tool (Ctrl+Shift+P → "Upload LittleFS to Pico/ESP32").

The captive portal only handles configuration. After rebooting in normal mode, your web files from LittleFS will be served.

DIFFERENCES FROM BASIC VERSION

  • No hardcoded credentials - WiFi 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 - Server port, display brightness, timezone
  • Dynamic port - Web server runs on user-configured port

BUILD CHECKLIST

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

  1. Create project folder: LittleWeb_Portal/
  2. Copy Arduino sketch: Save code as LittleWeb_Portal.ino
  3. Create data folder: LittleWeb_Portal/data/
  4. Add web files: Put index.html and other files in data/
  5. Install library: Arduino_GFX_Library (Library Manager)
  6. Wire components: Connect display per wiring diagram
  7. Configure board: Set ESP32S3 Dev Module + USB CDC On Boot: Enabled
  8. Upload sketch: Compile and upload (hold BOOT if needed)
  9. Upload web files: Use LittleFS upload tool (Ctrl+Shift+P)
  10. First boot: Connect to "LittleWeb-Setup" WiFi, configure at 192.168.4.1