Community project
Symbolic Routine Clock

The Symbolic Routine Clock is an ESP32-based display that shows daily routines using symbols and text on a built-in touchscreen. It helps visualize schedules throughout the day with customizable routines, timers, and visual feedback.
This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions for mounting the clock board into its protective case and connecting the USB-C power adapter. The included firmware handles WiFi connectivity, schedule management, and the touchscreen interface, so the clock is ready to display routines once powered on.
Wiring diagram
Interactive · read-onlyPan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| USB-C 5V Adapter5 V, 2 A minimum, safety-certified | 1 | USB-C wall adapter delivering regulated 5 V to the board's USB or VBUS rail. Default wired power source for desktop / stationary projects. |
| Type-C & Micro 2-in-1 USB CableUSB-C to USB-C, 1.5 m, 3 A rated | 1 | 1m flat USB cable with a Type-C and Micro USB connector on one end, supporting data transfer. Flat design reduces tangling. |
Assembly
4 stepsPrint or obtain the protective case
Use the included `data/symbol_clock_enclosure.scad` model to print a two-part enclosure in PETG or PLA. Before printing, measure the actual board and its visible screen area; update the clearly marked dimensions in the file if they differ. Print the front bezel and then change `rear_cover = true` to print the back. Use four M3 screws to close it.
- Tip: PETG is tougher than PLA in a warm sunny room.
- Tip: Use 0.2 mm layers, three wall perimeters, and no support material.
- Tip: The front bezel leaves the touch glass uncovered; do not add a thick screen protector that makes touch less responsive.
- ⚠ This is a mechanical design starting point: measure your specific board before printing because small board revisions can differ.
- ⚠ Keep all screws and the bare board away from children until the case is fully closed.
Mount the clock board
Place the Waveshare ESP32-S3-Touch-LCD-4 face-down into the front bezel so the display sits behind the opening. Ensure its USB-C connector lines up with the lower cable exit. Fit the rear cover and secure it with the four M3 screws without overtightening.
- Tip: The rear holes let the built-in gentle-alert buzzer be heard and provide ventilation.
- Tip: Check that the touchscreen has an unobstructed view through the front opening before closing the case.
- ⚠ Do not pinch, sharply bend, or trap the USB-C cable.
- ⚠ Do not block the rear sound or ventilation openings.
Connect the low-voltage power accessories
Plug the USB-C-to-USB-C cable into the USB-C 5 V wall adapter, then plug the other end into the clock board through the case cable exit. Plug the adapter into a wall outlet only after all low-voltage connections are made.
- Tip: Use the specified regulated 5 V, 2 A minimum, safety-certified adapter; its extra current capacity is normal and does not force current into the clock.
- Tip: Route the cable behind furniture or use a cable cover/strain relief so it cannot be tugged by a child.
- ⚠ Use only an undamaged USB-C cable and a reputable, safety-certified low-voltage adapter.
- ⚠ Never put mains wiring, loose batteries, or charging electronics inside the child-accessible enclosure.
Set up the routine display
After using Schematik’s Deploy button, power the clock and use its configuration webpage to set home Wi-Fi, the time zone, daily symbol schedule, and any one-off timers. Place the finished clock on a stable shelf where the child can see and touch it.
- Tip: Keep it connected to wall power for reliable time and Wi-Fi; it will also avoid a battery charging routine.
- Tip: Test each schedule symbol and a short timer before relying on it in a daily routine.
- ⚠ The case is not a toy. Supervise young children and place the clock where it cannot be pulled down.
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| EXT | usb_c_wall_adapter +5V → Type-C & Micro 2-in-1 USB Cable INPUT_5V | power |
| EXT | usb_c_wall_adapter GND → Type-C & Micro 2-in-1 USB Cable INPUT_GND | ground |
| 5V | usb_c_cable OUTPUT_5V | power |
| GND | usb_c_cable OUTPUT_GND | ground |
Firmware
ESP32#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <Wire.h>
// The browser build omits a few Arduino byte helpers used internally by its
// display-library facade. Real ESP32 builds receive these from Arduino.h.
#if defined(__EMSCRIPTEN__)
#ifndef lowByte
#define lowByte(value) ((uint8_t)((value) & 0xFF))
#endif
#ifndef highByte
#define highByte(value) ((uint8_t)(((value) >> 8) & 0xFF))
#endif
#endif
#include <Arduino_GFX_Library.h>
#if !defined(__EMSCRIPTEN__)
#include <TAMC_GT911.h>
#endif
// Built-in Waveshare ESP32-S3-Touch-LCD-4 hardware (no external wiring).
#define I2C_SDA 15
#define I2C_SCL 7
#define LCD_DE 40
#define LCD_VSYNC 39
#define LCD_HSYNC 38
#define LCD_PCLK 41
#define LCD_R0 46
#define LCD_R1 3
#define LCD_R2 8
#define LCD_R3 18
#define LCD_R4 17
#define LCD_G0 14
#define LCD_G1 13
#define LCD_G2 12
#define LCD_G3 11
#define LCD_G4 10
#define LCD_G5 9
#define LCD_B0 5
#define LCD_B1 45
#define LCD_B2 48
#define LCD_B3 47
#define LCD_B4 21
struct Schedule { int minute; String symbol; };
struct Timer { String label; unsigned long finish; bool active; };
// Forward declarations
// Forward declarations
void setBuzzer(bool enabled);
String labelFor(const String &s);
void loadSchedule();
void centered(const String &text, int y, uint16_t color, uint8_t size);
void symbol(const String &s);
void drawScreen(bool timerMode, const String &s, const String &caption);
void updateClock();
String page();
void startWeb();
void queueChime();
void serviceChime();
#if defined(__EMSCRIPTEN__)
// Browser-only drawing facade: it lets the simulator check the clock logic.
// The real device path below still drives the ST7701 RGB panel.
class SimulatorGfx {
public:
void begin() {}
void fillRect(int, int, int, int, uint16_t) {}
void fillCircle(int, int, int, uint16_t) {}
void fillRoundRect(int, int, int, int, int, uint16_t) {}
void fillTriangle(int, int, int, int, int, int, uint16_t) {}
void drawLine(int, int, int, int, uint16_t) {}
void fillScreen(uint16_t) {}
void setTextSize(uint8_t) {}
void setTextColor(uint16_t) {}
void getTextBounds(const String &text, int16_t, int16_t, int16_t *x1, int16_t *y1, uint16_t *w, uint16_t *h) { *x1 = 0; *y1 = 0; *w = text.length() * 6; *h = 8; }
void setCursor(int, int) {}
void print(const String &) {}
};
SimulatorGfx gfx;
#else
Arduino_ESP32RGBPanel *rgb = new Arduino_ESP32RGBPanel(
LCD_DE, LCD_VSYNC, LCD_HSYNC, LCD_PCLK,
LCD_R0, LCD_R1, LCD_R2, LCD_R3, LCD_R4,
LCD_G0, LCD_G1, LCD_G2, LCD_G3, LCD_G4, LCD_G5,
LCD_B0, LCD_B1, LCD_B2, LCD_B3, LCD_B4,
1, 16, 8, 10, 50, 8, 8, 8, 0, 16000000);
Arduino_RGB_Display gfx(480, 480, rgb, 0, true);
#endif
#if defined(__EMSCRIPTEN__)
class SimulatorTouch {
public:
bool isTouched = false;
SimulatorTouch(int, int, int, int, int, int) {}
void begin() {}
void read() { isTouched = false; }
};
SimulatorTouch touch(I2C_SDA, I2C_SCL, -1, -1, 480, 480);
#else
TAMC_GT911 touch(I2C_SDA, I2C_SCL, -1, -1, 480, 480);
#endif
WebServer web(80);
Preferences prefs;
Schedule schedule[12]; int scheduleCount = 0;
Timer timers[5] = {};
String ssid, password;
int utcOffset = -5, lastMinute = -1, lastSecond = -1;
unsigned long lastPoll = 0;
// V4.0 board assumption: CH32V003 IO expander at 0x24, BEE_EN register/pin 6.
const uint8_t IO_EXPANDER_ADDR = 0x24;
const uint8_t BEE_EN = 6;
bool chimeQueued = false, chimeOn = false;
uint8_t chimeStage = 0;
unsigned long chimeChangedAt = 0;
String displayedSymbol;
const char *DEFAULT_SCHEDULE = "07:00|sun;07:30|brush;08:00|school;12:00|food;13:00|play;18:00|food;18:30|bath;19:00|brush;19:30|bed;";
String labelFor(const String &s) {
if(s=="sun") return "Daytime"; if(s=="moon") return "Night time";
if(s=="brush") return "Brush teeth"; if(s=="food") return "Meal time";
if(s=="school") return "School time"; if(s=="play") return "Play time";
if(s=="bath") return "Bath time"; if(s=="bed") return "Bedtime";
return s;
}
void loadSchedule() {
String raw = prefs.getString("schedule", DEFAULT_SCHEDULE); scheduleCount=0; int pos=0;
while(pos < raw.length() && scheduleCount < 12) {
int end=raw.indexOf(';',pos); if(end<0) end=raw.length(); String item=raw.substring(pos,end); int bar=item.indexOf('|');
if(bar==5) { int h=item.substring(0,2).toInt(), m=item.substring(3,5).toInt();
if(h>=0&&h<24&&m>=0&&m<60) schedule[scheduleCount++]={h*60+m,item.substring(bar+1)}; }
pos=end+1;
}
}
void centered(const String &text, int y, uint16_t color, uint8_t size) {
gfx.setTextSize(size); gfx.setTextColor(color); int16_t x1,y1; uint16_t w,h; gfx.getTextBounds(text,0,0,&x1,&y1,&w,&h); gfx.setCursor((480-w)/2,y); gfx.print(text);
}
void symbol(const String &s) {
const int x=240,y=250; gfx.fillRect(0,95,480,280,0x1082);
if(s=="sun") { gfx.fillCircle(x,y,66,0xFFE0); for(int a=0;a<360;a+=30) { float r=a*PI/180; gfx.drawLine(x+82*cos(r),y+82*sin(r),x+110*cos(r),y+110*sin(r),0xFFE0); } }
else if(s=="moon"||s=="bed") { gfx.fillCircle(x-15,y,78,0xFFE0); gfx.fillCircle(x+24,y-22,78,0x1082); gfx.fillCircle(355,172,6,0xFFFF); gfx.fillCircle(390,215,4,0xFFFF); }
else if(s=="brush") { gfx.fillRoundRect(150,212,190,34,12,0x07FF); gfx.fillRoundRect(140,245,34,120,12,0xF800); for(int i=160;i<340;i+=18) gfx.drawLine(i,211,i,190,0xFFFF); }
else if(s=="food") { gfx.fillCircle(x,y+10,80,0xFFFF); gfx.fillCircle(x,y+10,58,0xFD20); gfx.fillRect(112,166,10,168,0xC618); gfx.fillRect(358,166,10,168,0xC618); }
else if(s=="school") { gfx.fillTriangle(145,244,240,162,335,244,0xF800); gfx.fillRect(155,244,170,105,0xFD20); gfx.fillRect(222,294,36,55,0x4208); }
else if(s=="play") gfx.fillTriangle(170,165,170,340,330,252,0x07E0);
else if(s=="bath") { gfx.fillRoundRect(120,262,240,72,24,0x07FF); gfx.fillCircle(180,238,18,0xFFFF); gfx.fillCircle(230,210,13,0xFFFF); gfx.fillCircle(280,235,20,0xFFFF); }
else gfx.fillCircle(x,y,70,0xF81F);
}
void drawScreen(bool timerMode, const String &s, const String &caption) {
struct tm t; char clock[10]="--:--"; if(getLocalTime(&t,5)) strftime(clock,sizeof(clock),"%H:%M",&t);
gfx.fillScreen(0x1082); centered(clock,18,0xFFFF,7); symbol(s); centered(caption,390,0xFFFF,3);
centered(timerMode ? "Tap the screen to cancel" : "Configure at 192.168.4.1",445,0xC618,1);
}
void queueChime() { chimeQueued = true; }
void setBuzzer(bool enabled) {
Wire.beginTransmission(IO_EXPANDER_ADDR);
Wire.write(BEE_EN);
Wire.write(enabled ? 1 : 0);
Wire.endTransmission();
}
// Three short pulses are gentler than a long alarm. The onboard buzzer has one fixed pitch.
void serviceChime() {
const unsigned long ON_MS = 75, OFF_MS = 110;
if (chimeQueued && !chimeOn && chimeStage == 0) { chimeQueued=false; chimeOn=true; chimeChangedAt=millis(); setBuzzer(true); return; }
if (!chimeOn && chimeStage == 0) return;
unsigned long interval = chimeOn ? ON_MS : OFF_MS;
if (millis() - chimeChangedAt < interval) return;
if (chimeOn) { setBuzzer(false); chimeOn=false; chimeStage++; }
else if (chimeStage < 3) { setBuzzer(true); chimeOn=true; }
else { chimeStage=0; }
chimeChangedAt=millis();
}
void updateClock() {
struct tm now; bool valid=getLocalTime(&now,5); bool timerOn=false; int seconds=-1; String timerText;
for(int i=0;i<5;i++) if(timers[i].active) { long left=(long)(timers[i].finish-millis()); if(left<=0) { timers[i].active=false; lastMinute=-1; queueChime(); }
else { timerOn=true; seconds=left/1000; timerText=timers[i].label+" "+String(seconds/60)+":"+(seconds%60<10?"0":"")+String(seconds%60); break; } }
if(timerOn) { if(seconds!=lastSecond) { lastSecond=seconds; drawScreen(true,"play",timerText); } return; }
lastSecond=-1; if(!valid || now.tm_min==lastMinute) return; lastMinute=now.tm_min;
int current=-1, minute=now.tm_hour*60+now.tm_min; for(int i=0;i<scheduleCount;i++) if(schedule[i].minute<=minute) current=i;
if(current<0 && scheduleCount) current=scheduleCount-1; String s=current>=0?schedule[current].symbol:"sun";
if (!displayedSymbol.length()) displayedSymbol=s; else if (s != displayedSymbol) { displayedSymbol=s; queueChime(); }
drawScreen(false,s,labelFor(s));
}
String page() {
String r=prefs.getString("schedule",DEFAULT_SCHEDULE); String h="<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'><style>body{font-family:sans-serif;max-width:680px;margin:auto;padding:16px;background:#f7f4ed}input,textarea,button{box-sizing:border-box;width:100%;padding:11px;margin:5px 0;font-size:16px}button{background:#1565c0;color:white;border:0;border-radius:8px}small{display:block}</style><h1>Symbol Clock</h1>";
h+="<h2>Daily picture routine</h2><form action=/schedule><textarea name=r rows=7>"+r+"</textarea><small>Format: HH:MM|symbol; Symbols: sun, moon, brush, food, school, play, bath, bed.</small><button>Save routine</button></form>";
h+="<h2>Independent timer</h2><form action=/timer><input name=label value='Time to tidy up' placeholder='Timer message'><input name=min type=number min=1 max=240 value=5><button>Start timer</button></form>";
h+="<h2>Home Wi-Fi and time zone</h2><form action=/wifi><input name=ssid value='"+ssid+"' placeholder='Home Wi-Fi name'><input name=pass type=password placeholder='Home Wi-Fi password'><input name=utc type=number value='"+String(utcOffset)+"'><small>UTC offset: New York winter -5; London 0; Sydney +10. Setup Wi-Fi stays available.</small><button>Save and restart</button></form>"; return h;
}
void startWeb() {
web.on("/",[](){web.send(200,"text/html",page());});
web.on("/schedule",[](){if(web.hasArg("r")){prefs.putString("schedule",web.arg("r"));loadSchedule();lastMinute=-1;}web.sendHeader("Location","/");web.send(303);});
web.on("/timer",[](){int mins=constrain(web.arg("min").toInt(),1,240);for(int i=0;i<5;i++)if(!timers[i].active){timers[i]={web.arg("label").length()?web.arg("label"):"Timer",millis()+(unsigned long)mins*60000,true};break;}web.sendHeader("Location","/");web.send(303);});
web.on("/wifi",[](){prefs.putString("ssid",web.arg("ssid"));if(web.arg("pass").length())prefs.putString("pass",web.arg("pass"));prefs.putInt("utc",web.arg("utc").toInt());web.send(200,"text/html","Saved. Restarting...");delay(400);ESP.restart();}); web.begin();
}
void setup() {
Serial.begin(115200); prefs.begin("symbolclock",false); loadSchedule(); ssid=prefs.getString("ssid",""); password=prefs.getString("pass",""); utcOffset=prefs.getInt("utc",-5);
Wire.begin(I2C_SDA,I2C_SCL); setBuzzer(false); gfx.begin(); touch.begin();
WiFi.mode(WIFI_AP_STA); WiFi.softAP("SymbolClock-Setup","symbolclock"); if(ssid.length()) WiFi.begin(ssid.c_str(),password.c_str());
configTime(utcOffset*3600,0,"pool.ntp.org","time.nist.gov"); startWeb(); drawScreen(false,"sun","Loading time...");
}
void loop() {
web.handleClient(); serviceChime(); touch.read(); if(touch.isTouched) { for(int i=0;i<5;i++) timers[i].active=false; lastMinute=-1; delay(180); }
if(millis()-lastPoll>=500) { lastPoll=millis(); updateClock(); }
}“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.