This example demonstrates how to build a fully functional desktop weather clock on the ESP32‑WROOM‑32E development board. The program connects to the internet via WiFi, retrieves real‑time weather data (temperature, humidity, wind speed, weather conditions) from the free Open‑Meteo weather API, synchronizes precise time via NTP (Network Time Protocol), and displays all information on a TFT LCD screen. The screen features a Kindle‑like e‑ink visual style with dynamic digital clock, weather icons, scrolling info banner, and a fun little animation. This example is suitable for smart home, desktop decorations, IoT beginners, and showcases ESP32's comprehensive capabilities including WiFi communication, HTTPS requests, JSON parsing, NTP time sync, SPI display driver, EEPROM storage, and serial command control.
Hardware prerequisites: ILI9341 SPI display;
TJpg_Decoderlibrary required for JPG decoding; partition scheme must be set to "Huge APP (3MB No OTA / 1MB SPIFFS)".
| ESP32 Pin | LCD Pin | Description |
|---|---|---|
| 15 | CS | SPI Chip‑Select signal |
| 2 | DC/RS | Data/Command select |
| - | RESET | Reset signal (ESP32‑EN, shared with board reset pin) |
| 13 | SDI/MOSI | SPI master data output |
| 14 | SCK | SPI clock signal |
| 12 | SDO/MISO | SPI master data input |
| 27 | BL | LCD backlight PWM control |
| 5V | VCC | LCD screen power supply (5V) |
| GND | GND | Common ground |
Note: Backlight control uses GPIO 27 (
LCD_BL_PIN) for PWM dimming.
ESP32_Desktop_Weather_Clock.ino file.Tools → Partition Scheme → "Huge APP (3MB No OTA / 1MB SPIFFS)"const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const float TIMEZONE = 0; // London=0, Paris=1, New York=-5, Tokyo=9, Beijing=8, Hong Kong=8
#define DEFAULT_CITY "London"
The program supports configuration via serial commands:
| Command | Function | Description |
|---|---|---|
0x01 |
Adjust screen brightness | Enter value 0‑100 |
0x02 |
Change weather city | Enter city name (e.g. "London"), or "0" to reset |
| Other | Show help menu | Displays all available commands |
After each re‑upload, the city name saved in Preferences is retained, causing the DEFAULT_CITY defined in your code to fail to take effect.
This happens because Preferences data is stored in the ESP32 NVS (Non‑Volatile Storage), which is not automatically erased when new firmware is flashed. As a result, even if you modify DEFAULT_CITY in your code, the program will still read the previously‑saved value of the "city" key on startup.
Solution: Runtime reset via serial command (available)
You can send the serial command 0x02 with parameter 0 to restore the default city. However, this is only a runtime reset, and the values stored in NVS will still be preserved after power‑cycle or re‑flashing. If you only want to test the default city, you can perform the reset over serial immediately after uploading without modifying your code.
Example workflow:
In Serial Monitor enter: 0x01
Serial returns: Enter brightness value (0-100)
Enter: 80
Serial returns: Brightness set to: 80
Screen brightness adjusts immediately
Enter: 0x02
Serial returns: Enter city name in English (e.g. London), or 0 to reset
Enter: Beijing
Serial returns: City set to: Beijing
Screen updates to show Beijing weather
Enter: 0x02
Serial returns: Enter city name in English (e.g. London), or 0 to reset
Enter: 0
Serial returns: City reset to default: London
……
⚠️ Key Notes:
WiFiClientSecureThis example is based on the ESP32‑WROOM‑32E. Program modules include: WiFi connection (with SmartConfig fallback), NTP time sync, Open‑Meteo weather API calls (HTTPS + JSON parsing), TFT display, backlight PWM control, EEPROM/Preferences storage, and serial command control.
#include "ArduinoJson.h"
#include <TimeLib.h>
#include <HTTPClient.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WiFiClientSecure.h>
#include <TFT_eSPI.h>
#include <SPI.h>
#include <TJpg_Decoder.h>
#include <EEPROM.h>
#include <Preferences.h>
#include "number.h"
#include "weathernum.h"
#include "font/ZdyLwFont_20.h"
#include "img/temperature.h"
#include "img/humidity.h"
#include "img/pangzi/i0.h" // Animation frames 0‑9
// ... other image resources
#define LCD_BL_PIN 27
#define DEFAULT_CITY "London"
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const float TIMEZONE = 0;
Library explanations:
ArduinoJson: Parses JSON dataTimeLib: Time management (hour(), minute(), second(), day(), month(), year(), weekday())WiFiClientSecure: Critical for HTTPS secure connectionsTJpg_Decoder: Decodes JPG imagesEEPROM: Stores backlight brightness (persistent)Preferences: Stores city name (persistent)Image resource descriptions:
number.h: Large clock digit fontsweathernum.h: Weather icon mappingimg/pangzi/i0.h ~ i9.h: 10‑frame running animationtemperature.h / humidity.h: Thermometer and humidity iconsTFT_eSPI tft = TFT_eSPI();
TFT_eSprite clk = TFT_eSprite(&tft); // Off‑screen buffer for clock digits
TFT_eSprite clkb = TFT_eSprite(&tft); // Off‑screen buffer for scrolling banner
Number dig; // Digit display object
WeatherNum wrat; // Weather icon display object
char t_buf[100] = {0};
String cityName = DEFAULT_CITY;
int LCD_BL_PWM = 100;
int tempnum = 0, windnum = 0, huminum = 0;
int tempcol = 0xffff, windcol = 0xffff, humicol = 0xffff;
int Anim = 0;
int BL_addr = 1;
String scrollText[7];
int currentIndex = 0;
Preferences preferences;
WiFiClientSecure secureClient;
WiFiUDP Udp;
Object descriptions:
TFT_eSPI tft: Main TFT screen objectTFT_eSprite clk / clkb: Off‑screen buffers – draw in memory first, then push to screen to avoid flickeringNumber dig: From number.h, for drawing large clock digitsWeatherNum wrat: From weathernum.h, for drawing weather iconstempcol / windcol / humicol: Progress bar colors (RGB565), dynamically changedvoid setup()
{
Serial.begin(115200);
EEPROM.begin(1024);
if (EEPROM.read(BL_addr) > 0 && EEPROM.read(BL_addr) < 100)
LCD_BL_PWM = EEPROM.read(BL_addr);
pinMode(LCD_BL_PIN, OUTPUT);
analogWrite(LCD_BL_PIN, map(LCD_BL_PWM, 0, 100, 0, 255));
tft.begin();
tft.fillScreen(TFT_WHITE);
tft.setTextColor(TFT_BLACK, bgColor);
targetTime = millis() + 1000;
TJpgDec.setJpgScale(1);
TJpgDec.setSwapBytes(true);
TJpgDec.setCallback(tft_output);
connect_wifi();
Udp.begin(localPort);
setSyncProvider(getNtpTime);
setSyncInterval(300);
preferences.begin("weather", false);
String savedCity = preferences.getString("city", DEFAULT_CITY);
preferences.end();
cityName = savedCity;
tft.fillScreen(TFT_WHITE);
TJpgDec.drawJpg(10, 193, temperature, sizeof(temperature));
TJpgDec.drawJpg(10, 223, humidity, sizeof(humidity));
getCityWeather();
}
Module 1: Serial and EEPROM initialization
Serial.begin(115200);
EEPROM.begin(1024);
if (EEPROM.read(BL_addr) > 0 && EEPROM.read(BL_addr) < 100)
LCD_BL_PWM = EEPROM.read(BL_addr);
LCD_BL_PWM.Module 2: Backlight and TFT screen initialization
pinMode(LCD_BL_PIN, OUTPUT);
analogWrite(LCD_BL_PIN, map(LCD_BL_PWM, 0, 100, 0, 255));
tft.begin();
tft.fillScreen(TFT_WHITE);
tft.setTextColor(TFT_BLACK, bgColor);
analogWrite() with map() converts 0‑100 to 0‑255 PWM range.Module 3: JPG decoder configuration
TJpgDec.setJpgScale(1);
TJpgDec.setSwapBytes(true);
TJpgDec.setCallback(tft_output);
Module 4: WiFi connection, NTP sync, city loading, and first weather fetch
connect_wifi() connects to WiFi (with SmartConfig fallback).setSyncProvider(getNtpTime) sets NTP as time source.DEFAULT_CITY.getCityWeather() fetches first weather data.void loop()
{
if (now() != prevDisplay) {
prevDisplay = now();
digitalClockDisplay();
prevTime = 0;
}
if (millis() - weatherTime > 300000) {
weatherTime = millis();
getCityWeather();
}
scrollBanner();
imgAnim();
Serial_set();
}
digitalClockDisplay().getCityWeather().scrollBanner() switches text every 2 seconds.imgAnim() switches frame every 37ms.Serial_set() handles serial commands.void connect_wifi(void)
{
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED)
{
loading(70);
if (loadNum >= 194)
{
SmartConfig();
break;
}
}
while (loadNum < 194) { loading(1); }
}
loadNum >= 194 (progress bar full, ~13 seconds) and not connected, calls SmartConfig().void SmartConfig(void)
{
WiFi.mode(WIFI_AP_STA);
tft.drawString("Start WiFi SmartConfig", 10, 140, 2);
tft.drawString("Open EspTouch APP on phone", 10, 160, 2);
WiFi.beginSmartConfig();
while (!WiFi.smartConfigDone()) { delay(500); }
while (WiFi.status() != WL_CONNECTED) { delay(500); }
// Display SSID, password, IP, MAC on screen
}
void loading(byte delayTime)
{
clk.createSprite(200, 60);
clk.fillSprite(TFT_WHITE);
clk.drawRoundRect(0, 0, 200, 16, 8, TFT_BLACK);
clk.fillRoundRect(3, 3, loadNum, 10, 5, TFT_BLACK);
clk.drawString("Connecting to WiFi...", 100, 40, 2);
clk.pushSprite(20, 130);
clk.deleteSprite();
loadNum += 1;
delay(delayTime);
}
loadNum increments each call; delayTime controls speed.String SMOD = "";
void Serial_set()
{
if (Serial.available() > 0) {
// Read input, trim whitespace
if (SMOD == "0x01") {
// Brightness setting: save to EEPROM
}
else if (SMOD == "0x02") {
// City setting: save to Preferences, call getCityWeather()
}
else {
SMOD = incomingByte;
if (SMOD == "0x01") Serial.println("Enter brightness value (0-100)");
else if (SMOD == "0x02") Serial.println("Enter city name (e.g. London)");
else { /* Show help menu */ }
}
}
}
SMOD tracks current command state.0x01 → prompts for brightness value → saves to EEPROM.0x02 → prompts for city name → saves to Preferences → refreshes weather.void getCityWeather() {
secureClient.setInsecure();
float lat = 51.51, lon = -0.13;
// 1. Geocoding: City name → coordinates
String geoURL = "https://geocoding-api.open-meteo.com/v1/search?name=" + urlEncode(cityName) + "&count=1&language=en&format=json";
HTTPClient http;
http.begin(secureClient, geoURL);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
DynamicJsonDocument doc(1024);
deserializeJson(doc, payload);
lat = doc["results"][0]["latitude"];
lon = doc["results"][0]["longitude"];
}
http.end();
// 2. Weather forecast (NEW API format)
String weatherURL = "https://api.open-meteo.com/v1/forecast?latitude=" + String(latStr) +
"&longitude=" + String(lonStr) +
"¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m"
"&wind_speed_unit=ms&timezone=auto";
secureClient.setInsecure();
http.begin(secureClient, weatherURL);
httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
DynamicJsonDocument doc(2048);
deserializeJson(doc, payload);
float temp = doc["current"]["temperature_2m"];
int humidity = doc["current"]["relative_humidity_2m"];
int weatherCode = doc["current"]["weather_code"];
float windSpeed = doc["current"]["wind_speed_10m"];
int windDir = doc["current"]["wind_direction_10m"];
updateWeatherDisplay(temp, humidity, windSpeed, cityDisplay, weatherCode, desc, windDirStr);
}
http.end();
}
Key changes from previous version:
current_weather to current with explicit fields.relative_humidity_2m.weathercode to weather_code.winddirection to wind_direction_10m.void updateWeatherDisplay(float temp, int humidity, float windSpeed, String city, int weatherCode, String desc, String windDir) {
int tempInt = (int)round(temp);
int windInt = (int)round(windSpeed);
// Temperature digits + progress bar (y=194)
clk.createSprite(58, 24);
clk.drawString(String(tempInt) + "C", 28, 13);
clk.pushSprite(95, 194);
clk.deleteSprite();
tempnum = map(tempInt + 20, 0, 70, 0, 50);
// Color selection based on temperature
tempWin(); // Draw at (40, 202)
// Humidity digits + progress bar (y=224) — NEW
clk.createSprite(58, 24);
clk.drawString(String(humidity) + "%", 28, 13);
clk.pushSprite(95, 224);
clk.deleteSprite();
huminum = map(humidity, 0, 100, 0, 50);
if (humidity < 30) humicol = 0xF00F; // Red (dry)
else if (humidity < 60) humicol = 0x0F0F; // Green (comfortable)
else humicol = 0x00FF; // Blue (humid)
humWin(); // Draw at (40, 232)
// Wind speed digits + progress bar (y=254)
clk.createSprite(58, 24);
clk.drawString(String(windInt) + "m/s", 28, 13);
clk.pushSprite(95, 254);
clk.deleteSprite();
windnum = windInt * 2;
if (windnum > 50) windnum = 50;
// Color selection based on wind speed
windWin(); // Draw at (40, 254)
// City name (y=15)
// Weather short description (y=18)
// Scroll text update
// Weather icon (y=15)
}
Layout summary:
| Element | X | Y |
|---|---|---|
| City name | 5 | 15 |
| Weather icon | 170 | 15 |
| Weather label | 100 | 18 |
| Clock digits | 10‑215 | 82‑112 |
| Scrolling banner | 10 | 45 |
| Temperature digits | 95 | 194 |
| Temperature bar | 40 | 202 |
| Humidity digits | 95 | 224 |
| Humidity bar | 40 | 232 |
| Wind speed digits | 95 | 254 |
| Wind speed bar | 40 | 254 |
| Animation | 160 | 185 |
void digitalClockDisplay()
{
// Hours (large digits, 36×60)
if (hour() != Hour_sign) {
dig.printfW3660(10, 82, hour() / 10);
dig.printfW3660(50, 82, hour() % 10);
Hour_sign = hour();
}
// Minutes (large digits with colon)
if (minute() != Minute_sign) {
dig.printfO3660(105, 82, minute() / 10);
dig.printfO3660(145, 82, minute() % 10);
Minute_sign = minute();
}
// Seconds (small digits, 18×30)
if (second() != Second_sign) {
dig.printfW1830(195, 112, second() / 10);
dig.printfW1830(215, 112, second() % 10);
Second_sign = second();
}
// Weekday (182, 150) and date (5, 150)
}
printfW3660: 36×60 large digits for hours.printfO3660: 36×60 large digits with colon for minutes.printfW1830: 18×30 small digits for seconds.time_t getNtpTime()
{
WiFi.hostByName(ntpServerName, ntpServerIP);
sendNTPpacket(ntpServerIP);
uint32_t beginWait = millis();
while (millis() - beginWait < 1500) {
int size = Udp.parsePacket();
if (size >= NTP_PACKET_SIZE) {
Udp.read(packetBuffer, NTP_PACKET_SIZE);
unsigned long secsSince1900 = ...; // Parse from bytes 40‑43
return secsSince1900 - 2208988800UL + (long)(TIMEZONE * SECS_PER_HOUR);
}
}
return 0;
}
pool.ntp.org.TIMEZONE * SECS_PER_HOUR.void imgAnim()
{
if (millis() - AprevTime > 37) { Anim++; AprevTime = millis(); }
if (Anim == 10) Anim = 0;
TJpgDec.drawJpg(160, 185, i0, sizeof(i0)); // Frame 0‑9 loop
}
void scrollBanner()
{
if (second() % 2 == 0 && prevTime == 0) {
clkb.drawString(scrollText[currentIndex], 74, 16);
if (currentIndex >= 5) currentIndex = 0;
else currentIndex += 1;
prevTime = 1;
}
}
scrollText[0] to scrollText[5].// EEPROM for brightness (setup)
EEPROM.begin(1024);
LCD_BL_PWM = EEPROM.read(BL_addr);
// Write on brightness change
EEPROM.write(BL_addr, LCDBL);
EEPROM.commit();
// Preferences for city (setup)
preferences.begin("weather", false);
String savedCity = preferences.getString("city", DEFAULT_CITY);
preferences.end();
// Write on city change
preferences.begin("weather", false);
preferences.putString("city", cityName);
preferences.end();
This example implements a complete desktop weather clock:
Key functions:
connect_wifi(): WiFi connection (with progress bar and SmartConfig)getCityWeather(): Fetch weather data (geocoding + weather query)updateWeatherDisplay(): Update screen weather informationdigitalClockDisplay(): Update time displayimgAnim() / scrollBanner(): Animation and scrolling bannerSerial_set(): Serial command processinggetNtpTime(): NTP time synchronizationconst char* WIFI_SSID = "YourWiFiName";
const char* WIFI_PASSWORD = "YourWiFiPassword";
const float TIMEZONE = 8; // Beijing/Hong Kong/Singapore = 8
Common values: London=0, Paris=1, Moscow=3, Dubai=4, New York=-5, Los Angeles=-8, Tokyo=9, Sydney=11
#define DEFAULT_CITY "Beijing"
City names must be in English.
int LCD_BL_PWM = 100; // 0‑100
Also adjustable via serial command 0x01.
if (millis() - weatherTime > 300000) { // 300000ms = 5 minutes
Change to 60000 = 1 minute, 180000 = 3 minutes.
if (millis() - AprevTime > 37) { // 37ms ≈ 27 fps
Larger = slower, smaller = faster.
Replace img/pangzi/i0.h ~ i9.h with JPG images of the same format and size.
Add more fields in updateWeatherDisplay():
pressure)uv_index)Compilation error: No space left
Compilation error: cannot find libraries
WiFi connection fails, enters SmartConfig
Weather data fetch fails (HTTP error)
setInsecure() to bypass SSL issues.No display or garbled screen
User_Setup.h configured for ILI9341.Incorrect time display
TIMEZONE constant.Animation/images not showing
#include "img/pangzi/i0.h" etc.).Serial commands ineffective
City setting lost after reboot
preferences.begin("weather", false) is called.preferences.putString() and preferences.end() are called.