Build a Wall-Mounted Smart Clock and Weather Display (ESP32 + RTC)
A genuinely good wall clock replacement that also shows the weather — built on an ESP32 with a color display, pulling live time and forecast data over WiFi, in an enclosure you actually design rather than a bare dev board stuck to the wall. This is the project version of "my clock broke" that ends with something better than what broke.
Design Goals
The difference between "a screen showing the time" and "a nice clock for your wall" is almost entirely the physical presentation. This build treats the enclosure as seriously as the electronics — a laser-cut or CNC-routed face, a 3D printed housing, and a layout that reads clearly from across a room, not just up close.
Hardware
ComponentNotes ESP32 dev boardAny standard ESP32 works fine — this project's demands are light Round or square TFT display, 2.8"-4" A round display genuinely sells the "this is a clock" read at a glance better than a rectangular one, though either works fine functionally RTC module (DS3231)Keeps accurate time through brief power/WiFi outages via battery backup — without it, the clock loses its sense of time every time it loses power or WiFi, which for a WALL CLOCK is a real problem 5V USB power supplyWall-powered, no battery needed for a stationary wall clockGetting a Weather API Key
OpenWeatherMap's free tier is more than sufficient for a single wall display checking every 10-30 minutes — sign up at openweathermap.org, generate an API key, and note your location's coordinates (or city name) for the forecast endpoint.
Wiring
- Display: connects via SPI (MOSI, MISO, SCK, CS, DC, RST) to the ESP32 — exact pins depend on your specific display board, check its documentation
- DS3231 RTC: connects via I2C (SDA, SCL) — just two data pins plus power/ground
Core Firmware Structure
#include <WiFi.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <RTClib.h> #include <TFT_eSPI.h> RTC_DS3231 rtc; TFT_eSPI tft = TFT_eSPI(); const char* WEATHER_API_KEY = "your_key_here"; const char* LAT = "35.4"; // your latitude const char* LON = "-82.9"; // your longitude float currentTemp = 0; String currentCondition = ""; unsigned long lastWeatherFetch = 0; const unsigned long WEATHER_INTERVAL = 15 * 60 * 1000; // 15 minutes void setup() { tft.init(); tft.setRotation(0); tft.fillScreen(TFT_BLACK); Wire.begin(); rtc.begin(); // If the RTC lost power (dead coin cell, first boot), set it from compile time as a fallback if (rtc.lostPower()) { rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); } WiFi.begin("your-ssid", "your-password"); while (WiFi.status() != WL_CONNECTED) delay(500); // Sync RTC from NTP once on boot for accurate real-world time syncTimeFromNTP(); fetchWeather(); } void syncTimeFromNTP() { configTime(-5 * 3600, 0, "pool.ntp.org"); // adjust UTC offset for your timezone struct tm timeinfo; if (getLocalTime(&timeinfo)) { rtc.adjust(DateTime(timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday, timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)); } } void fetchWeather() { HTTPClient http; String url = "http://api.openweathermap.org/data/2.5/weather?lat=" + String(LAT) + "&lon=" + String(LON) + "&units=imperial&appid=" + String(WEATHER_API_KEY); http.begin(url); if (http.GET() == 200) { JsonDocument doc; deserializeJson(doc, http.getString()); currentTemp = doc["main"]["temp"]; currentCondition = doc["weather"][0]["main"].as<String>(); } http.end(); } void loop() { DateTime now = rtc.now(); if (millis() - lastWeatherFetch > WEATHER_INTERVAL) { fetchWeather(); lastWeatherFetch = millis(); } drawClockFace(now); delay(1000); }Drawing a Clean Clock Face
Rather than just printing digits, drawing an actual analog-style face (or a large, well-spaced digital layout) makes a real difference in how "finished" this looks from across a room:
void drawClockFace(DateTime now) { tft.fillScreen(TFT_BLACK); // Large centered digital time char timeStr[9]; sprintf(timeStr, "%02d:%02d:%02d", now.hour(), now.minute(), now.second()); tft.setTextSize(4); tft.setTextColor(TFT_WHITE); tft.setCursor(30, 60); tft.println(timeStr); // Date below char dateStr[16]; sprintf(dateStr, "%02d/%02d/%04d", now.month(), now.day(), now.year()); tft.setTextSize(2); tft.setCursor(50, 110); tft.println(dateStr); // Weather block tft.setTextSize(3); tft.setTextColor(TFT_CYAN); tft.setCursor(40, 160); tft.print(String((int)currentTemp) + "F"); tft.setTextSize(2); tft.setTextColor(TFT_WHITE); tft.setCursor(40, 200); tft.print(currentCondition); }Refine the layout to fit your specific display's resolution and your own visual taste — the actual positioning above is a starting point, not a fixed design.
Designing the Enclosure
This is where the project earns its place on a wall instead of a desk:
- Laser-cut face plate: cut from thin plywood, acrylic, or MDF, with a precise cutout matching your display's visible area. A slightly recessed bezel around the screen (two layers, laser-cut and glued) gives real visual depth instead of a flat cutout.
- 3D printed back housing: holds the ESP32, RTC module, and wiring, with a cable pass-through for power and a keyhole or French-cleat mounting point printed directly into the back for clean wall-hanging.
- CNC-routed hybrid option: if you want a wood-look face with genuine depth, a CNC-routed pocket for the display sitting flush inside a solid wood face gives a considerably more premium result than a flat laser-cut panel.
Design the face plate's cutout to your exact display's active area (not the full PCB size — most display boards have a visible screen area smaller than the board itself), and mock up the fit with cardboard or scrap material before committing to your final material.
Nice-to-Have Additions
AdditionValue Ambient light sensor + auto-dimmingDims the display at night so it's not glaringly bright in a dark room — genuinely important for something staying on 24/7 on a wall Multi-day forecastOpenWeatherMap's free tier includes a forecast endpoint, not just current conditions — cycle between "now" and "next 3 days" on a timer Second time zoneIf you regularly coordinate with someone elsewhere, a small secondary time readout costs almost nothing to add NTP re-sync scheduleBeyond the boot-time sync, re-sync from NTP once every 24 hours to correct any RTC drift accumulated over timeWhy the RTC Actually Matters Here
It's tempting to skip the DS3231 and just re-fetch time from NTP on every loop — but that means the clock face freezes or shows stale time the moment WiFi drops, which for a wall clock (a thing whose entire job is "always show the correct time") is a real functional failure. The RTC keeps ticking independently, accurate to within a couple seconds a month, regardless of what WiFi is doing — NTP just becomes a periodic correction rather than a hard dependency for the clock to function at all.
The end result: something that actually replaces a broken wall clock, does more than the one that broke, and looks like you meant to build it that way — because you did.
Related Guides
- Real-Time Clock Modules for Arduino and ESP32: DS3231 vs DS1307, Battery Backup, and NTP Sync
- Build a Word Clock: Laser-Cut Face, WS2812B LEDs, and ESP32
- Build a DIY Thermal Camera with ESP32 and MLX90640: Wiring, Calibration, and False-Color Display
- Driving TFT and OLED Displays with Arduino and ESP32: SSD1306, ST7789, ILI9341, and Choosing a Graphics Library
- Build a Standalone ESP32 WiFi Security Testing Tool with Marauder
- Build a Battery-Powered ESP32 Smart Mailbox Notifier
- Build a Wall-Mounted Dashboard with the ESP32 Cheap Yellow Display (CYD)
- Build a Standalone RFID Access Control Reader with a PN532 and ESP32