- ESP32 with 320 x 240 2.8" LCD display (ESP32-Cheap-Yellow-Display)
There are two ways to get the firmware onto a device:
- Flash a released image (next section) — no toolchain or libraries needed, just a USB cable. Start here if you only want to run the clock.
- Build from source (see "Building" below) — needed if you want to change
the code, preconfigure WiFi credentials or set an OTA password. Copy
secrets.h.exampletosecrets.hfirst and fill in your values (secrets.his git-ignored so your credentials are never committed). All three toolchains use the vendoredlibraries/folder in this repo (its TFT_eSPI copy carries the display config for the CYD), so no library installation is needed except for the Arduino IDE.
Each GitHub release has a
esp32worldclock-<tag>-factory.bin attached: a full flash image (bootloader +
partition table + app) that brings a brand-new ESP32 to a working clock in one
step.
-
Download
esp32worldclock-<tag>-factory.binfrom the latest release. -
Connect the CYD over USB. If no serial port appears, install the CH340 USB-to-UART driver (the port shows up as
COMxon Windows,/dev/ttyUSB0on Linux,/dev/tty.usbserial-*on macOS). -
Flash the image to offset
0x0with esptool (pip install esptool):esptool.py --chip esp32 --port COM3 --baud 921600 write_flash 0x0 esp32worldclock-<tag>-factory.binReplace
COM3with your port. No install needed alternative: open Espressif's web flasher in Chrome/Edge, connect, and program the same file at address0x0. -
Press reset (or replug USB). The released binaries are built without WiFi credentials, so the device starts the
esp32Projectcaptive portal — see "Connect Wifi" below to get it online.
Flashing at 0x0 replaces the bootloader, partition table and app, so it also
works to recover a device in a bad state or one running different firmware.
If a previous project left settings behind that you want gone, run
esptool.py --chip esp32 --port COM3 erase_flash first (this also wipes any
stored WiFi credentials).
Once the device is on WiFi, future releases don't need the cable: upload the
release's esp32worldclock-<tag>-ota.bin through the web updater at
http://esp32worldclock.local/update (see "Over-the-air updates" below).
Settings and WiFi credentials survive OTA updates.
PlatformIO (fastest incremental builds):
pio run # build
pio run -t upload # build + flash
pio device monitor # serial monitor (115200 baud)
arduino-cli:
arduino-cli compile --fqbn esp32:esp32:esp32:PartitionScheme=min_spiffs --libraries libraries .
arduino-cli upload -p COM3 --fqbn esp32:esp32:esp32:PartitionScheme=min_spiffs .
Arduino IDE:
- Install Arduino IDE and CH340 USB to UART Driver
- Copy
librariestoC:\Users\[YOU_USER_NAME]\Documents\Arduino\libraries - Select board "ESP32 Dev Module" and set Tools → Partition Scheme → "Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS)", then build and upload
Every push and pull request is build-verified by GitHub Actions
(.github/workflows/build.yml), which uploads
the compiled images as a workflow artifact. To publish a release with
firmware attached, push a version tag:
git tag v1.0.0
git push origin v1.0.0
The release gets two files: esp32worldclock-<tag>-ota.bin (app image —
upload it straight through the web updater below, no toolchain needed) and
esp32worldclock-<tag>-factory.bin (bootloader + partition table + app, for
a first USB flash — see "Flashing a release" above). CI builds from
secrets.h.example, so release binaries contain no WiFi credentials — a
freshly flashed device opens the captive portal, and OTA-updated devices
keep their stored settings.
Once a build with OTA support is on the device, later updates can go over
WiFi — no USB cable needed. The device advertises itself as esp32worldclock
on mDNS (the hostname is configurable on the web settings page, so two clocks
on one network don't collide; its IP is also shown on the System status
page), and shows a
progress bar on the display during the transfer before rebooting into the
new firmware. There are two ways in:
- Web page (no tools needed): browse to
http://esp32worldclock.local/update(or follow the Firmware update link from the settings page athttp://<device-ip>/), pick a compiled firmware image and press Update firmware. The.binto upload is.pio/build/cyd/firmware.binfor PlatformIO, the exported binary from Arduino IDE → Sketch → Export Compiled Binary, or add--output-dir buildto thearduino-cli compilecommand. The page shows upload progress and the running build's compile timestamp. - PlatformIO / espota: uncomment the
espotalines inplatformio.iniandpio run -t uploadas usual. In the Arduino IDE, pick the network port namedesp32worldclockunder Tools → Port.
OTA is unauthenticated by default (anyone on your WiFi can flash the
device); set OTA_PASSWORD in secrets.h to protect both paths — the web
page then asks for HTTP Basic credentials (username admin), and espota
needs upload_flags = --auth=<password>.
Display settings (timezones, formats, brightness, face) and WiFi credentials survive OTA updates — only the app partition is rewritten.
All three toolchains should use the Minimal SPIFFS partition scheme (two
1.9MB OTA app slots + 128KB SPIFFS). PlatformIO picks it up automatically from
platformio.ini; for arduino-cli / Arduino IDE it is selected as shown above.
The firmware fills ~96% of the default scheme's 1.31MB app slots, so this
scheme is what leaves room for the binary to grow (the sketch keeps only a
<1KB config JSON in SPIFFS, so the smaller filesystem costs nothing).
Note: the first flash after switching partition schemes relocates the SPIFFS region, so the on-device display settings (timezones, clock/date format, brightness) reset to defaults once. WiFi credentials and the timezone cache live in NVS and survive.
There are two ways to get the clock online:
- Preconfigured credentials (optional): Set
PRECONFIGURED_SSID/PRECONFIGURED_PASSWORDin your localsecrets.h. On boot the device tries these first (up to 10 attempts, 5 seconds each). Leave the placeholders unchanged to skip this. - WiFiManager captive portal (fallback): If the preconfigured connection
fails — or you double-press reset to force config mode — the device starts a
captive portal. Connect to SSID
esp32Project(password12345678) and use the portal to enter your WiFi, time zone, 24-hour clock and US date format preferences. These are saved to flash for next boot.
If nobody uses the portal within 5 minutes, the device reboots and retries the whole sequence (preconfigured credentials first). So after a power cut where the router comes back later than the clock, the clock reconnects on its own — no button pressing needed.
If WiFi drops while the clock is running, the clock keeps ticking (time
runs locally between NTP syncs) and recovers in stages: after a minute
offline a steady NO WIFI label appears at the bottom of the home screen
(and the System status page shows the WiFi row in red), every 3 minutes an
explicit reconnect is kicked in case the WiFi stack's auto-reconnect has
wedged, and after 30 minutes offline the device reboots into the full boot
recovery sequence above. A clock that lost its network during a multi-hour
router outage therefore rejoins on its own once the router is back.
The home screen is split into three touch zones (the same on every clock face):
| Zone | Action |
|---|---|
| Left third | Decrease backlight brightness |
| Center third | Open the Settings page |
| Right third | Increase backlight brightness |
The home screen has five faces, cycled with the Clock face button on the settings page (the choice is saved to flash):
- World clock — the classic four-quadrant view: one timezone per quadrant
with date, day-offset vs. home and stock market status. On a public
holiday in a zone's country, that quadrant's day line turns gold and shows
the holiday's name next to the day (e.g.
WED - INDEPENDENCE DAY); the date stays visible as usual. Each zone's colors follow the sun's real position at that city (computed from its coordinates — no network needed): daytime is orange/yellow from actual sunrise to actual sunset, so London correctly reads as night at 4:30 PM in December and as day at 9 PM in June; after sunset the zone dims to grey (light before local midnight, dark in the small hours). - Big clock — the home zone (top-left quadrant) in 75px digits with date and market status, plus a mini strip of the other three zones' times along the bottom.
- Calendar — a month calendar for the home zone with today highlighted,
and the current time in the header. Public holidays are marked in gold
(today's highlight box also turns gold on a holiday), and a footer line
names today's holiday — or the next upcoming one (
NEXT: 25 DEC - CHRISTMAS DAY). - Weather — current temperature and conditions for all four configured cities (with each city's local time), from the free Open-Meteo API — no API key needed. A background task fetches every 20 minutes regardless of which face is showing, so the data is ready the moment the face opens and the clock never pauses. Weather is only available for cities picked from the preset timezone list, since it needs their coordinates.
- Markets — every exchange the clock knows about (NYSE, LSE, SSE, TSE, HKEX) at a glance, independent of which cities occupy the quadrants: one row per exchange with its local time and the same colored open/closed/countdown status as the quadrant view. These rows tick on built-in timezone rules, so the face works even without the timezone server.
-
Change timezones — tap any of the four clock slots, then pick a city from the paged timezone list. Cities with a stock exchange (New York, London, Beijing, Tokyo, Hong Kong) automatically show that market's trading status, color-coded: green while the exchange is open, yellow with a countdown when the next regular open is less than 24 hours away (e.g.
NYSE OPENS IN 5H 03M), and a plain redCLOSEDwhen the open is further out (weekends viewed early, long holiday closures — multi-day countdowns like2D 8Hwere too easy to misread as a time). Full-day exchange holidays are respected — the status shows closed on holidays and the countdown skips them — and so are half-day early closes (NYSE Black Friday / Christmas Eve 1 PM, LSE 12:30 on Christmas/New Year's Eve, HKEX noon closes): the status flips to closed at the early-close time instead of running hours long. The selection is saved to flash and restored on boot.Exchange holiday calendars keep themselves current: once a week the device fetches
marketHolidays.jsonfrom this repository over HTTPS and caches it in flash, so updating that file (when an exchange publishes next year's schedule) reaches every clock within a week — no reflash needed. The file carries full-day closures ("holidays", YYYYMMDD integers) and half-day early closes ("earlyCloses","YYYYMMDD:HHMM"strings, e.g."20261224:1300"for a 1 PM close). Compiled-in tables (2026–2027 for NYSE/LSE, 2026 for SSE/TSE/HKEX, inmarketHolidays.cpp) serve as the offline fallback. TypeHOLIDAYSin the serial monitor to inspect the active calendars or force a refetch, and setMARKET_HOLIDAYS_URLinsecrets.hto point a forked device at your own copy of the file. -
Clock face — cycle between the four home-screen faces (see above).
-
Clock format — toggle between 24-hour and 12-hour (AM/PM) display.
-
Date format — toggle between
DD/MM/YYandMM/DD/YY. -
Brightness —
-/+buttons adjust the backlight (also pauses auto-brightness for 2 hours, same as the home-screen gesture). The level is saved and restored on the next boot, and is used as the daytime target by auto-brightness. -
System status — opens a live diagnostics page.
-
Logs — shows the most recent log lines right on the display (see below).
The world-clock quadrants and the calendar face mark each zone's public
holidays by name (see the face descriptions above). The names come from the
free Nager.Date API — no key needed — fetched in the
background per zone country (one small request per country-year: on boot,
weekly, when a zone changes and at the year rollover), so the clock itself
never pauses. Only nationwide holidays are shown; regional ones are filtered
out. Dubai and Mumbai have no calendars on that API, so those zones simply
show no holidays. Type HOLIDAYS in the serial monitor to see what data
each zone currently has.
Everything on the settings page can also be changed from a browser: go to
http://esp32worldclock.local/ (or the device IP shown on the System status
page) to pick the four timezones, clock face, clock/date format and
brightness without touching the device. The web page additionally exposes a
few settings that have no on-device UI:
- Night dimming — the backlight level used at night (default: minimum) and the fallback dim window (default 1–7 AM home-zone time, used when the light sensor is unavailable; the window may wrap midnight, and equal start/end hours disable it).
- Hostname — the mDNS name the device advertises (
<hostname>.local, defaultesp32worldclock). Change it when running two clocks on one network; it is applied on the next reboot. - Config backup / restore — the Backup config link downloads all
settings as JSON (also available at
/api/config); picking a backup file next to restore uploads it back, after which the device saves it and reboots. Handy for cloning a second clock, or for restoring the display settings after a partition-scheme change wipes SPIFFS. Scriptable too:curl http://esp32worldclock.local/api/config -o backup.jsonandcurl -X POST --data-binary @backup.json http://esp32worldclock.local/api/config.
The page also links to the firmware updater (/update), the log viewer
(/logs) and a scriptable diagnostics endpoint (/api/status, JSON: IP,
RSSI, chip/CPU, flash, heap, uptime, NTP syncs, zones, market status...). If
OTA_PASSWORD is set in secrets.h, the same HTTP Basic credentials
(username admin) protect these pages.
The clock dims itself using the CYD's onboard light sensor (LDR on GPIO 34):
when the room goes dark the backlight fades to the configured night
brightness, and it fades back to the saved brightness when the lights come
on. The LDR circuit is unreliable on some CYD board revisions, so the sensor
is only trusted after its reading has actually been seen to move; until then
the clock falls back to a time schedule (default: dim between 1–7 AM
home-zone time). Both the night brightness and the schedule window are
configurable on the web settings page. Type LDR in the serial monitor to
see the live readings, and set LDR_DARK_IS_HIGH to 0 in ClockLogic.h if
your board's sensor reads inverted. Manual brightness changes (touch gesture
or settings page) always win for 2 hours.
Live diagnostics across three pages, refreshed every second — each tap moves to the next page, and the last tap returns to settings:
- System (1/3) — WiFi SSID and signal strength (color-coded, red
OFFLINEwhen the connection is down), IP address; chip model / revision and CPU frequency, plus the CPU temperature on chips that have a sensor (the classic ESP32 in the CYD does not); flash size and speed, firmware size (with % of the OTA slot used) and the running build's compile timestamp; free heap (with the low-water mark since boot), uptime; NTP sync count / last sync age and the current UTC time. - Network & storage (2/3) — mDNS hostname, MAC address, gateway, DNS, WiFi channel; WiFi dropouts since boot (with the last outage's length and how long ago it ended); the reason for the last reset (power-on, software reset, crash, brownout... — shown in red after an abnormal one); SPIFFS usage, largest allocatable heap block (fragmentation), SDK version.
- Clock data (3/3) — home timezone, active face and formats; weather data age; market-holiday calendar source (weekly-fetched vs. compiled-in) and age; public-holiday tables loaded per eligible zone; current backlight level, what's driving auto-brightness (light sensor vs. schedule), any manual-brightness hold remaining, and the configured night window.
Everything on these pages is also in the /api/status JSON for scripting.
Everything the firmware logs goes to the serial port and into a 6KB in-RAM ring buffer, each line stamped with the uptime. Two ways to read it without a USB cable:
- On the device — settings → Logs shows the newest lines on the display, live; tap anywhere to go back.
- In the browser —
http://esp32worldclock.local/logsis an auto-refreshing viewer (/api/logsserves the same text raw, handy forcurl).
The buffer holds the most recent couple hundred lines; it resets on reboot.
- Time is synced over NTP every 30 minutes (ezTime).
- Timezone definitions are cached in flash after the first successful lookup,
so later boots get correct local times even when the timezone server
(
timezoned.rop.nl) or the network is unreachable. Cached entries refresh automatically once they are older than 6 months, and immediately whenever a quadrant's timezone is changed from the settings page. - Every preset city also carries built-in POSIX timezone rules (including DST transitions) as a last resort: if the timezone server is down and nothing usable is cached — e.g. a first boot, or changing a zone while the server is unreachable — the zone still shows correct local time instead of falling back to UTC.














