Community project

ESP32 PDA Dashboard

Franklin Tam

Published July 16, 2026

ESP320 components5 assembly steps
Remix this project
Photo of 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-only

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Assembly

5 steps
  1. What'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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
src/main.cppDeploy to device
// ============================================================
//  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.

Open in Schematik