Community project
ESP32 PDA Dashboard

Build a fully functional PDA dashboard on the ESP32 CYD touchscreen board. This project packs seven apps into a single device: Home launcher, Clock, Weather (via OpenWeatherMap API), Stopwatch, Timer, Notes with on-screen keyboard, and WiFi manager. The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions.
The firmware handles touch input, NTP time synchronization, persistent storage for WiFi credentials and notes, and real-time weather updates. Readers will learn how to integrate multiple apps into a single touchscreen interface, manage WiFi connectivity on the ESP32, and build a practical handheld tool from readily available components.
Wiring diagram
Interactive · read-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Assembly
5 stepsWhat's already built in
The ESP32-2432S028R (Cheap Yellow Display) has everything soldered on the PCB: a 2.8" ILI9341 320×240 touchscreen, XPT2046 resistive touch controller, RGB LED, LDR light sensor, piezo speaker, and microSD slot. You need no extra wiring for this project.
- Tip: The board ships ready to flash — no assembly of display or touch components required.
Power the board
Connect the CYD to your computer or a 5V USB-C power adapter using the USB-C port on the board. The board regulates 5V down to 3.3V internally.
- ⚠ Use a quality USB cable; thin charge-only cables can cause resets when WiFi is active.
- ⚠ A 5V/2A supply is recommended — WiFi + bright backlight can draw close to 500mA.
Get an OpenWeatherMap API key
For the Weather app to work: 1) Go to openweathermap.org and create a free account. 2) Navigate to API Keys in your profile. 3) Copy your key. 4) Open the firmware file src/main.cpp and replace YOUR_OWM_API_KEY on the line 'char weatherKey[65]' with your actual key. 5) Also change weatherCity to your city name (e.g. "New York"). Then redeploy.
- Tip: New API keys can take up to 2 hours to activate — if you get a 401 error, wait and retry.
- Tip: The free tier allows 1000 calls/day; this firmware refreshes once per hour so you will never hit the limit.
Deploy the firmware
Click the Deploy button in Schematik. The firmware compiles, all libraries install automatically, and the binary is flashed to the board. Watch the Deploy panel for progress.
- Tip: If the upload fails, press and hold the BOOT button on the board, click Deploy again, then release BOOT once flashing starts.
First boot & setup
On power-up you will see a splash screen then the HOME screen with 6 app tiles. Tap any tile to enter that app: • CLOCK — shows digital + analogue clock; use UTC-/UTC+ buttons to set your timezone, then tap SYNC. • WEATHER — tap REFRESH; requires WiFi first. • STOPWATCH — START/PAUSE/RESET. • TIMER — adjust minutes/seconds with M+/M-/S+/S-, then START. • NOTES — tap a note row to select, tap EDIT to modify with the on-screen keyboard, tap DONE to save. • WiFi — tap SCAN, tap CONNECT next to your network, type password on the keyboard, tap DONE.
- Tip: Your WiFi credentials and notes are saved in flash and survive reboots.
- Tip: After connecting WiFi, the clock will sync automatically via NTP.
Firmware
ESP32// ============================================================
// ESP32 CYD (2432S028R) — Full PDA
// Apps: Home | Clock | Weather | Stopwatch | Timer | Notes | WiFi
// ============================================================
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <Preferences.h>
// ── Touch (VSPI) ──────────────────────────────────────────
#define TOUCH_CS 33
#define TOUCH_IRQ 36
#define TOUCH_MOSI 32
#define TOUCH_MISO 39
#define TOUCH_CLK 25
// Hoisted type definitions
enum AppID { APP_HOME=0, APP_CLOCK, APP_WEATHER, APP_STOPWATCH,
APP_TIMER, APP_NOTES, APP_WIFI, APP_COUNT };
struct Pt { int16_t x, y; };
struct WifiNet { char ssid[33]; int8_t rssi; };
struct AppTile { const char* label; uint16_t col; };
// Forward declarations
Pt mapTouch(TS_Point p);
void loadWifiCreds();
void saveWifiCreds(const char* ssid, const char* pass);
void syncNTP();
void fetchWeather();
uint32_t swElapsed();
uint32_t tmRemaining();
void loadNotes();
void saveNotes();
void drawKeyboard();
char kbHitTest(Pt p);
void drawTitleBar(const char* title, uint16_t col);
void drawHomeButton();
bool touchedHome(Pt p);
void drawButton(int x, int y, int w, int h, const char* label, uint16_t bg, uint16_t fg, int ts);
void drawHome();
AppID tileHitTest(Pt p);
void drawClock();
void handleClockTouch(Pt p);
void drawWeather();
void handleWeatherTouch(Pt p);
void drawStopwatch();
void handleStopwatchTouch(Pt p);
void drawTimer();
void handleTimerTouch(Pt p);
void drawNotes();
void handleNotesTouch(Pt p);
void drawWifi();
void doScan();
void tryConnect(const char* ssid, const char* pass);
void handleWifiTouch(Pt p);
void drawCurrentApp();
void handleTouch(Pt p);
SPIClass touchSPI(VSPI);
XPT2046_Touchscreen touch(TOUCH_CS, TOUCH_IRQ);
// ── TFT ───────────────────────────────────────────────────
TFT_eSPI tft = TFT_eSPI();
// ── NVS for persistence ───────────────────────────────────
Preferences prefs;
// ── Color palette ─────────────────────────────────────────
#define COL_BG 0x0820 // near-black blue
#define COL_PANEL 0x1082 // dark card
#define COL_ACCENT 0x04FF // cyan-ish
#define COL_ACCENT2 0xFD20 // amber
#define COL_WARN 0xF800 // red
#define COL_TEXT 0xFFFF // white
#define COL_DIM 0x8410 // grey
#define COL_GREEN 0x07E0
#define COL_TITLE_BG 0x0410
// ── Screen dimensions (landscape) ─────────────────────────
#define SCR_W 320
#define SCR_H 240
// ── App IDs ───────────────────────────────────────────────
AppID currentApp = APP_HOME;
bool needsRedraw = true;
// ── Touch calibration (raw → pixel) ───────────────────────
// Portrait raw space: X 200-3900, Y 200-3900
// Board is rotated 90°: swap axes
#define TOUCH_XMIN 200
#define TOUCH_XMAX 3900
#define TOUCH_YMIN 200
#define TOUCH_YMAX 3900
Pt mapTouch(TS_Point p) {
// Board rotated → swap x/y and invert
int16_t rx = map(p.y, TOUCH_YMIN, TOUCH_YMAX, 0, SCR_W-1);
int16_t ry = map(p.x, TOUCH_XMIN, TOUCH_XMAX, SCR_H-1, 0);
rx = constrain(rx, 0, SCR_W-1);
ry = constrain(ry, 0, SCR_H-1);
return {rx, ry};
}
// ── WiFi networks storage ──────────────────────────────────
#define MAX_NETS 10
WifiNet nets[MAX_NETS];
int netCount = 0;
char savedSSID[33] = "";
char savedPass[65] = "";
bool wifiConnected = false;
void loadWifiCreds() {
prefs.begin("pda", true);
prefs.getString("ssid", savedSSID, sizeof(savedSSID));
prefs.getString("pass", savedPass, sizeof(savedPass));
prefs.end();
}
void saveWifiCreds(const char* ssid, const char* pass) {
prefs.begin("pda", false);
prefs.putString("ssid", ssid);
prefs.putString("pass", pass);
prefs.end();
strncpy(savedSSID, ssid, 32);
strncpy(savedPass, pass, 64);
}
// ── Clock / NTP ───────────────────────────────────────────
bool ntpSynced = false;
const char* ntpServer = "pool.ntp.org";
long gmtOffset = 0; // seconds — user can edit
int dstOffset = 0;
void syncNTP() {
if (!wifiConnected) return;
configTime(gmtOffset, dstOffset, ntpServer);
struct tm t;
if (getLocalTime(&t, 5000)) ntpSynced = true;
}
// ── Weather ───────────────────────────────────────────────
char weatherCity[33] = "London";
char weatherKey[65] = "YOUR_OWM_API_KEY";
char weatherDesc[32] = "--";
float weatherTemp = 0;
int weatherHumidity = 0;
float weatherWind = 0;
char weatherIcon[4] = "--";
unsigned long lastWeatherFetch = 0;
bool weatherFetched = false;
bool weatherFetching = false;
void fetchWeather() {
if (!wifiConnected) return;
if (strlen(weatherKey) < 10) return;
HTTPClient http;
char url[256];
snprintf(url, sizeof(url),
"http://api.openweathermap.org/data/2.5/weather?q=%s&units=metric&appid=%s",
weatherCity, weatherKey);
http.begin(url);
int code = http.GET();
if (code == 200) {
String payload = http.getString();
StaticJsonDocument<1024> doc;
if (!deserializeJson(doc, payload)) {
strncpy(weatherDesc, doc["weather"][0]["description"] | "--", 31);
strncpy(weatherIcon, doc["weather"][0]["icon"] | "--", 3);
weatherTemp = doc["main"]["temp"] | 0.0f;
weatherHumidity = doc["main"]["humidity"] | 0;
weatherWind = doc["wind"]["speed"] | 0.0f;
weatherFetched = true;
lastWeatherFetch = millis();
}
}
http.end();
}
// ── Stopwatch ─────────────────────────────────────────────
bool swRunning = false;
uint32_t swStart = 0;
uint32_t swAccum = 0; // ms accumulated before last pause
uint32_t swElapsed() {
if (swRunning) return swAccum + (millis() - swStart);
return swAccum;
}
// ── Timer ─────────────────────────────────────────────────
bool tmRunning = false;
uint32_t tmEnd = 0; // millis when timer expires
uint32_t tmDuration = 60000; // default 1 min
bool tmExpired = false;
// Timer set UI
int tmSetMin = 1, tmSetSec = 0;
bool inTimerSet = false;
uint32_t tmRemaining() {
if (!tmRunning) return tmDuration;
long rem = (long)tmEnd - (long)millis();
return rem > 0 ? (uint32_t)rem : 0;
}
// ── Notes ─────────────────────────────────────────────────
#define NOTE_COUNT 5
#define NOTE_LEN 64
char notes[NOTE_COUNT][NOTE_LEN] = {
"Buy groceries",
"Meeting 3pm",
"",
"",
""
};
int selectedNote = 0;
bool editingNote = false;
void loadNotes() {
prefs.begin("pda", true);
for (int i = 0; i < NOTE_COUNT; i++) {
char key[8]; snprintf(key, 8, "note%d", i);
prefs.getString(key, notes[i], NOTE_LEN);
}
prefs.end();
}
void saveNotes() {
prefs.begin("pda", false);
for (int i = 0; i < NOTE_COUNT; i++) {
char key[8]; snprintf(key, 8, "note%d", i);
prefs.putString(key, notes[i]);
}
prefs.end();
}
// ── Virtual keyboard (A-Z + backspace + done) ─────────────
const char KB_ROWS[3][11] = {
{'Q','W','E','R','T','Y','U','I','O','P',0},
{'A','S','D','F','G','H','J','K','L',0,0},
{'Z','X','C','V','B','N','M',0,0,0,0}
};
#define KB_Y0 120
#define KB_KEY_W 27
#define KB_KEY_H 24
void drawKeyboard() {
tft.fillRect(0, KB_Y0-4, SCR_W, SCR_H-KB_Y0+4, 0x2104);
for (int r = 0; r < 3; r++) {
int cols = (r==0)?10:(r==1)?9:7;
int xOff = (r==1)?5:(r==2)?14:0;
for (int c = 0; c < cols; c++) {
int x = xOff + c*(KB_KEY_W+2);
int y = KB_Y0 + r*(KB_KEY_H+3);
char ch = KB_ROWS[r][c];
if (!ch) break;
tft.fillRoundRect(x, y, KB_KEY_W, KB_KEY_H, 4, COL_PANEL);
tft.setTextColor(COL_TEXT);
tft.setTextSize(1);
tft.setCursor(x+9, y+8);
tft.print(ch);
}
}
// Space bar
tft.fillRoundRect(14, KB_Y0+3*(KB_KEY_H+3), 7*(KB_KEY_W+2)-2, KB_KEY_H, 4, COL_DIM);
tft.setTextColor(COL_TEXT); tft.setTextSize(1);
tft.setCursor(70, KB_Y0+3*(KB_KEY_H+3)+8); tft.print("SPACE");
// Backspace
tft.fillRoundRect(220, KB_Y0+3*(KB_KEY_H+3), 46, KB_KEY_H, 4, COL_WARN);
tft.setCursor(227, KB_Y0+3*(KB_KEY_H+3)+8); tft.print("<--");
// Done
tft.fillRoundRect(220, KB_Y0, 98, 22, 4, COL_GREEN);
tft.setCursor(235, KB_Y0+7); tft.print("DONE");
}
char kbHitTest(Pt p) {
// Done button
if (p.x >= 220 && p.x <= 318 && p.y >= KB_Y0 && p.y <= KB_Y0+22) return '\n';
// Backspace
if (p.x >= 220 && p.y >= KB_Y0+3*(KB_KEY_H+3) && p.y <= KB_Y0+3*(KB_KEY_H+3)+KB_KEY_H) return '\b';
// Space
if (p.y >= KB_Y0+3*(KB_KEY_H+3) && p.y <= KB_Y0+3*(KB_KEY_H+3)+KB_KEY_H && p.x >= 14 && p.x <= 14+7*(KB_KEY_W+2)) return ' ';
// Letter keys
for (int r = 0; r < 3; r++) {
int cols = (r==0)?10:(r==1)?9:7;
int xOff = (r==1)?5:(r==2)?14:0;
for (int c = 0; c < cols; c++) {
int x = xOff + c*(KB_KEY_W+2);
int y = KB_Y0 + r*(KB_KEY_H+3);
char ch = KB_ROWS[r][c];
if (!ch) break;
if (p.x >= x && p.x <= x+KB_KEY_W && p.y >= y && p.y <= y+KB_KEY_H)
return ch;
}
}
return 0;
}
// ── WiFi password entry state ──────────────────────────────
int wifiSelecting = -1; // index in nets[] being connected
bool enteringPass = false;
char tmpPass[65] = "";
char tmpSSID[33] = "";
// ── GMT offset setting ─────────────────────────────────────
bool settingGMT = false;
// ══════════════════════════════════════════════════════════
// DRAWING HELPERS
// ══════════════════════════════════════════════════════════
void drawTitleBar(const char* title, uint16_t col = COL_ACCENT) {
tft.fillRect(0, 0, SCR_W, 28, COL_TITLE_BG);
tft.drawFastHLine(0, 28, SCR_W, col);
tft.setTextColor(col);
tft.setTextSize(1);
tft.setTextDatum(ML_DATUM);
tft.drawString(title, 8, 14);
// Home button
tft.fillRoundRect(SCR_W-50, 4, 46, 20, 4, COL_PANEL);
tft.setTextColor(COL_DIM);
tft.drawString("HOME", SCR_W-37, 14);
tft.setTextDatum(TL_DATUM);
}
void drawHomeButton() { /* already in drawTitleBar */ }
bool touchedHome(Pt p) {
return (p.x >= SCR_W-50 && p.y >= 4 && p.y <= 24);
}
void drawButton(int x, int y, int w, int h, const char* label,
uint16_t bg = COL_PANEL, uint16_t fg = COL_TEXT, int ts = 1) {
tft.fillRoundRect(x, y, w, h, 6, bg);
tft.setTextColor(fg);
tft.setTextSize(ts);
int lx = x + w/2 - strlen(label)*6*ts/2;
int ly = y + h/2 - 4*ts;
tft.setCursor(lx, ly);
tft.print(label);
}
// ══════════════════════════════════════════════════════════
// APP: HOME
// ══════════════════════════════════════════════════════════
AppTile tiles[APP_COUNT-1] = {
{"CLOCK", 0x041F},
{"WEATHER", 0x03EF},
{"STOPWATCH", 0xFD20},
{"TIMER", 0xF81F},
{"NOTES", 0x07FF},
{"WiFi", 0x0400}
};
#define TILE_COLS 3
#define TILE_ROWS 2
#define TILE_W 96
#define TILE_H 84
#define TILE_X0 8
#define TILE_Y0 36
void drawHome() {
tft.fillScreen(COL_BG);
// Header
tft.fillRect(0, 0, SCR_W, 30, COL_TITLE_BG);
tft.drawFastHLine(0, 30, SCR_W, COL_ACCENT);
tft.setTextColor(COL_ACCENT); tft.setTextSize(2);
tft.setCursor(8, 7); tft.print("PDA");
// NTP time
struct tm t;
if (ntpSynced && getLocalTime(&t, 0)) {
char tbuf[12]; strftime(tbuf, 12, "%H:%M", &t);
tft.setTextColor(COL_TEXT); tft.setTextSize(1);
tft.setCursor(230, 11); tft.print(tbuf);
}
// WiFi status dot
tft.fillCircle(215, 15, 5, wifiConnected ? COL_GREEN : COL_WARN);
// App tiles
for (int i = 0; i < APP_COUNT-1; i++) {
int col = i % TILE_COLS;
int row = i / TILE_COLS;
int x = TILE_X0 + col*(TILE_W+4);
int y = TILE_Y0 + row*(TILE_H+4);
tft.fillRoundRect(x, y, TILE_W, TILE_H, 10, tiles[i].col);
tft.setTextColor(COL_TEXT); tft.setTextSize(1);
int lx = x + TILE_W/2 - strlen(tiles[i].label)*3;
tft.setCursor(lx, y + TILE_H/2 - 4);
tft.print(tiles[i].label);
// small icon hint
if (i==0) { // clock
tft.drawCircle(x+TILE_W/2, y+25, 12, COL_TEXT);
tft.drawFastHLine(x+TILE_W/2, y+25, 8, COL_TEXT);
tft.drawFastVLine(x+TILE_W/2, y+15, 10, COL_TEXT);
}
}
}
AppID tileHitTest(Pt p) {
if (p.y < TILE_Y0 || p.y > TILE_Y0+2*(TILE_H+4)) return APP_HOME;
int col = (p.x - TILE_X0) / (TILE_W+4);
int row = (p.y - TILE_Y0) / (TILE_H+4);
if (col<0||col>=TILE_COLS||row<0||row>=TILE_ROWS) return APP_HOME;
int idx = row*TILE_COLS + col;
if (idx < 0 || idx >= APP_COUNT-1) return APP_HOME;
return (AppID)(idx+1);
}
// ══════════════════════════════════════════════════════════
// APP: CLOCK
// ══════════════════════════════════════════════════════════
void drawClock() {
tft.fillScreen(COL_BG);
drawTitleBar("CLOCK", COL_ACCENT);
struct tm t;
bool ok = ntpSynced && getLocalTime(&t, 0);
if (ok) {
char tbuf[10]; strftime(tbuf, 10, "%H:%M:%S", &t);
tft.setTextColor(COL_TEXT); tft.setTextSize(3);
tft.setCursor(30, 55); tft.print(tbuf);
char dbuf[20]; strftime(dbuf, 20, "%A", &t);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(10, 110); tft.print(dbuf);
char d2buf[20]; strftime(d2buf, 20, "%d %B %Y", &t);
tft.setCursor(10, 125); tft.print(d2buf);
} else {
tft.setTextColor(COL_WARN); tft.setTextSize(1);
tft.setCursor(10, 80);
tft.print(wifiConnected ? "Syncing NTP..." : "No WiFi — connect in WiFi app");
}
// Analogue clock face
int cx=250, cy=130, r=80;
tft.drawCircle(cx, cy, r, COL_ACCENT);
tft.drawCircle(cx, cy, r-1, COL_PANEL);
for (int m=0; m<60; m++) {
float a = m * 6.0f * DEG_TO_RAD;
int len = (m%5==0) ? 8 : 3;
tft.drawLine(cx + (r-len)*sin(a), cy - (r-len)*cos(a),
cx + (r-1) *sin(a), cy - (r-1) *cos(a), COL_DIM);
}
if (ok) {
float sa = ((t.tm_sec) * 6.0f) * DEG_TO_RAD;
float ma = ((t.tm_min*60+t.tm_sec) * 0.1f) * DEG_TO_RAD;
float ha = ((t.tm_hour%12*3600+t.tm_min*60+t.tm_sec) * (360.0f/43200.0f)) * DEG_TO_RAD;
tft.drawLine(cx, cy, cx+(r-20)*sin(ha), cy-(r-20)*cos(ha), COL_ACCENT2);
tft.drawLine(cx, cy, cx+(r-8) *sin(ma), cy-(r-8) *cos(ma), COL_TEXT);
tft.drawLine(cx, cy, cx+(r-4) *sin(sa), cy-(r-4) *cos(sa), COL_WARN);
tft.fillCircle(cx, cy, 3, COL_ACCENT);
}
// GMT offset controls
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(10, 150);
tft.printf("GMT%+d ", (int)(gmtOffset/3600));
drawButton(10, 165, 36, 22, "UTC-", COL_PANEL);
drawButton(54, 165, 36, 22, "UTC+", COL_PANEL);
drawButton(100,165, 60, 22, "SYNC", COL_ACCENT, COL_BG);
}
void handleClockTouch(Pt p) {
if (p.x >= 10 && p.x <= 46 && p.y >= 165 && p.y <= 187) {
gmtOffset -= 3600; configTime(gmtOffset, dstOffset, ntpServer); needsRedraw=true;
}
if (p.x >= 54 && p.x <= 90 && p.y >= 165 && p.y <= 187) {
gmtOffset += 3600; configTime(gmtOffset, dstOffset, ntpServer); needsRedraw=true;
}
if (p.x >= 100 && p.x <= 160 && p.y >= 165 && p.y <= 187) {
syncNTP(); needsRedraw=true;
}
}
// ══════════════════════════════════════════════════════════
// APP: WEATHER
// ══════════════════════════════════════════════════════════
void drawWeather() {
tft.fillScreen(COL_BG);
drawTitleBar("WEATHER", 0x03EF);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(8, 35); tft.print("City: ");
tft.setTextColor(COL_TEXT); tft.print(weatherCity);
if (!wifiConnected) {
tft.setTextColor(COL_WARN); tft.setCursor(8, 60);
tft.print("No WiFi. Connect in WiFi app.");
} else if (weatherFetching) {
tft.setTextColor(COL_DIM); tft.setCursor(8, 80);
tft.print("Fetching...");
} else if (!weatherFetched) {
tft.setTextColor(COL_DIM); tft.setCursor(8, 60);
tft.printf("API Key: %.8s...", weatherKey);
tft.setCursor(8, 75);
tft.print("Press REFRESH to load weather");
} else {
// Temperature big
tft.setTextColor(COL_ACCENT); tft.setTextSize(3);
tft.setCursor(8, 55);
tft.printf("%.1f", weatherTemp);
tft.setTextSize(2); tft.print(" C");
tft.setTextSize(1);
tft.setTextColor(COL_TEXT);
tft.setCursor(8, 105); tft.printf("Desc: %s", weatherDesc);
tft.setCursor(8, 120); tft.printf("Humidity: %d%%",weatherHumidity);
tft.setCursor(8, 135); tft.printf("Wind: %.1f m/s",weatherWind);
unsigned long age = (millis() - lastWeatherFetch)/1000;
tft.setTextColor(COL_DIM);
tft.setCursor(8, 150); tft.printf("Updated %lus ago", age);
}
// API key hint
if (strcmp(weatherKey, "YOUR_OWM_API_KEY")==0) {
tft.setTextColor(COL_WARN); tft.setTextSize(1);
tft.setCursor(8, 168);
tft.print("! Edit weatherKey in code");
}
drawButton(8, 185, 80, 26, "REFRESH", COL_ACCENT, COL_BG);
drawButton(100, 185, 80, 26, "CITY+", COL_PANEL);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(185, 192);
tft.print("(edit code)");
}
void handleWeatherTouch(Pt p) {
if (p.x >= 8 && p.x <= 88 && p.y >= 185 && p.y <= 211) {
weatherFetching = true; needsRedraw = true;
drawWeather(); // show "fetching"
fetchWeather();
weatherFetching = false; needsRedraw = true;
}
}
// ══════════════════════════════════════════════════════════
// APP: STOPWATCH
// ══════════════════════════════════════════════════════════
void drawStopwatch() {
tft.fillScreen(COL_BG);
drawTitleBar("STOPWATCH", COL_ACCENT2);
uint32_t ms = swElapsed();
uint32_t mins = ms / 60000;
uint32_t secs = (ms % 60000) / 1000;
uint32_t centi= (ms % 1000) / 10;
tft.setTextColor(COL_TEXT); tft.setTextSize(3);
tft.setCursor(30, 70);
tft.printf("%02d:%02d.%02d", mins, secs, centi);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(30, 118); tft.print(swRunning ? "Running" : "Paused");
drawButton(15, 145, 80, 32, swRunning?"PAUSE":"START",
swRunning ? COL_WARN : COL_GREEN, COL_BG);
drawButton(105, 145, 80, 32, "RESET", COL_PANEL);
}
void handleStopwatchTouch(Pt p) {
if (p.x>=15&&p.x<=95&&p.y>=145&&p.y<=177) {
if (swRunning) {
swAccum = swElapsed();
swRunning = false;
} else {
swStart = millis();
swRunning = true;
}
needsRedraw = true;
}
if (p.x>=105&&p.x<=185&&p.y>=145&&p.y<=177) {
swRunning = false; swAccum = 0; needsRedraw = true;
}
}
// ══════════════════════════════════════════════════════════
// APP: TIMER
// ══════════════════════════════════════════════════════════
void drawTimer() {
tft.fillScreen(COL_BG);
drawTitleBar("TIMER", 0xF81F);
uint32_t rem = tmRemaining();
uint32_t mins= rem / 60000;
uint32_t secs= (rem % 60000) / 1000;
if (tmExpired) {
tft.setTextColor(COL_WARN); tft.setTextSize(2);
tft.setCursor(50, 60); tft.print("TIME'S UP!");
} else {
uint16_t col = (rem < 10000 && tmRunning) ? COL_WARN : COL_TEXT;
tft.setTextColor(col); tft.setTextSize(4);
tft.setCursor(40, 60);
tft.printf("%02d:%02d", mins, secs);
}
// Progress bar
if (tmRunning && tmDuration > 0) {
int barW = map(rem, 0, tmDuration, 0, 280);
tft.fillRect(20, 115, 280, 10, COL_PANEL);
tft.fillRect(20, 115, barW, 10, rem<10000?COL_WARN:COL_ACCENT);
}
// Set controls
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(20, 135); tft.printf("Set: %02dm %02ds", tmSetMin, tmSetSec);
drawButton(20, 150, 32, 22, "M+", COL_PANEL);
drawButton(56, 150, 32, 22, "M-", COL_PANEL);
drawButton(94, 150, 32, 22, "S+", COL_PANEL);
drawButton(130, 150, 32, 22, "S-", COL_PANEL);
drawButton(15, 182, 80, 28, tmRunning?"PAUSE":"START",
tmRunning ? COL_WARN : COL_GREEN, COL_BG);
drawButton(105, 182, 80, 28, "RESET", COL_PANEL);
}
void handleTimerTouch(Pt p) {
bool adj = false;
if (p.y>=150&&p.y<=172) {
if (p.x>=20&&p.x<=52) { tmSetMin=min(99,tmSetMin+1); adj=true; }
if (p.x>=56&&p.x<=88) { tmSetMin=max(0, tmSetMin-1); adj=true; }
if (p.x>=94&&p.x<=126){ tmSetSec=min(59,tmSetSec+1); adj=true; }
if (p.x>=130&&p.x<=162){tmSetSec=max(0, tmSetSec-1); adj=true; }
if (adj) { tmDuration=(tmSetMin*60+tmSetSec)*1000UL; needsRedraw=true; }
}
if (p.x>=15&&p.x<=95&&p.y>=182&&p.y<=210) {
if (tmExpired) { tmExpired=false; tmRunning=false; needsRedraw=true; return; }
if (!tmRunning) {
tmEnd = millis() + tmDuration;
tmRunning = true;
tmExpired = false;
} else {
// pause: update duration to remaining
tmDuration = tmRemaining();
tmRunning = false;
}
needsRedraw = true;
}
if (p.x>=105&&p.x<=185&&p.y>=182&&p.y<=210) {
tmRunning = false; tmExpired = false;
tmDuration = (tmSetMin*60+tmSetSec)*1000UL;
needsRedraw = true;
}
}
// ══════════════════════════════════════════════════════════
// APP: NOTES
// ══════════════════════════════════════════════════════════
void drawNotes() {
tft.fillScreen(COL_BG);
if (editingNote) {
drawTitleBar("EDIT NOTE", 0x07FF);
// Show current text
tft.fillRect(0, 30, SCR_W, 84, COL_PANEL);
tft.setTextColor(COL_TEXT); tft.setTextSize(1);
tft.setCursor(8, 38);
tft.printf("Note %d: ", selectedNote+1);
tft.setTextColor(COL_ACCENT);
tft.print(notes[selectedNote]);
tft.print("_");
drawKeyboard();
return;
}
drawTitleBar("NOTES", 0x07FF);
for (int i=0; i<NOTE_COUNT; i++) {
int y = 36 + i*38;
uint16_t bg = (i==selectedNote) ? COL_ACCENT : COL_PANEL;
tft.fillRoundRect(8, y, 230, 30, 5, bg);
tft.setTextColor((i==selectedNote)?COL_BG:COL_TEXT);
tft.setTextSize(1);
tft.setCursor(14, y+11);
if (strlen(notes[i]) == 0) {
tft.setTextColor(COL_DIM); tft.print("(empty)");
} else {
tft.print(notes[i]);
}
// Edit button
drawButton(246, y+4, 48, 22, "EDIT", COL_ACCENT2, COL_BG);
}
}
void handleNotesTouch(Pt p) {
if (editingNote) {
char k = kbHitTest(p);
if (k == '\n') {
editingNote = false;
saveNotes();
needsRedraw = true;
} else if (k == '\b') {
int len = strlen(notes[selectedNote]);
if (len > 0) notes[selectedNote][len-1] = '\0';
needsRedraw = true;
} else if (k && strlen(notes[selectedNote]) < NOTE_LEN-1) {
int len = strlen(notes[selectedNote]);
notes[selectedNote][len] = k;
notes[selectedNote][len+1] = '\0';
needsRedraw = true;
}
return;
}
// Select note
for (int i=0; i<NOTE_COUNT; i++) {
int y = 36 + i*38;
if (p.y >= y && p.y <= y+30 && p.x >= 8 && p.x <= 238) {
selectedNote = i; needsRedraw = true;
}
// Edit
if (p.x>=246&&p.x<=294&&p.y>=y+4&&p.y<=y+26) {
selectedNote = i;
editingNote = true;
needsRedraw = true;
}
}
}
// ══════════════════════════════════════════════════════════
// APP: WiFi
// ══════════════════════════════════════════════════════════
void drawWifi() {
tft.fillScreen(COL_BG);
if (enteringPass) {
drawTitleBar("ENTER PASSWORD", COL_GREEN);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(8, 36); tft.printf("SSID: %s", tmpSSID);
tft.setTextColor(COL_TEXT);
tft.setCursor(8, 52); tft.printf("Pass: %s_", tmpPass);
drawKeyboard();
return;
}
drawTitleBar("WiFi", 0x0400);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(8, 34);
if (wifiConnected) {
tft.setTextColor(COL_GREEN); tft.printf("Connected: %s", savedSSID);
} else {
tft.setTextColor(COL_WARN); tft.print("Not connected");
}
// Scan / Disconnect buttons
drawButton(8, 50, 80, 24, "SCAN", COL_ACCENT, COL_BG);
if (wifiConnected)
drawButton(96, 50, 100, 24, "DISCONNECT", COL_WARN, COL_TEXT);
// Network list
if (netCount == 0) {
tft.setTextColor(COL_DIM); tft.setCursor(8, 85);
tft.print("Press SCAN to find networks");
}
for (int i=0; i<netCount && i<5; i++) {
int y = 82 + i*30;
tft.fillRoundRect(8, y, 224, 24, 5, COL_PANEL);
tft.setTextColor(COL_TEXT); tft.setTextSize(1);
tft.setCursor(14, y+8);
// SSID truncated
char shortSSID[20]; strncpy(shortSSID, nets[i].ssid, 18); shortSSID[18]=0;
tft.print(shortSSID);
// Signal
tft.setTextColor(COL_DIM);
tft.setCursor(195, y+8); tft.printf("%d", nets[i].rssi);
// Connect button
drawButton(238, y, 68, 24, "CONNECT", COL_ACCENT2, COL_BG);
}
}
void doScan() {
tft.fillRect(8, 82, SCR_W-16, 150, COL_BG);
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(8, 95); tft.print("Scanning...");
int n = WiFi.scanNetworks();
netCount = 0;
for (int i=0; i<n && netCount<MAX_NETS; i++) {
strncpy(nets[netCount].ssid, WiFi.SSID(i).c_str(), 32);
nets[netCount].rssi = WiFi.RSSI(i);
netCount++;
}
needsRedraw = true;
}
void tryConnect(const char* ssid, const char* pass) {
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(8, 175); tft.print("Connecting...");
WiFi.begin(ssid, pass);
int tries=0;
while (WiFi.status()!=WL_CONNECTED && tries<20) {
delay(500); tries++;
}
if (WiFi.status()==WL_CONNECTED) {
wifiConnected = true;
saveWifiCreds(ssid, pass);
syncNTP();
} else {
wifiConnected = false;
WiFi.disconnect();
}
needsRedraw = true;
}
void handleWifiTouch(Pt p) {
if (enteringPass) {
char k = kbHitTest(p);
if (k == '\n') {
enteringPass = false;
needsRedraw = true;
tryConnect(tmpSSID, tmpPass);
} else if (k == '\b') {
int len = strlen(tmpPass);
if (len>0) tmpPass[len-1]='\0';
needsRedraw = true;
} else if (k && strlen(tmpPass)<63) {
int len = strlen(tmpPass);
tmpPass[len]=k; tmpPass[len+1]='\0';
needsRedraw = true;
}
return;
}
// Scan
if (p.x>=8&&p.x<=88&&p.y>=50&&p.y<=74) doScan();
// Disconnect
if (wifiConnected&&p.x>=96&&p.x<=196&&p.y>=50&&p.y<=74) {
WiFi.disconnect(); wifiConnected=false; needsRedraw=true;
}
// Connect buttons
for (int i=0; i<netCount&&i<5; i++) {
int y = 82+i*30;
if (p.x>=238&&p.y>=y&&p.y<=y+24) {
strncpy(tmpSSID, nets[i].ssid, 32);
tmpPass[0]='\0';
enteringPass = true;
needsRedraw = true;
}
}
}
// ══════════════════════════════════════════════════════════
// MAIN DISPATCHER
// ══════════════════════════════════════════════════════════
void drawCurrentApp() {
switch(currentApp) {
case APP_HOME: drawHome(); break;
case APP_CLOCK: drawClock(); break;
case APP_WEATHER: drawWeather(); break;
case APP_STOPWATCH: drawStopwatch(); break;
case APP_TIMER: drawTimer(); break;
case APP_NOTES: drawNotes(); break;
case APP_WIFI: drawWifi(); break;
default: break;
}
needsRedraw = false;
}
void handleTouch(Pt p) {
// Global: HOME button (all apps except home itself)
if (currentApp != APP_HOME && touchedHome(p)) {
currentApp = APP_HOME;
editingNote = false;
enteringPass = false;
needsRedraw = true;
return;
}
if (currentApp == APP_HOME) {
AppID tapped = tileHitTest(p);
if (tapped != APP_HOME) { currentApp=tapped; needsRedraw=true; }
return;
}
switch(currentApp) {
case APP_CLOCK: handleClockTouch(p); break;
case APP_WEATHER: handleWeatherTouch(p); break;
case APP_STOPWATCH: handleStopwatchTouch(p); break;
case APP_TIMER: handleTimerTouch(p); break;
case APP_NOTES: handleNotesTouch(p); break;
case APP_WIFI: handleWifiTouch(p); break;
default: break;
}
}
// ══════════════════════════════════════════════════════════
// SETUP & LOOP
// ══════════════════════════════════════════════════════════
void setup() {
Serial.begin(115200);
// TFT
tft.init();
tft.setRotation(1); // landscape, USB on right
tft.fillScreen(COL_BG);
pinMode(21, OUTPUT); digitalWrite(21, HIGH); // backlight on
// Splash
tft.setTextColor(COL_ACCENT); tft.setTextSize(3);
tft.setCursor(60, 90); tft.print("PDA");
tft.setTextColor(COL_DIM); tft.setTextSize(1);
tft.setCursor(60, 130); tft.print("ESP32 CYD Edition");
delay(1200);
// Touch (separate VSPI)
touchSPI.begin(TOUCH_CLK, TOUCH_MISO, TOUCH_MOSI, TOUCH_CS);
touch.begin(touchSPI);
touch.setRotation(1);
// Persistent storage
loadNotes();
loadWifiCreds();
// Auto-connect saved WiFi
if (strlen(savedSSID) > 0) {
WiFi.begin(savedSSID, savedPass);
int tries=0;
while(WiFi.status()!=WL_CONNECTED && tries<16) { delay(300); tries++; }
if (WiFi.status()==WL_CONNECTED) {
wifiConnected = true;
syncNTP();
}
}
needsRedraw = true;
}
unsigned long lastClockRedraw = 0;
unsigned long lastSwRedraw = 0;
unsigned long lastTmRedraw = 0;
void loop() {
unsigned long now = millis();
// Auto-refresh ticking apps
if (currentApp == APP_CLOCK) {
struct tm t; getLocalTime(&t, 0);
if (now - lastClockRedraw > 1000) { lastClockRedraw=now; needsRedraw=true; }
}
if (currentApp == APP_STOPWATCH && swRunning) {
if (now - lastSwRedraw > 50) { lastSwRedraw=now; needsRedraw=true; }
}
if (currentApp == APP_TIMER && tmRunning) {
if (now - lastTmRedraw > 100) { lastTmRedraw=now; needsRedraw=true; }
if (tmRemaining() == 0 && !tmExpired) {
tmRunning=false; tmExpired=true; needsRedraw=true;
// Flash LED on expiry
for(int i=0;i<6;i++){digitalWrite(4,i%2==0?LOW:HIGH);delay(150);}
digitalWrite(4, HIGH);
}
}
// Auto-refresh weather once per hour
if (wifiConnected && currentApp==APP_WEATHER) {
if (!weatherFetched || (now - lastWeatherFetch > 3600000UL)) {
weatherFetching=true; needsRedraw=true; drawCurrentApp();
fetchWeather(); weatherFetching=false; needsRedraw=true;
}
}
if (needsRedraw) drawCurrentApp();
// Touch
if (touch.tirqTouched() && touch.touched()) {
TS_Point raw = touch.getPoint();
if (raw.z > 300) {
Pt p = mapTouch(raw);
handleTouch(p);
delay(120); // debounce
}
}
}“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.
Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.