WEATHERCLOCK CAPTIVE PORTAL

Web-Configurable Clock - No Hardcoded Credentials

← BASIC VERSION (Hardcoded Config)

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)
→
2
Connect to
"WeatherClock-Setup" WiFi
→
3
Browser opens
config page
→
4
Save settings
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_Library by moononournation - Display driver
  • Adafruit AHTX0 - AHT20 temperature/humidity sensor
  • Adafruit 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_Portal in your Arduino sketches directory (usually Documents/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

  1. □ Install Arduino IDE 3.x
  2. □ Add ESP32 board package URL and install ESP32 boards
  3. □ Install required libraries: Arduino_GFX_Library, Adafruit AHTX0, Adafruit BMP280
  4. □ Create project folder: WeatherClock_Portal/
  5. □ Copy sketch as WeatherClock_Portal.ino
  6. □ Wire GC9A01 display to ESP32-S3
  7. □ Wire AHT20+BMP280 sensor (I2C)
  8. □ Connect ESP32-S3 via USB-C
  9. □ Select ESP32S3 Dev Module and correct COM port
  10. □ Upload sketch (hold BOOT if needed)
  11. □ Connect to "WeatherClock-Setup" WiFi network
  12. □ Open 192.168.4.1 in browser
  13. □ Scan and select your WiFi network
  14. □ Set timezone, temperature units, brightness
  15. □ Click "SAVE & REBOOT" - Done!