WEATHERCLOCK CAPTIVE PORTAL
Web-Configurable Clock - No Hardcoded Credentials
CAPTIVE PORTAL FEATURES
Web Configuration
Configure WiFi, NTP server, timezone 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
Temperature Units
Choose between Celsius and Fahrenheit
Display Brightness
Adjustable backlight via PWM control
SETUP WORKFLOW
1
Power on ESP32
(hold BOOT for config)
(hold BOOT for config)
2
Connect to
"WeatherClock-Setup" WiFi
"WeatherClock-Setup" WiFi
3
Browser opens
config page
config page
4
Save settings
ESP32 reboots
ESP32 reboots
ARDUINO SKETCH
WeatherClock_Portal.ino
/* * WeatherClock - Captive Portal Version * ESP32-S3 Super Mini + GC9A01 1.28" Round LCD (240x240) + AHT20/BMP280 * * Features: * - Web-based configuration (no hardcoded credentials) * - WiFi network scanning * - Configurable: WiFi, NTP server, timezone, temp units, brightness * - Settings persist in flash memory * * Config Mode Triggers: * - First boot (no saved WiFi) * - Hold BOOT button during power-on */ #include <WiFi.h> #include "time.h" #include <Wire.h> #include <WebServer.h> #include <DNSServer.h> #include <Preferences.h> #include <Arduino_GFX_Library.h> #include <Adafruit_AHTX0.h> #include <Adafruit_BMP280.h> // ============== PIN DEFINITIONS ============== // Display (GC9A01 240x240 Round) #define TFT_SCK 12 #define TFT_MOSI 11 #define TFT_CS 8 #define TFT_DC 9 #define TFT_RST 10 #define TFT_BL 7 // I2C (Sensors) #define I2C_SDA 5 #define I2C_SCL 6 // BOOT button for config mode #define BOOT_BTN 0 // ============== DISPLAY SETUP ============== 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); // ============== SENSORS ============== Adafruit_AHTX0 aht; Adafruit_BMP280 bmp; // ============== SCREEN CONSTANTS ============== const int SCREEN_W = 240; const int SCREEN_H = 240; const int CX = SCREEN_W / 2; const int CY = SCREEN_H / 2; const int OUTER_R = 110; const int SMALL_R = 22; // ============== COLORS (RGB565) ============== #define COLOR_BG 0x0000 #define COLOR_TEXT 0xFFFF #define COLOR_TICK_BG 0xBDF7 #define COLOR_TICK_15 0x4208 #define COLOR_ELAPSED 0xF81F #define COLOR_DATE 0x07FF #define COLOR_RING_TEMP 0x07E0 #define COLOR_RING_HUM 0x5D9B #define COLOR_RING_ALT 0xC618 #define COLOR_CONFIG 0xFFE0 // ============== CONFIGURATION STRUCTURE ============== struct Config { char ssid[33]; char password[65]; char ntpServer[64]; int gmtOffset; // hours from UTC int dstOffset; // 0 or 1 hour bool useFahrenheit; int brightness; // 0-255 }; Config config; Preferences prefs; WebServer server(80); DNSServer dnsServer; // ============== STATE VARIABLES ============== bool configMode = false; int last_minute = -1; int last_day = -1; float lastTemp = 0, lastHum = 0, lastAlt = 0; char lastTimeStr[8] = ""; // ============== DEFAULT CONFIGURATION ============== void setDefaults() { memset(&config, 0, sizeof(config)); strcpy(config.ntpServer, "pool.ntp.org"); config.gmtOffset = -6; // CST config.dstOffset = 1; // DST active config.useFahrenheit = true; config.brightness = 255; } // ============== LOAD/SAVE CONFIG ============== void loadConfig() { prefs.begin("wclock", true); if (!prefs.isKey("ssid")) { prefs.end(); setDefaults(); return; } strcpy(config.ssid, prefs.getString("ssid", "").c_str()); strcpy(config.password, prefs.getString("pass", "").c_str()); strcpy(config.ntpServer, prefs.getString("ntp", "pool.ntp.org").c_str()); config.gmtOffset = prefs.getInt("gmt", -6); config.dstOffset = prefs.getInt("dst", 1); config.useFahrenheit = prefs.getBool("useF", true); config.brightness = prefs.getInt("bright", 255); prefs.end(); } void saveConfig() { prefs.begin("wclock", false); prefs.putString("ssid", config.ssid); prefs.putString("pass", config.password); prefs.putString("ntp", config.ntpServer); prefs.putInt("gmt", config.gmtOffset); prefs.putInt("dst", config.dstOffset); prefs.putBool("useF", config.useFahrenheit); prefs.putInt("bright", config.brightness); 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>WeatherClock 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:#00ffff;text-align:center;margin-bottom:20px;text-shadow:0 0 10px #00ffff} .card{background:#12121f;border:1px solid #333;border-radius:8px;padding:15px;margin-bottom:15px} .card h2{color:#ff00ff;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:#00ffff;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 #00ffff} .scan-btn{background:transparent;border:1px solid #ff00ff;color:#ff00ff;padding:8px;margin-bottom:10px} .scan-btn:hover{background:#ff00ff;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} .toggle{display:flex;gap:10px;margin-bottom:10px} .toggle label{flex:1;text-align:center;padding:8px;border:1px solid #333;cursor:pointer} .toggle input{display:none} .toggle input:checked+label{background:#00ffff;color:#000;border-color:#00ffff} </style> </head> <body> <h1>[WEATHERCLOCK]</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>TIME SETTINGS</h2> <label>NTP Server</label> <input type="text" name="ntp" value="pool.ntp.org" maxlength="63"> <div class="row"> <div> <label>GMT Offset (hours)</label> <select name="gmt"> <option value="-12">UTC-12</option> <option value="-11">UTC-11</option> <option value="-10">UTC-10 (Hawaii)</option> <option value="-9">UTC-9 (Alaska)</option> <option value="-8">UTC-8 (Pacific)</option> <option value="-7">UTC-7 (Mountain)</option> <option value="-6" selected>UTC-6 (Central)</option> <option value="-5">UTC-5 (Eastern)</option> <option value="-4">UTC-4 (Atlantic)</option> <option value="-3">UTC-3</option> <option value="-2">UTC-2</option> <option value="-1">UTC-1</option> <option value="0">UTC+0 (London)</option> <option value="1">UTC+1 (Paris)</option> <option value="2">UTC+2</option> <option value="3">UTC+3 (Moscow)</option> <option value="4">UTC+4</option> <option value="5">UTC+5</option> <option value="6">UTC+6</option> <option value="7">UTC+7</option> <option value="8">UTC+8 (China)</option> <option value="9">UTC+9 (Japan)</option> <option value="10">UTC+10 (Sydney)</option> <option value="11">UTC+11</option> <option value="12">UTC+12 (NZ)</option> </select> </div> <div> <label>Daylight Saving</label> <select name="dst"> <option value="0">Off</option> <option value="1" selected>On (+1 hour)</option> </select> </div> </div> </div> <div class="card"> <h2>DISPLAY SETTINGS</h2> <label>Temperature Units</label> <div class="toggle"> <input type="radio" name="unit" value="C" id="unitC"><label for="unitC">CELSIUS</label> <input type="radio" name="unit" value="F" id="unitF" checked><label for="unitF">FAHRENHEIT</label> </div> <label>Display Brightness (10-255)</label> <input type="range" name="bright" min="10" max="255" value="255" oninput="this.nextElementSibling.textContent=this.value"> <span style="color:#00ffff">255</span> </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 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()); strcpy(config.ntpServer, server.arg("ntp").c_str()); config.gmtOffset = server.arg("gmt").toInt(); config.dstOffset = server.arg("dst").toInt(); config.useFahrenheit = (server.arg("unit") == "F"); config.brightness = server.arg("bright").toInt(); if (strlen(config.ntpServer) == 0) { strcpy(config.ntpServer, "pool.ntp.org"); } 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(); } // ============== START CONFIG MODE ============== void startConfigMode() { configMode = true; Serial.println("Starting Config Mode..."); // Show CONFIG MODE on display gfx->fillScreen(COLOR_BG); gfx->setTextColor(COLOR_CONFIG); gfx->setTextSize(2); // Draw border circle gfx->drawCircle(CX, CY, 115, COLOR_CONFIG); gfx->drawCircle(CX, CY, 114, COLOR_CONFIG); // Title gfx->setCursor(30, 60); gfx->print("CONFIG MODE"); // Instructions gfx->setTextSize(1); gfx->setTextColor(COLOR_TEXT); gfx->setCursor(40, 100); gfx->print("Connect WiFi to:"); gfx->setTextColor(COLOR_DATE); gfx->setTextSize(2); gfx->setCursor(10, 120); gfx->print("WeatherClock"); gfx->setCursor(60, 140); gfx->print("-Setup"); gfx->setTextSize(1); gfx->setTextColor(COLOR_TEXT); gfx->setCursor(55, 175); gfx->print("Then open:"); gfx->setTextColor(COLOR_RING_TEMP); gfx->setCursor(45, 190); gfx->print("192.168.4.1"); // Start AP WiFi.mode(WIFI_AP); WiFi.softAP("WeatherClock-Setup"); // Start DNS (captive portal) dnsServer.start(53, "*", WiFi.softAPIP()); // Start web server server.on("/", handleRoot); server.on("/scan", handleScan); server.on("/save", HTTP_POST, handleSave); server.onNotFound(handleRoot); server.begin(); Serial.println("AP IP: 192.168.4.1"); } // ============== CONNECT WiFi ============== bool connectWiFi() { if (strlen(config.ssid) == 0) return false; // Show connecting screen gfx->fillScreen(COLOR_BG); gfx->drawCircle(CX, CY, 115, COLOR_DATE); gfx->setTextColor(COLOR_DATE); gfx->setTextSize(2); gfx->setCursor(40, 80); gfx->print("CONNECTING"); gfx->setTextSize(1); gfx->setTextColor(COLOR_TEXT); gfx->setCursor(50, 110); gfx->print("SSID: "); gfx->print(config.ssid); WiFi.mode(WIFI_STA); WiFi.begin(config.ssid, config.password); int attempts = 0; int barX = 40, barY = 140, barW = 160, barH = 16; gfx->drawRect(barX, barY, barW, barH, COLOR_DATE); while (WiFi.status() != WL_CONNECTED && attempts < 20) { delay(500); int fillW = (barW - 4) * attempts / 20; gfx->fillRect(barX + 2, barY + 2, fillW, barH - 4, COLOR_DATE); attempts++; } if (WiFi.status() == WL_CONNECTED) { gfx->fillScreen(COLOR_BG); gfx->drawCircle(CX, CY, 115, COLOR_RING_TEMP); gfx->setTextColor(COLOR_RING_TEMP); gfx->setTextSize(2); gfx->setCursor(50, 100); gfx->print("CONNECTED"); gfx->setTextSize(1); gfx->setCursor(55, 130); gfx->print(WiFi.localIP()); delay(1000); return true; } return false; } // ============== READ SENSORS ============== void readSensors() { sensors_event_t hum, temp; aht.getEvent(&hum, &temp); if (config.useFahrenheit) { lastTemp = temp.temperature * 9.0 / 5.0 + 32.0; } else { lastTemp = temp.temperature; } lastHum = hum.relative_humidity; lastAlt = bmp.readAltitude(1013.25); } // ============== DISPLAY FUNCTIONS ============== void drawOuterRing() { const int THICKNESS = 6, HALF = 3; struct tm ti; if (!getLocalTime(&ti)) return; // Light grey tick marks for all 60 minutes for (int i = 0; i < 360; i += 6) { float a = (i - 90) * PI / 180.0; int x1 = CX + (OUTER_R - HALF) * cos(a); int y1 = CY + (OUTER_R - HALF) * sin(a); int x2 = CX + (OUTER_R + HALF) * cos(a); int y2 = CY + (OUTER_R + HALF) * sin(a); gfx->drawLine(x1, y1, x2, y2, COLOR_TICK_BG); } // 15-minute markers for (int m = 0; m < 60; m += 15) { float a = (m * 6 - 90) * PI / 180.0; int x1 = CX + (OUTER_R - HALF) * cos(a); int y1 = CY + (OUTER_R - HALF) * sin(a); int x2 = CX + (OUTER_R + HALF) * cos(a); int y2 = CY + (OUTER_R + HALF) * sin(a); gfx->fillCircle(x1, y1, HALF + 2, COLOR_TICK_15); gfx->fillCircle(x2, y2, HALF + 2, COLOR_TICK_15); } // Magenta marks for elapsed minutes for (int m = 0; m < ti.tm_min; m++) { float a = (m * 6 - 90) * PI / 180.0; for (int t = -HALF; t <= HALF; t++) { gfx->drawLine( CX + (OUTER_R - HALF + t) * cos(a), CY + (OUTER_R - HALF + t) * sin(a), CX + (OUTER_R + HALF + t) * cos(a), CY + (OUTER_R + HALF + t) * sin(a), COLOR_ELAPSED); } } } void addMinuteTick(int minute) { if (minute < 0 || minute >= 60) return; const int THICKNESS = 6, HALF = 3; float a = (minute * 6 - 90) * PI / 180.0; int x1 = CX + (OUTER_R - HALF) * cos(a); int y1 = CY + (OUTER_R - HALF) * sin(a); int x2 = CX + (OUTER_R + HALF) * cos(a); int y2 = CY + (OUTER_R + HALF) * sin(a); for (int t = -HALF; t <= HALF; t++) { gfx->drawLine( CX + (OUTER_R - HALF + t) * cos(a), CY + (OUTER_R - HALF + t) * sin(a), CX + (OUTER_R + HALF + t) * cos(a), CY + (OUTER_R + HALF + t) * sin(a), COLOR_ELAPSED); } gfx->fillCircle(x1, y1, HALF + 1, COLOR_ELAPSED); gfx->fillCircle(x2, y2, HALF + 1, COLOR_ELAPSED); } void drawMiniCircle(int cx, int cy, int r, float value, float vmin, float vmax, const char* unit, uint16_t color) { int newR = r + 6; gfx->fillCircle(cx, cy, newR, 0x1082); gfx->drawCircle(cx, cy, newR, color); float ratio = constrain((value - vmin) / (vmax - vmin), 0, 1); int filled = (int)(ratio * 50); for (int i = 0; i <= filled; i++) { float a = (-90 + i * 7.2) * PI / 180.0; gfx->drawLine(cx + (newR - 7) * cos(a), cy + (newR - 7) * sin(a), cx + (newR - 2) * cos(a), cy + (newR - 2) * sin(a), color); } char buf[16]; snprintf(buf, sizeof(buf), "%.0f%s", value, unit); gfx->setTextSize(1); gfx->setTextColor(COLOR_TEXT); int textWidth = strlen(buf) * 6; gfx->setCursor(cx - textWidth / 2, cy - 4); gfx->print(buf); } void drawCentralTime() { struct tm ti; if (!getLocalTime(&ti)) return; char buf[8]; int hour12 = ti.tm_hour % 12; if (hour12 == 0) hour12 = 12; snprintf(buf, sizeof(buf), "%2d:%02d", hour12, ti.tm_min); gfx->setTextSize(4); gfx->setTextColor(COLOR_TEXT); int textWidth = strlen(buf) * 24; gfx->setCursor(CX - textWidth / 2 - 10, CY + 15); gfx->print(buf); // AM/PM indicator gfx->setTextSize(1); gfx->setTextColor(COLOR_RING_TEMP); gfx->setCursor(CX + textWidth / 2 - 10, CY + 15); gfx->print(ti.tm_hour >= 12 ? "PM" : "AM"); strcpy(lastTimeStr, buf); } void drawDateLine() { struct tm ti; if (!getLocalTime(&ti)) return; const char *wdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}; char dstr[32]; snprintf(dstr, sizeof(dstr), "%s %02d/%02d/%04d", wdays[ti.tm_wday], ti.tm_mday, ti.tm_mon + 1, ti.tm_year + 1900); gfx->setTextSize(1); gfx->setTextColor(COLOR_DATE); int textWidth = strlen(dstr) * 6; gfx->setCursor(CX - textWidth / 2, CY + 50); gfx->print(dstr); } void updateTimeOnly() { struct tm ti; if (!getLocalTime(&ti)) return; char newTime[8]; int hour12 = ti.tm_hour % 12; if (hour12 == 0) hour12 = 12; snprintf(newTime, sizeof(newTime), "%2d:%02d", hour12, ti.tm_min); // Erase old time in black gfx->setTextSize(4); if (lastTimeStr[0] != '\0') { int textWidth = strlen(lastTimeStr) * 24; gfx->setTextColor(COLOR_BG); gfx->setCursor(CX - textWidth / 2 - 10, CY + 15); gfx->print(lastTimeStr); } // Draw new time in white int textWidth = strlen(newTime) * 24; gfx->setTextColor(COLOR_TEXT); gfx->setCursor(CX - textWidth / 2 - 10, CY + 15); gfx->print(newTime); // AM/PM indicator gfx->setTextSize(1); gfx->setTextColor(COLOR_RING_TEMP); gfx->setCursor(CX + textWidth / 2 - 10, CY + 15); gfx->print(ti.tm_hour >= 12 ? "PM" : "AM"); strcpy(lastTimeStr, newTime); } void drawFullFace(bool full) { readSensors(); gfx->fillScreen(COLOR_BG); drawOuterRing(); const char* tempUnit = config.useFahrenheit ? "F" : "C"; float tempMin = config.useFahrenheit ? 14 : -10; float tempMax = config.useFahrenheit ? 122 : 50; drawMiniCircle(CX - 60, CY - 40, SMALL_R, lastTemp, tempMin, tempMax, tempUnit, COLOR_RING_TEMP); drawMiniCircle(CX, CY - 70, SMALL_R, lastHum, 0, 100, "%", COLOR_RING_HUM); drawMiniCircle(CX + 60, CY - 40, SMALL_R, lastAlt, -50, 3000, "m", COLOR_RING_ALT); drawCentralTime(); drawDateLine(); } // ============== SETUP ============== void setup() { Serial.begin(115200); delay(100); Serial.println("\n=== WeatherClock - Captive Portal Version ==="); // Init backlight pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, HIGH); // Init BOOT button pinMode(BOOT_BTN, INPUT_PULLUP); // Load saved config loadConfig(); // Apply brightness analogWrite(TFT_BL, config.brightness); // Init display gfx->begin(); gfx->setRotation(0); gfx->fillScreen(COLOR_BG); // Init I2C and sensors Wire.begin(I2C_SDA, I2C_SCL); aht.begin(); bmp.begin(0x77); bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, Adafruit_BMP280::SAMPLING_X2, Adafruit_BMP280::SAMPLING_X16, Adafruit_BMP280::FILTER_X16, Adafruit_BMP280::STANDBY_MS_250); // 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 with saved settings configTime(config.gmtOffset * 3600, config.dstOffset * 3600, config.ntpServer); // Initial display drawFullFace(true); } // ============== MAIN LOOP ============== void loop() { if (configMode) { dnsServer.processNextRequest(); server.handleClient(); return; } struct tm ti; if (!getLocalTime(&ti)) return; if (ti.tm_min != last_minute) { if (ti.tm_min == 0) { drawFullFace(true); // New hour - full redraw } else { addMinuteTick(last_minute); updateTimeOnly(); } last_minute = ti.tm_min; } delay(200); }
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 "WeatherClock-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_Libraryby moononournation - Display driverAdafruit AHTX0- AHT20 temperature/humidity sensorAdafruit BMP280- BMP280 pressure/altitude sensor
Note: WebServer, DNSServer, and Preferences are built into ESP32 Arduino core - no install needed.
HARDWARE PINOUT
| Display (GC9A01) | Sensors (I2C) |
| SCK = GPIO 12 | SDA = GPIO 5 |
| MOSI = GPIO 11 | SCL = GPIO 6 |
| CS = GPIO 8 | |
| DC = GPIO 9 | |
| RST = GPIO 10 | |
| BL = GPIO 7 |
DIFFERENCES FROM BASIC VERSION
- No hardcoded credentials - WiFi 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 timezone - Select from UTC-12 to UTC+12
- Temperature units - Choose Celsius or Fahrenheit
- Brightness control - Adjustable display backlight
- Uses Arduino_GFX_Library - Instead of Adafruit_GC9A01A for better performance
ARDUINO PROJECT SETUP
- Create Project Folder: Create a folder named
WeatherClock_Portalin your Arduino sketches directory (usuallyDocuments/Arduino/) - Main Sketch: Save the code as
WeatherClock_Portal.ino- filename MUST match folder name - Single File Project: This project uses only one .ino file - no additional .h files required
WeatherClock_Portal/
└── WeatherClock_Portal.ino // Complete sketch (copy the code above)
COMPONENTS NEEDED
| ESP32-S3 Super Mini | Main microcontroller | AliExpress |
| GC9A01 Round Display | 240x240 1.28" TFT | Amazon |
| AHT20+BMP280 Module | Combined temp/humidity/pressure sensor | Amazon |
| Dupont Wires | For connections | Amazon |
WIRING DIAGRAM
| GC9A01 Display | ESP32-S3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL (SCK) | GPIO 12 |
| SDA (MOSI) | GPIO 11 |
| RES (RST) | GPIO 10 |
| DC | GPIO 9 |
| CS | GPIO 8 |
| BLK (Backlight) | GPIO 7 |
| AHT20+BMP280 Sensor | ESP32-S3 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SDA | GPIO 5 |
| SCL | GPIO 6 |
ARDUINO IDE 3.x BOARD SETTINGS
| Board | ESP32S3 Dev Module |
| USB CDC On Boot | Enabled |
| Flash Size | 4MB (32Mb) |
| Partition Scheme | Default 4MB with spiffs |
| Upload Speed | 921600 |
ESP32 Board URL: Add this in File → Preferences → Additional Board Manager URLs:
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
TROUBLESHOOTING
- Upload fails / Guru Meditation Error: Hold BOOT button while clicking Upload, release after "Connecting..." appears.
- Display is white/blank: Check wiring, especially CS, DC, and RST pins. Verify 3.3V power.
- Can't find "WeatherClock-Setup" network: Wait 10 seconds after power on. Hold BOOT to force config mode.
- Captive portal doesn't auto-open: Manually navigate to 192.168.4.1 in your browser.
- WiFi won't connect after config: Double-check SSID and password. ESP32 only supports 2.4GHz networks.
- Settings not saved: Make sure you click "SAVE & REBOOT" button. Check Serial Monitor for errors.
- Time shows 00:00: Check WiFi connection and NTP server. Verify timezone is set in config.
- Sensor shows 0 or NaN: Check I2C wiring (SDA to GPIO 5, SCL to GPIO 6). Run I2C scanner.
- Serial Monitor empty: Set baud rate to 115200. Enable "USB CDC On Boot" in board settings.
BUILD CHECKLIST
- □ Install Arduino IDE 3.x
- □ Add ESP32 board package URL and install ESP32 boards
- □ Install required libraries: Arduino_GFX_Library, Adafruit AHTX0, Adafruit BMP280
- □ Create project folder:
WeatherClock_Portal/ - □ Copy sketch as
WeatherClock_Portal.ino - □ Wire GC9A01 display to ESP32-S3
- □ Wire AHT20+BMP280 sensor (I2C)
- □ Connect ESP32-S3 via USB-C
- □ Select ESP32S3 Dev Module and correct COM port
- □ Upload sketch (hold BOOT if needed)
- □ Connect to "WeatherClock-Setup" WiFi network
- □ Open 192.168.4.1 in browser
- □ Scan and select your WiFi network
- □ Set timezone, temperature units, brightness
- □ Click "SAVE & REBOOT" - Done!