LITTLE WEB SERVER CAPTIVE PORTAL
Web-Configurable Server - No Hardcoded Credentials
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)
(hold BOOT for config)
2
Connect to
"LittleWeb-Setup" WiFi
"LittleWeb-Setup" WiFi
3
Browser opens
config page
config page
4
Save settings
ESP32 reboots
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 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL (SCK) | GPIO 12 |
| SDA (MOSI) | GPIO 11 |
| CS | GPIO 8 |
| DC | GPIO 9 |
| RST | GPIO 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
datafolder next to your .ino file. - Add Files: Put your
index.htmland 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:
- Create project folder: LittleWeb_Portal/
- Copy Arduino sketch: Save code as LittleWeb_Portal.ino
- Create data folder: LittleWeb_Portal/data/
- Add web files: Put index.html and other files in data/
- Install library: Arduino_GFX_Library (Library Manager)
- Wire components: Connect display per wiring diagram
- Configure board: Set ESP32S3 Dev Module + USB CDC On Boot: Enabled
- Upload sketch: Compile and upload (hold BOOT if needed)
- Upload web files: Use LittleFS upload tool (Ctrl+Shift+P)
- First boot: Connect to "LittleWeb-Setup" WiFi, configure at 192.168.4.1