LITTLE WEB SERVER TF CAPTIVE PORTAL
Web-Configurable Server - No Hardcoded Credentials
CAPTIVE PORTAL FEATURES
SETUP WORKFLOW
(hold BOOT for config)
"LittleWeb-TF-Setup" WiFi
config page
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 |
|---|---|
| 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 |
SD Card Module (HSPI)
| SD Card Pin | ESP32-S3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCK | GPIO 4 |
| MISO | GPIO 3 |
| MOSI | GPIO 2 |
| CS | GPIO 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
/* * 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
<!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
/* 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 GC9A01SimpleFTPServer- 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_TYPEtoSTORAGE_SD - Set
STORAGE_SD_FORCE_DISABLE_SEPORATECLASStotrue
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:
- Create project folder: LittleWebTF_Portal/
- Copy Arduino sketch: Save code as LittleWebTF_Portal.ino
- Format SD card: FAT32 format (32GB or smaller recommended)
- Add web files to SD: Copy index.html and other files to SD card root
- Install libraries: Arduino_GFX_Library + SimpleFTPServer
- Configure SimpleFTPServer: Edit FtpServerKey.h (STORAGE_SD)
- Wire components: Connect display and SD card per wiring diagram
- Insert SD card: Place SD card in module before powering on
- Configure board: Set ESP32S3 Dev Module + USB CDC On Boot: Enabled
- Upload sketch: Compile and upload (hold BOOT if needed)
- First boot: Connect to "LittleWeb-TF-Setup" WiFi, configure at 192.168.4.1