Skip to content

Instantly share code, notes, and snippets.

@thetzel
Created March 29, 2026 22:02
Show Gist options
  • Select an option

  • Save thetzel/daf460e53a89d9c688e0712f5007e6dc to your computer and use it in GitHub Desktop.

Select an option

Save thetzel/daf460e53a89d9c688e0712f5007e6dc to your computer and use it in GitHub Desktop.
CYD triple clock Arduino sketch for ESP32 Cheap Yellow Display
/*
CYD Triple Clock / Stopwatch
Target stack:
- ESP32 Cheap Yellow Display (ESP32-2432S028R)
- Arduino ESP32 core 2.x
- TFT_eSPI configured with witnessmenow CYD User_Setup.h
- LVGL 8.3.x
- XPT2046_Touchscreen
- CNMAT OSC library
Main behavior
- Boot screen: network / AP status only
- After first NTP sync: switch to main screen
- Row 1: NTP/local wall clock HH:MM:SS
- Row 2/3: OSC-fed stopwatches using local monotonic interpolation
- Short press on the main screen: settings
- Settings tabs: Color / Network / OSC
- Config saved in NVS using Preferences
- Fallback Soft AP + tiny config portal
OSC protocol
/clock/2/set <int32 ms> <int running>
/clock/2/reset
/clock/3/set <int32 ms> <int running>
/clock/3/reset
Stopwatch display format
- Default: HH:MM:SS
- If hours are 00: show MM:SS with a leading blank field
*/
#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <ctype.h>
#include "esp_sntp.h"
#include "esp_timer.h"
#ifndef SPI_BITORDER_MSBFIRST
#define SPI_BITORDER_MSBFIRST MSBFIRST
#endif
#ifndef SPI_BITORDER_LSBFIRST
#define SPI_BITORDER_LSBFIRST LSBFIRST
#endif
#ifndef BitOrder
typedef uint8_t BitOrder;
#endif
#include <lvgl.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <XPT2046_Touchscreen.h>
#ifndef ESPxx
#define ESPxx
#endif
#include <OSCBundle.h>
#include <OSCData.h>
#if __has_include("wifi_credentials.h")
#include "wifi_credentials.h"
#else
#define DEFAULT_WIFI_SSID ""
#define DEFAULT_WIFI_PASS ""
#endif
// ----------------------------
// CYD pins / display
// ----------------------------
#define TFT_BL 21
#define XPT2046_IRQ 36
#define XPT2046_MOSI 32
#define XPT2046_MISO 39
#define XPT2046_CLK 25
#define XPT2046_CS 33
static const uint16_t SCREEN_W = 320;
static const uint16_t SCREEN_H = 240;
// Touch calibration measured on this board.
static uint16_t touchMinX = 3639;
static uint16_t touchMaxX = 516;
static uint16_t touchMinY = 3557;
static uint16_t touchMaxY = 622;
SPIClass touchSpi = SPIClass(VSPI);
XPT2046_Touchscreen touch(XPT2046_CS, XPT2046_IRQ);
SPIClass tftSpi = SPIClass(HSPI);
Adafruit_ST7789 tft = Adafruit_ST7789(&tftSpi, 15, 2, -1);
static lv_disp_draw_buf_t drawBuf;
static lv_color_t drawBufMem[SCREEN_W * 20];
static uint16_t flushLineBuf[SCREEN_W];
// ----------------------------
// App constants
// ----------------------------
static const char *PREF_NS = "tripleclk";
static const char *DEFAULT_TZ = "CET-1CEST,M3.5.0,M10.5.0/3";
static const char *DEFAULT_NTP1 = "pool.ntp.org";
static const char *DEFAULT_NTP2 = "time.nist.gov";
static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 25000UL;
static const uint32_t WIFI_RETRY_INTERVAL_MS = 10000UL;
static const uint32_t NTP_SYNC_INTERVAL_MS = 300000UL; // 5 min
static const uint32_t NTP_SYNC_FLASH_MS = 900UL;
static const uint32_t OSC_STALE_DEFAULT_MS = 1500UL;
static const char *FALLBACK_AP_SSID = "CYD-Clock-Setup";
static const char *FALLBACK_AP_PASS = "clock1234";
static const uint8_t BACKLIGHT_PWM_CH = 0;
static const uint16_t BACKLIGHT_PWM_FREQ = 5000;
static const uint8_t BACKLIGHT_PWM_BITS = 8;
// ----------------------------
// Config
// ----------------------------
enum ColorPreset : uint8_t {
FG_GREEN = 0,
FG_AMBER,
FG_RED,
FG_WHITE,
FG_CYAN,
FG_MAGENTA,
FG_COUNT
};
enum BgPreset : uint8_t {
BG_BLACK = 0,
BG_NAVY,
BG_MAROON,
BG_DARKGREEN,
BG_COUNT
};
struct AppConfig {
char wifiSsid[33];
char wifiPass[65];
char tz[65];
uint16_t oscPort;
char osc2SetRoute[32];
char osc2ResetRoute[32];
char osc3SetRoute[32];
char osc3ResetRoute[32];
uint16_t oscStaleMs;
uint8_t fgPreset;
uint8_t bgPreset;
uint8_t brightness; // 0..255
};
AppConfig cfg;
Preferences prefs;
// ----------------------------
// Networking / servers
// ----------------------------
WiFiUDP Udp;
WebServer webServer(80);
bool udpStarted = false;
bool portalStarted = false;
bool apRunning = false;
bool wifiConnectAttemptActive = false;
uint32_t wifiConnectStartedAt = 0;
uint32_t lastWifiRetryAt = 0;
// ----------------------------
// NTP / clock state
// ----------------------------
volatile bool ntpSyncEvent = false;
bool ntpEverSynced = false;
uint32_t ntpSyncFlashUntil = 0;
bool sntpStarted = false;
// ----------------------------
// Stopwatch state
// ----------------------------
struct StopwatchState {
bool valid = false;
bool running = false;
int32_t baseMs = 0;
int64_t baseUs = 0;
char shown[9] = "NO DA TA";
uint32_t lastRxMs = 0;
};
StopwatchState sw2;
StopwatchState sw3;
bool oscDataChanged = false;
// ----------------------------
// UI state
// ----------------------------
enum UiState {
UI_BOOT,
UI_MAIN,
UI_SETTINGS
};
UiState uiState = UI_BOOT;
lv_obj_t *scrBoot = nullptr;
lv_obj_t *scrMain = nullptr;
lv_obj_t *scrSettings = nullptr;
lv_obj_t *lblBootTitle = nullptr;
lv_obj_t *lblBootSta = nullptr;
lv_obj_t *lblBootIp = nullptr;
lv_obj_t *lblBootAp = nullptr;
lv_obj_t *lblBootHint = nullptr;
// Settings widgets
lv_obj_t *tabview = nullptr;
lv_obj_t *kb = nullptr;
lv_obj_t *ddFg = nullptr;
lv_obj_t *ddBg = nullptr;
lv_obj_t *sliderBrightness = nullptr;
lv_obj_t *lblBrightness = nullptr;
lv_obj_t *taSsid = nullptr;
lv_obj_t *taPass = nullptr;
lv_obj_t *taTz = nullptr;
lv_obj_t *lblNetInfo = nullptr;
lv_obj_t *taOscPort = nullptr;
lv_obj_t *taOscStale = nullptr;
lv_obj_t *taOsc2Set = nullptr;
lv_obj_t *taOsc2Reset = nullptr;
lv_obj_t *taOsc3Set = nullptr;
lv_obj_t *taOsc3Reset = nullptr;
// ----------------------------
// 7-segment renderer
// ----------------------------
static const uint8_t SEG_A = 1 << 0;
static const uint8_t SEG_B = 1 << 1;
static const uint8_t SEG_C = 1 << 2;
static const uint8_t SEG_D = 1 << 3;
static const uint8_t SEG_E = 1 << 4;
static const uint8_t SEG_F = 1 << 5;
static const uint8_t SEG_G = 1 << 6;
struct SegCell {
lv_obj_t *root = nullptr;
lv_obj_t *seg[7] = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr};
lv_obj_t *dot1 = nullptr;
lv_obj_t *dot2 = nullptr;
};
static const uint8_t SEG_ROW_CHARS = 8;
struct SegRow {
lv_obj_t *root = nullptr;
SegCell cells[SEG_ROW_CHARS];
char text[SEG_ROW_CHARS + 1] = " ";
};
SegRow rowNtp;
SegRow rowSw2;
SegRow rowSw3;
static void showStartupProbe(const char *line1, const char *line2) {
tft.fillScreen(ST77XX_BLACK);
tft.fillRect(0, 0, 320, 60, ST77XX_RED);
tft.fillRect(0, 60, 320, 60, ST77XX_GREEN);
tft.fillRect(0, 120, 320, 60, ST77XX_BLUE);
tft.fillRect(0, 180, 320, 60, ST77XX_YELLOW);
tft.setTextWrap(false);
tft.setTextSize(2);
tft.setTextColor(ST77XX_WHITE, ST77XX_BLACK);
tft.setCursor(10, 12);
tft.print(line1);
tft.setCursor(10, 212);
tft.print(line2);
}
// ----------------------------
// Helpers
// ----------------------------
static void safeCopy(char *dst, size_t dstSize, const char *src) {
if (!dst || dstSize == 0) return;
if (!src) src = "";
strncpy(dst, src, dstSize - 1);
dst[dstSize - 1] = '\0';
}
static lv_color_t fgColorFromPreset(uint8_t idx) {
switch (idx) {
case FG_AMBER: return lv_color_hex(0xFFB000);
case FG_RED: return lv_color_hex(0xFF4040);
case FG_WHITE: return lv_color_hex(0xF8F8F8);
case FG_CYAN: return lv_color_hex(0x40F0FF);
case FG_MAGENTA: return lv_color_hex(0xFF50E5);
case FG_GREEN:
default: return lv_color_hex(0x45FF45);
}
}
static lv_color_t bgColorFromPreset(uint8_t idx) {
switch (idx) {
case BG_NAVY: return lv_color_hex(0x000814);
case BG_MAROON: return lv_color_hex(0x140000);
case BG_DARKGREEN: return lv_color_hex(0x001200);
case BG_BLACK:
default: return lv_color_hex(0x000000);
}
}
static lv_color_t offColor(void) {
return lv_color_hex(0x151515);
}
static void applyBacklight(uint8_t value) {
ledcWrite(BACKLIGHT_PWM_CH, value);
}
static int64_t monotonicUs() {
return esp_timer_get_time();
}
static bool wifiHasCreds() {
return cfg.wifiSsid[0] != '\0';
}
static String localIpString() {
if (WiFi.status() == WL_CONNECTED) return WiFi.localIP().toString();
return "-";
}
static String softApIpString() {
return WiFi.softAPIP().toString();
}
static void setDefaults() {
memset(&cfg, 0, sizeof(cfg));
safeCopy(cfg.wifiSsid, sizeof(cfg.wifiSsid), DEFAULT_WIFI_SSID);
safeCopy(cfg.wifiPass, sizeof(cfg.wifiPass), DEFAULT_WIFI_PASS);
safeCopy(cfg.tz, sizeof(cfg.tz), DEFAULT_TZ);
cfg.oscPort = 8000;
safeCopy(cfg.osc2SetRoute, sizeof(cfg.osc2SetRoute), "/clock/2/set");
safeCopy(cfg.osc2ResetRoute, sizeof(cfg.osc2ResetRoute), "/clock/2/reset");
safeCopy(cfg.osc3SetRoute, sizeof(cfg.osc3SetRoute), "/clock/3/set");
safeCopy(cfg.osc3ResetRoute, sizeof(cfg.osc3ResetRoute), "/clock/3/reset");
cfg.oscStaleMs = OSC_STALE_DEFAULT_MS;
cfg.fgPreset = FG_RED;
cfg.bgPreset = BG_BLACK;
cfg.brightness = 180;
}
static void loadConfig() {
setDefaults();
if (!prefs.begin(PREF_NS, false)) {
return;
}
String s;
if (prefs.isKey("ssid")) {
s = prefs.getString("ssid", "");
if (s.length() > 0) safeCopy(cfg.wifiSsid, sizeof(cfg.wifiSsid), s.c_str());
}
if (prefs.isKey("pass")) {
s = prefs.getString("pass", "");
if (s.length() > 0) safeCopy(cfg.wifiPass, sizeof(cfg.wifiPass), s.c_str());
}
if (prefs.isKey("tz")) {
s = prefs.getString("tz", DEFAULT_TZ);
if (s.length() > 0) safeCopy(cfg.tz, sizeof(cfg.tz), s.c_str());
}
if (prefs.isKey("oport")) cfg.oscPort = prefs.getUShort("oport", 8000);
if (prefs.isKey("o2set")) {
s = prefs.getString("o2set", "/clock/2/set");
if (s.length() > 0) safeCopy(cfg.osc2SetRoute, sizeof(cfg.osc2SetRoute), s.c_str());
}
if (prefs.isKey("o2rst")) {
s = prefs.getString("o2rst", "/clock/2/reset");
if (s.length() > 0) safeCopy(cfg.osc2ResetRoute, sizeof(cfg.osc2ResetRoute), s.c_str());
}
if (prefs.isKey("o3set")) {
s = prefs.getString("o3set", "/clock/3/set");
if (s.length() > 0) safeCopy(cfg.osc3SetRoute, sizeof(cfg.osc3SetRoute), s.c_str());
}
if (prefs.isKey("o3rst")) {
s = prefs.getString("o3rst", "/clock/3/reset");
if (s.length() > 0) safeCopy(cfg.osc3ResetRoute, sizeof(cfg.osc3ResetRoute), s.c_str());
}
if (prefs.isKey("ostale")) cfg.oscStaleMs = prefs.getUShort("ostale", OSC_STALE_DEFAULT_MS);
if (prefs.isKey("fg")) cfg.fgPreset = prefs.getUChar("fg", FG_RED);
if (prefs.isKey("bg")) cfg.bgPreset = prefs.getUChar("bg", BG_BLACK);
if (prefs.isKey("bright")) cfg.brightness = prefs.getUChar("bright", 180);
prefs.end();
if (cfg.fgPreset >= FG_COUNT) cfg.fgPreset = FG_RED;
if (cfg.bgPreset >= BG_COUNT) cfg.bgPreset = BG_BLACK;
}
static void saveConfig() {
prefs.begin(PREF_NS, false);
prefs.putString("ssid", cfg.wifiSsid);
prefs.putString("pass", cfg.wifiPass);
prefs.putString("tz", cfg.tz);
prefs.putUShort("oport", cfg.oscPort);
prefs.putString("o2set", cfg.osc2SetRoute);
prefs.putString("o2rst", cfg.osc2ResetRoute);
prefs.putString("o3set", cfg.osc3SetRoute);
prefs.putString("o3rst", cfg.osc3ResetRoute);
prefs.putUShort("ostale", cfg.oscStaleMs);
prefs.putUChar("fg", cfg.fgPreset);
prefs.putUChar("bg", cfg.bgPreset);
prefs.putUChar("bright", cfg.brightness);
prefs.end();
}
// ----------------------------
// 7-seg character maps
// ----------------------------
static uint8_t maskForChar(char c) {
switch (toupper(static_cast<unsigned char>(c))) {
case '0': return SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F;
case '1': return SEG_B | SEG_C;
case '2': return SEG_A | SEG_B | SEG_D | SEG_E | SEG_G;
case '3': return SEG_A | SEG_B | SEG_C | SEG_D | SEG_G;
case '4': return SEG_B | SEG_C | SEG_F | SEG_G;
case '5': return SEG_A | SEG_C | SEG_D | SEG_F | SEG_G;
case '6': return SEG_A | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G;
case '7': return SEG_A | SEG_B | SEG_C;
case '8': return SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F | SEG_G;
case '9': return SEG_A | SEG_B | SEG_C | SEG_D | SEG_F | SEG_G;
case 'A': return SEG_A | SEG_B | SEG_C | SEG_E | SEG_F | SEG_G;
case 'C': return SEG_A | SEG_D | SEG_E | SEG_F;
case 'D': return SEG_B | SEG_C | SEG_D | SEG_E | SEG_G;
case 'F': return SEG_A | SEG_E | SEG_F | SEG_G;
case 'I': return SEG_E | SEG_F;
case 'N': return SEG_C | SEG_E | SEG_G;
case 'O': return SEG_A | SEG_B | SEG_C | SEG_D | SEG_E | SEG_F;
case 'P': return SEG_A | SEG_B | SEG_E | SEG_F | SEG_G;
case 'S': return SEG_A | SEG_C | SEG_D | SEG_F | SEG_G;
case 'T': return SEG_D | SEG_E | SEG_F | SEG_G;
case 'Y': return SEG_B | SEG_C | SEG_D | SEG_F | SEG_G;
case '-': return SEG_G;
default: return 0;
}
}
static bool isColonChar(char c) {
return c == ':';
}
static void styleSegmentRect(lv_obj_t *obj, lv_color_t color) {
lv_obj_set_style_bg_color(obj, color, 0);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(obj, 0, 0);
lv_obj_set_style_radius(obj, 2, 0);
}
static void segCellCreate(SegCell &cell, lv_obj_t *parent, lv_coord_t x, lv_coord_t y, lv_coord_t w, lv_coord_t h) {
cell.root = lv_obj_create(parent);
lv_obj_set_pos(cell.root, x, y);
lv_obj_set_size(cell.root, w, h);
lv_obj_clear_flag(cell.root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_opa(cell.root, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(cell.root, 0, 0);
lv_obj_set_style_pad_all(cell.root, 0, 0);
lv_coord_t t = w / 8;
if (t < 4) t = 4;
lv_coord_t pad = 2;
lv_coord_t hSegW = w - 2 * (pad + t / 2);
lv_coord_t vSegH = (h - 3 * t) / 2;
// A
cell.seg[0] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[0], pad + t / 2, pad);
lv_obj_set_size(cell.seg[0], hSegW, t);
// B
cell.seg[1] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[1], w - pad - t, pad + t / 2);
lv_obj_set_size(cell.seg[1], t, vSegH);
// C
cell.seg[2] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[2], w - pad - t, pad + t + vSegH + t / 2);
lv_obj_set_size(cell.seg[2], t, vSegH);
// D
cell.seg[3] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[3], pad + t / 2, h - pad - t);
lv_obj_set_size(cell.seg[3], hSegW, t);
// E
cell.seg[4] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[4], pad, pad + t + vSegH + t / 2);
lv_obj_set_size(cell.seg[4], t, vSegH);
// F
cell.seg[5] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[5], pad, pad + t / 2);
lv_obj_set_size(cell.seg[5], t, vSegH);
// G
cell.seg[6] = lv_obj_create(cell.root);
lv_obj_set_pos(cell.seg[6], pad + t / 2, (h - t) / 2);
lv_obj_set_size(cell.seg[6], hSegW, t);
for (int i = 0; i < 7; ++i) {
lv_obj_clear_flag(cell.seg[i], LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_border_width(cell.seg[i], 0, 0);
lv_obj_set_style_radius(cell.seg[i], 2, 0);
}
lv_coord_t dot = t;
cell.dot1 = lv_obj_create(cell.root);
lv_obj_set_size(cell.dot1, dot, dot);
lv_obj_set_pos(cell.dot1, (w - dot) / 2, h / 3 - dot / 2);
lv_obj_set_style_radius(cell.dot1, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_width(cell.dot1, 0, 0);
cell.dot2 = lv_obj_create(cell.root);
lv_obj_set_size(cell.dot2, dot, dot);
lv_obj_set_pos(cell.dot2, (w - dot) / 2, (2 * h) / 3 - dot / 2);
lv_obj_set_style_radius(cell.dot2, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_width(cell.dot2, 0, 0);
}
static void segCellSetChar(SegCell &cell, char c) {
lv_color_t fg = fgColorFromPreset(cfg.fgPreset);
lv_color_t off = offColor();
bool colon = isColonChar(c);
uint8_t mask = colon ? 0 : maskForChar(c);
for (int i = 0; i < 7; ++i) {
bool on = (mask & (1 << i)) != 0;
styleSegmentRect(cell.seg[i], on ? fg : off);
}
styleSegmentRect(cell.dot1, colon ? fg : off);
styleSegmentRect(cell.dot2, colon ? fg : off);
}
static void segRowCreate(SegRow &row, lv_obj_t *parent, lv_coord_t y, lv_coord_t charW, lv_coord_t charH, lv_coord_t gap) {
lv_coord_t totalW = SEG_ROW_CHARS * charW + (SEG_ROW_CHARS - 1) * gap;
row.root = lv_obj_create(parent);
lv_obj_set_pos(row.root, (SCREEN_W - totalW) / 2, y);
lv_obj_set_size(row.root, totalW, charH);
lv_obj_clear_flag(row.root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_opa(row.root, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(row.root, 0, 0);
lv_obj_set_style_pad_all(row.root, 0, 0);
for (uint8_t i = 0; i < SEG_ROW_CHARS; ++i) {
segCellCreate(row.cells[i], row.root, i * (charW + gap), 0, charW, charH);
}
}
static void segRowSetText(SegRow &row, const char *txt) {
char padded[SEG_ROW_CHARS + 1];
memset(padded, ' ', SEG_ROW_CHARS);
padded[SEG_ROW_CHARS] = '\0';
if (txt) {
size_t n = strlen(txt);
if (n > SEG_ROW_CHARS) n = SEG_ROW_CHARS;
memcpy(padded, txt, n);
}
memcpy(row.text, padded, SEG_ROW_CHARS + 1);
for (uint8_t i = 0; i < SEG_ROW_CHARS; ++i) {
segCellSetChar(row.cells[i], padded[i]);
}
}
static void applyThemeToScreen(lv_obj_t *scr) {
if (!scr) return;
lv_obj_set_style_bg_color(scr, bgColorFromPreset(cfg.bgPreset), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_set_style_text_color(scr, fgColorFromPreset(cfg.fgPreset), 0);
}
static void styleLabel(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), 0);
}
static void styleTextArea(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_bg_color(obj, bgColorFromPreset(cfg.bgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_MAIN);
lv_obj_set_style_border_color(obj, lv_color_hex(0x606060), LV_PART_MAIN);
lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN);
lv_obj_set_style_radius(obj, 4, LV_PART_MAIN);
lv_obj_set_style_pad_left(obj, 6, LV_PART_MAIN);
lv_obj_set_style_pad_right(obj, 6, LV_PART_MAIN);
}
static void styleButton(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_bg_color(obj, lv_color_hex(0x101010), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_border_color(obj, lv_color_hex(0x707070), LV_PART_MAIN);
lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN);
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_color(obj, lv_color_hex(0x202020), LV_PART_MAIN | LV_STATE_PRESSED);
lv_obj_set_style_bg_color(obj, lv_color_hex(0x181818), LV_PART_MAIN | LV_STATE_FOCUSED);
}
static void styleDropdown(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_bg_color(obj, bgColorFromPreset(cfg.bgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_MAIN);
lv_obj_set_style_border_color(obj, lv_color_hex(0x707070), LV_PART_MAIN);
lv_obj_set_style_border_width(obj, 1, LV_PART_MAIN);
lv_obj_t *list = lv_dropdown_get_list(obj);
if (list) {
lv_obj_set_style_bg_color(list, bgColorFromPreset(cfg.bgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_opa(list, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_text_color(list, fgColorFromPreset(cfg.fgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_color(list, lv_color_hex(0x202020), LV_PART_SELECTED);
lv_obj_set_style_text_color(list, fgColorFromPreset(cfg.fgPreset), LV_PART_SELECTED);
}
}
static void styleSlider(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_bg_color(obj, lv_color_hex(0x202020), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_KNOB);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_KNOB);
}
static void styleTabview(lv_obj_t *obj) {
if (!obj) return;
lv_obj_set_style_bg_color(obj, bgColorFromPreset(cfg.bgPreset), LV_PART_MAIN);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(obj, lv_color_hex(0x101010), LV_PART_ITEMS);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, LV_PART_ITEMS);
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_ITEMS);
lv_obj_set_style_bg_color(obj, lv_color_hex(0x202020), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(obj, fgColorFromPreset(cfg.fgPreset), LV_PART_ITEMS | LV_STATE_CHECKED);
}
static const char *wifiStatusName(wl_status_t st) {
switch (st) {
case WL_NO_SHIELD: return "no shield";
case WL_IDLE_STATUS: return "idle";
case WL_NO_SSID_AVAIL: return "no ssid";
case WL_SCAN_COMPLETED: return "scan done";
case WL_CONNECTED: return "connected";
case WL_CONNECT_FAILED: return "auth failed";
case WL_CONNECTION_LOST:return "lost";
case WL_DISCONNECTED: return "disconnected";
default: return "unknown";
}
}
static void refreshAllSegRows() {
segRowSetText(rowNtp, rowNtp.text);
segRowSetText(rowSw2, rowSw2.text);
segRowSetText(rowSw3, rowSw3.text);
}
// ----------------------------
// Formatting
// ----------------------------
static void formatNtp(char out[9]) {
if (millis() < ntpSyncFlashUntil) {
safeCopy(out, 9, "SY NC");
return;
}
time_t now = 0;
time(&now);
if (now <= 100000) {
safeCopy(out, 9, "NO DA TA");
return;
}
struct tm ti;
localtime_r(&now, &ti);
snprintf(out, 9, "%02d:%02d:%02d", ti.tm_hour, ti.tm_min, ti.tm_sec);
}
static int32_t stopwatchNowMs(const StopwatchState &s) {
if (!s.valid) return -1;
if (!s.running) return s.baseMs;
int64_t deltaMs = (monotonicUs() - s.baseUs) / 1000LL;
int64_t now = static_cast<int64_t>(s.baseMs) + deltaMs;
if (now < 0) now = 0;
if (now > INT32_MAX) now = INT32_MAX;
return static_cast<int32_t>(now);
}
static void formatStopwatch(const StopwatchState &s, char out[9]) {
if (!s.valid) {
safeCopy(out, 9, "NO DA TA");
return;
}
int32_t totalMs = stopwatchNowMs(s);
if (totalMs < 0) {
safeCopy(out, 9, "NO DA TA");
return;
}
int32_t totalSec = totalMs / 1000;
int32_t sec = totalSec % 60;
int32_t min = (totalSec / 60) % 60;
int32_t hr = totalSec / 3600;
if (hr > 99) hr = 99;
if (hr > 0) {
snprintf(out, 9, "%02ld:%02ld:%02ld", (long)hr, (long)min, (long)sec);
} else {
snprintf(out, 9, " %02ld:%02ld", (long)min, (long)sec);
}
}
// ----------------------------
// Stopwatch updates
// ----------------------------
static void stopwatchSet(StopwatchState &s, int32_t ms, bool running) {
if (ms < 0) ms = 0;
s.valid = true;
s.running = running;
s.baseMs = ms;
s.baseUs = monotonicUs();
s.lastRxMs = millis();
}
static void stopwatchReset(StopwatchState &s) {
s.valid = true;
s.running = false;
s.baseMs = 0;
s.baseUs = monotonicUs();
s.lastRxMs = millis();
}
static void stopwatchInvalidateIfStale(StopwatchState &s) {
(void)s;
// Keep stopwatch values until explicitly replaced or reset.
}
// ----------------------------
// OSC receive
// ----------------------------
static void osc2SetHandler(OSCMessage &msg) {
int32_t ms = 0;
bool running = true;
if (msg.size() >= 1) ms = msg.getInt(0);
if (msg.size() >= 2) running = msg.getInt(1) != 0;
stopwatchSet(sw2, ms, running);
oscDataChanged = true;
}
static void osc2ResetHandler(OSCMessage &msg) {
(void)msg;
stopwatchReset(sw2);
oscDataChanged = true;
}
static void osc3SetHandler(OSCMessage &msg) {
int32_t ms = 0;
bool running = true;
if (msg.size() >= 1) ms = msg.getInt(0);
if (msg.size() >= 2) running = msg.getInt(1) != 0;
stopwatchSet(sw3, ms, running);
oscDataChanged = true;
}
static void osc3ResetHandler(OSCMessage &msg) {
(void)msg;
stopwatchReset(sw3);
oscDataChanged = true;
}
static int oscPaddedLength(const uint8_t *data, int len, int start) {
int i = start;
while (i < len && data[i] != 0) ++i;
if (i >= len) return -1;
++i; // include NUL
while ((i & 3) != 0) ++i;
return i - start;
}
static int32_t oscReadInt32(const uint8_t *p) {
return static_cast<int32_t>((static_cast<uint32_t>(p[0]) << 24) |
(static_cast<uint32_t>(p[1]) << 16) |
(static_cast<uint32_t>(p[2]) << 8) |
static_cast<uint32_t>(p[3]));
}
static void handleSimpleOscMessage(const char *address, const char *tags, const uint8_t *args, int argsLen) {
int intCount = 0;
if (tags && tags[0] == ',') {
for (const char *p = tags + 1; *p; ++p) {
if (*p == 'i') {
++intCount;
} else {
return; // unsupported type
}
}
}
if (argsLen < intCount * 4) return;
int32_t values[2] = {0, 0};
for (int i = 0; i < intCount && i < 2; ++i) {
values[i] = oscReadInt32(args + i * 4);
}
if (strcmp(address, cfg.osc2SetRoute) == 0) {
int32_t ms = intCount >= 1 ? values[0] : 0;
bool running = intCount >= 2 ? (values[1] != 0) : true;
stopwatchSet(sw2, ms, running);
oscDataChanged = true;
return;
}
if (strcmp(address, cfg.osc2ResetRoute) == 0) {
stopwatchReset(sw2);
oscDataChanged = true;
return;
}
if (strcmp(address, cfg.osc3SetRoute) == 0) {
int32_t ms = intCount >= 1 ? values[0] : 0;
bool running = intCount >= 2 ? (values[1] != 0) : true;
stopwatchSet(sw3, ms, running);
oscDataChanged = true;
return;
}
if (strcmp(address, cfg.osc3ResetRoute) == 0) {
stopwatchReset(sw3);
oscDataChanged = true;
}
}
static void restartUdp() {
if (udpStarted) {
Udp.stop();
udpStarted = false;
}
if (WiFi.status() == WL_CONNECTED || apRunning) {
Udp.begin(cfg.oscPort);
udpStarted = true;
}
}
static void pollOsc() {
if (!udpStarted) return;
int packetSize = Udp.parsePacket();
if (packetSize <= 0) return;
uint8_t packet[128];
int len = Udp.read(packet, sizeof(packet));
if (len <= 0) return;
int addrPadded = oscPaddedLength(packet, len, 0);
if (addrPadded <= 0) return;
const char *address = reinterpret_cast<const char *>(packet);
int tagsOffset = addrPadded;
int tagsPadded = oscPaddedLength(packet, len, tagsOffset);
if (tagsPadded <= 0) return;
const char *tags = reinterpret_cast<const char *>(packet + tagsOffset);
int argsOffset = tagsOffset + tagsPadded;
if (argsOffset > len) return;
handleSimpleOscMessage(address, tags, packet + argsOffset, len - argsOffset);
}
// ----------------------------
// NTP
// ----------------------------
static void onTimeSync(struct timeval *tv) {
(void)tv;
ntpSyncEvent = true;
}
static void startSntpIfNeeded() {
if (sntpStarted) return;
if (WiFi.status() != WL_CONNECTED) return;
setenv("TZ", cfg.tz[0] ? cfg.tz : DEFAULT_TZ, 1);
tzset();
sntp_set_sync_mode(SNTP_SYNC_MODE_SMOOTH);
sntp_set_sync_interval(NTP_SYNC_INTERVAL_MS);
sntp_set_time_sync_notification_cb(onTimeSync);
configTzTime(cfg.tz[0] ? cfg.tz : DEFAULT_TZ, DEFAULT_NTP1, DEFAULT_NTP2);
sntpStarted = true;
}
static void restartSntp() {
sntp_stop();
sntpStarted = false;
ntpSyncEvent = false;
ntpEverSynced = false;
ntpSyncFlashUntil = 0;
startSntpIfNeeded();
}
// ----------------------------
// Soft AP portal
// ----------------------------
static void portalRoot() {
String html;
html.reserve(2500);
html += F("<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>");
html += F("<title>CYD Clock Setup</title><style>body{font-family:sans-serif;max-width:640px;margin:20px auto;padding:0 12px;}label{display:block;margin-top:12px;font-weight:bold;}input{width:100%;padding:10px;font-size:16px;}button{margin-top:16px;padding:12px 16px;font-size:16px;}small{color:#555;}</style></head><body>");
html += F("<h2>CYD Clock Setup</h2>");
html += F("<form method='POST' action='/save'>");
html += F("<label>WiFi SSID</label><input name='ssid' value='");
html += cfg.wifiSsid;
html += F("'>");
html += F("<label>WiFi Password</label><input name='pass' type='password' value='");
html += cfg.wifiPass;
html += F("'>");
html += F("<label>Timezone (POSIX TZ string)</label><input name='tz' value='");
html += cfg.tz;
html += F("'><small>Example: CET-1CEST,M3.5.0,M10.5.0/3</small>");
html += F("<label>OSC Port</label><input name='oport' value='");
html += String(cfg.oscPort);
html += F("'>");
html += F("<label>Clock 2 Set Route</label><input name='o2set' value='");
html += cfg.osc2SetRoute;
html += F("'>");
html += F("<label>Clock 2 Reset Route</label><input name='o2rst' value='");
html += cfg.osc2ResetRoute;
html += F("'>");
html += F("<label>Clock 3 Set Route</label><input name='o3set' value='");
html += cfg.osc3SetRoute;
html += F("'>");
html += F("<label>Clock 3 Reset Route</label><input name='o3rst' value='");
html += cfg.osc3ResetRoute;
html += F("'>");
html += F("<label>OSC Stale Timeout (ms)</label><input name='ostale' value='");
html += String(cfg.oscStaleMs);
html += F("'>");
html += F("<label>Brightness (0-255)</label><input name='bright' value='");
html += String(cfg.brightness);
html += F("'>");
html += F("<button type='submit'>Save and reboot</button></form></body></html>");
webServer.send(200, "text/html", html);
}
static void portalSave() {
safeCopy(cfg.wifiSsid, sizeof(cfg.wifiSsid), webServer.arg("ssid").c_str());
safeCopy(cfg.wifiPass, sizeof(cfg.wifiPass), webServer.arg("pass").c_str());
String tz = webServer.arg("tz");
if (tz.length() == 0) tz = DEFAULT_TZ;
safeCopy(cfg.tz, sizeof(cfg.tz), tz.c_str());
uint16_t port = static_cast<uint16_t>(webServer.arg("oport").toInt());
cfg.oscPort = port == 0 ? 8000 : port;
safeCopy(cfg.osc2SetRoute, sizeof(cfg.osc2SetRoute), webServer.arg("o2set").c_str());
safeCopy(cfg.osc2ResetRoute, sizeof(cfg.osc2ResetRoute), webServer.arg("o2rst").c_str());
safeCopy(cfg.osc3SetRoute, sizeof(cfg.osc3SetRoute), webServer.arg("o3set").c_str());
safeCopy(cfg.osc3ResetRoute, sizeof(cfg.osc3ResetRoute), webServer.arg("o3rst").c_str());
uint16_t stale = static_cast<uint16_t>(webServer.arg("ostale").toInt());
cfg.oscStaleMs = stale == 0 ? OSC_STALE_DEFAULT_MS : stale;
int bright = webServer.arg("bright").toInt();
if (bright < 0) bright = 0;
if (bright > 255) bright = 255;
cfg.brightness = static_cast<uint8_t>(bright);
saveConfig();
String html = F("<!doctype html><html><body><h3>Saved.</h3><p>Rebooting...</p></body></html>");
webServer.send(200, "text/html", html);
delay(400);
ESP.restart();
}
static void startPortal() {
if (portalStarted) return;
webServer.on("/", HTTP_GET, portalRoot);
webServer.on("/save", HTTP_POST, portalSave);
webServer.begin();
portalStarted = true;
}
static void stopPortal() {
if (!portalStarted) return;
webServer.stop();
portalStarted = false;
}
static void startSoftAp() {
if (apRunning) return;
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(FALLBACK_AP_SSID, FALLBACK_AP_PASS);
startPortal();
apRunning = true;
restartUdp();
}
static void stopSoftAp() {
if (!apRunning) return;
WiFi.softAPdisconnect(false);
stopPortal();
apRunning = false;
restartUdp();
}
// ----------------------------
// WiFi connection
// ----------------------------
static void beginWifiConnect() {
if (!wifiHasCreds()) {
startSoftAp();
wifiConnectAttemptActive = false;
return;
}
WiFi.mode(apRunning ? WIFI_AP_STA : WIFI_STA);
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.disconnect(false, false);
delay(100);
WiFi.begin(cfg.wifiSsid, cfg.wifiPass);
wifiConnectAttemptActive = true;
wifiConnectStartedAt = millis();
}
static void ensureWifi() {
wl_status_t st = WiFi.status();
if (st == WL_CONNECTED) {
wifiConnectAttemptActive = false;
startSntpIfNeeded();
if (apRunning) {
stopSoftAp();
} else if (!udpStarted) {
restartUdp();
}
return;
}
if (!wifiHasCreds()) {
startSoftAp();
return;
}
if (!wifiConnectAttemptActive) {
if (millis() - lastWifiRetryAt >= WIFI_RETRY_INTERVAL_MS) {
lastWifiRetryAt = millis();
beginWifiConnect();
}
return;
}
if (millis() - wifiConnectStartedAt >= WIFI_CONNECT_TIMEOUT_MS) {
wifiConnectAttemptActive = false;
startSoftAp();
}
}
// ----------------------------
// UI
// ----------------------------
static void hideKeyboard() {
if (!kb) return;
lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN);
}
static void showKeyboardFor(lv_obj_t *ta) {
if (!kb || !ta) return;
lv_keyboard_set_textarea(kb, ta);
lv_obj_clear_flag(kb, LV_OBJ_FLAG_HIDDEN);
}
static void textareaEvent(lv_event_t *e) {
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t *ta = lv_event_get_target(e);
if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) {
showKeyboardFor(ta);
}
}
static void keyboardEvent(lv_event_t *e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) {
lv_keyboard_set_textarea(kb, nullptr);
hideKeyboard();
}
}
static void updateBootLabels() {
if (!lblBootTitle) return;
String staLine = "STA: ";
if (!wifiHasCreds()) {
staLine += "no saved WiFi";
} else if (WiFi.status() == WL_CONNECTED) {
staLine += "connected ";
staLine += cfg.wifiSsid;
} else if (wifiConnectAttemptActive) {
staLine += "connecting ";
staLine += cfg.wifiSsid;
} else {
staLine += wifiStatusName(WiFi.status());
staLine += " ";
staLine += cfg.wifiSsid;
}
String ipLine = "IP: ";
ipLine += localIpString();
String apLine = "AP: ";
if (apRunning) {
apLine += String(FALLBACK_AP_SSID) + " " + softApIpString();
} else {
apLine += "off";
}
lv_label_set_text(lblBootTitle, "NETWORK STATUS");
lv_label_set_text(lblBootSta, staLine.c_str());
lv_label_set_text(lblBootIp, ipLine.c_str());
lv_label_set_text(lblBootAp, apLine.c_str());
if (ntpEverSynced) {
lv_label_set_text(lblBootHint, "Time synced. Loading clock...");
} else if (apRunning) {
lv_label_set_text(lblBootHint, "Connect to AP and open 192.168.4.1");
} else {
lv_label_set_text(lblBootHint, "Waiting for first NTP sync...");
}
}
static void createBootScreen() {
scrBoot = lv_obj_create(nullptr);
lv_obj_clear_flag(scrBoot, LV_OBJ_FLAG_SCROLLABLE);
applyThemeToScreen(scrBoot);
lblBootTitle = lv_label_create(scrBoot);
lblBootSta = lv_label_create(scrBoot);
lblBootIp = lv_label_create(scrBoot);
lblBootAp = lv_label_create(scrBoot);
lblBootHint = lv_label_create(scrBoot);
lv_obj_align(lblBootTitle, LV_ALIGN_TOP_MID, 0, 18);
lv_obj_align(lblBootSta, LV_ALIGN_TOP_LEFT, 18, 62);
lv_obj_align(lblBootIp, LV_ALIGN_TOP_LEFT, 18, 94);
lv_obj_align(lblBootAp, LV_ALIGN_TOP_LEFT, 18, 126);
lv_obj_align(lblBootHint, LV_ALIGN_BOTTOM_MID, 0, -20);
lv_label_set_text(lblBootTitle, "NETWORK STATUS");
lv_label_set_text(lblBootSta, "STA: -");
lv_label_set_text(lblBootIp, "IP: -");
lv_label_set_text(lblBootAp, "AP: -");
lv_label_set_text(lblBootHint, "Waiting...");
}
static void mainShortClickEvent(lv_event_t *e);
static void createMainScreen() {
scrMain = lv_obj_create(nullptr);
lv_obj_clear_flag(scrMain, LV_OBJ_FLAG_SCROLLABLE);
applyThemeToScreen(scrMain);
segRowCreate(rowNtp, scrMain, 10, 34, 58, 2);
segRowCreate(rowSw2, scrMain, 87, 34, 58, 2);
segRowCreate(rowSw3, scrMain, 164, 34, 58, 2);
segRowSetText(rowNtp, "NO DA TA");
segRowSetText(rowSw2, "NO DA TA");
segRowSetText(rowSw3, "NO DA TA");
lv_obj_t *touchLayer = lv_obj_create(scrMain);
lv_obj_set_pos(touchLayer, 0, 0);
lv_obj_set_size(touchLayer, SCREEN_W, SCREEN_H);
lv_obj_clear_flag(touchLayer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(touchLayer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(touchLayer, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(touchLayer, 0, 0);
lv_obj_add_event_cb(touchLayer, mainShortClickEvent, LV_EVENT_SHORT_CLICKED, nullptr);
lv_obj_move_foreground(touchLayer);
}
static void updateNetInfoLabel() {
if (!lblNetInfo) return;
String s = "STA: ";
s += (WiFi.status() == WL_CONNECTED) ? "connected" : "not connected";
s += "\nIP: ";
s += localIpString();
s += "\nAP: ";
s += apRunning ? "on" : "off";
if (apRunning) {
s += " ";
s += softApIpString();
}
lv_label_set_text(lblNetInfo, s.c_str());
}
static void settingsSaveEvent(lv_event_t *e) {
(void)e;
cfg.fgPreset = lv_dropdown_get_selected(ddFg);
cfg.bgPreset = lv_dropdown_get_selected(ddBg);
int bright = lv_slider_get_value(sliderBrightness);
if (bright < 0) bright = 0;
if (bright > 255) bright = 255;
cfg.brightness = static_cast<uint8_t>(bright);
safeCopy(cfg.wifiSsid, sizeof(cfg.wifiSsid), lv_textarea_get_text(taSsid));
safeCopy(cfg.wifiPass, sizeof(cfg.wifiPass), lv_textarea_get_text(taPass));
safeCopy(cfg.tz, sizeof(cfg.tz), lv_textarea_get_text(taTz));
uint16_t port = static_cast<uint16_t>(atoi(lv_textarea_get_text(taOscPort)));
cfg.oscPort = port == 0 ? 8000 : port;
uint16_t stale = static_cast<uint16_t>(atoi(lv_textarea_get_text(taOscStale)));
cfg.oscStaleMs = stale == 0 ? OSC_STALE_DEFAULT_MS : stale;
safeCopy(cfg.osc2SetRoute, sizeof(cfg.osc2SetRoute), lv_textarea_get_text(taOsc2Set));
safeCopy(cfg.osc2ResetRoute, sizeof(cfg.osc2ResetRoute), lv_textarea_get_text(taOsc2Reset));
safeCopy(cfg.osc3SetRoute, sizeof(cfg.osc3SetRoute), lv_textarea_get_text(taOsc3Set));
safeCopy(cfg.osc3ResetRoute, sizeof(cfg.osc3ResetRoute), lv_textarea_get_text(taOsc3Reset));
saveConfig();
applyBacklight(cfg.brightness);
applyThemeToScreen(scrBoot);
applyThemeToScreen(scrMain);
applyThemeToScreen(scrSettings);
refreshAllSegRows();
restartUdp();
// Reconnect / restart time if network-related fields changed.
sntp_stop();
sntpStarted = false;
ntpSyncEvent = false;
WiFi.disconnect();
delay(50);
beginWifiConnect();
hideKeyboard();
uiState = UI_MAIN;
lv_scr_load(scrMain);
}
static void settingsBackEvent(lv_event_t *e) {
(void)e;
hideKeyboard();
uiState = UI_MAIN;
lv_scr_load(scrMain);
}
static void settingsPortalToggleEvent(lv_event_t *e) {
(void)e;
if (apRunning) stopSoftAp();
else startSoftAp();
updateNetInfoLabel();
}
static void brightnessEvent(lv_event_t *e) {
(void)e;
int v = lv_slider_get_value(sliderBrightness);
char buf[32];
snprintf(buf, sizeof(buf), "Brightness: %d", v);
lv_label_set_text(lblBrightness, buf);
applyBacklight(static_cast<uint8_t>(v));
}
static lv_obj_t *makeLabel(lv_obj_t *parent, const char *text, lv_coord_t x, lv_coord_t y) {
lv_obj_t *l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_pos(l, x, y);
styleLabel(l);
return l;
}
static lv_obj_t *makeTextArea(lv_obj_t *parent, const char *txt, lv_coord_t x, lv_coord_t y, lv_coord_t w, bool password = false) {
lv_obj_t *ta = lv_textarea_create(parent);
lv_obj_set_pos(ta, x, y);
lv_obj_set_size(ta, w, 34);
lv_textarea_set_one_line(ta, true);
lv_textarea_set_text(ta, txt ? txt : "");
lv_textarea_set_password_mode(ta, password);
lv_obj_add_event_cb(ta, textareaEvent, LV_EVENT_ALL, nullptr);
styleTextArea(ta);
return ta;
}
static void populateSettingsFromCfg() {
if (!scrSettings) return;
lv_dropdown_set_selected(ddFg, cfg.fgPreset);
lv_dropdown_set_selected(ddBg, cfg.bgPreset);
lv_slider_set_value(sliderBrightness, cfg.brightness, LV_ANIM_OFF);
char buf[32];
snprintf(buf, sizeof(buf), "Brightness: %u", cfg.brightness);
lv_label_set_text(lblBrightness, buf);
lv_textarea_set_text(taSsid, cfg.wifiSsid);
lv_textarea_set_text(taPass, cfg.wifiPass);
lv_textarea_set_text(taTz, cfg.tz);
snprintf(buf, sizeof(buf), "%u", cfg.oscPort);
lv_textarea_set_text(taOscPort, buf);
snprintf(buf, sizeof(buf), "%u", cfg.oscStaleMs);
lv_textarea_set_text(taOscStale, buf);
lv_textarea_set_text(taOsc2Set, cfg.osc2SetRoute);
lv_textarea_set_text(taOsc2Reset, cfg.osc2ResetRoute);
lv_textarea_set_text(taOsc3Set, cfg.osc3SetRoute);
lv_textarea_set_text(taOsc3Reset, cfg.osc3ResetRoute);
updateNetInfoLabel();
hideKeyboard();
}
static void createSettingsScreen() {
scrSettings = lv_obj_create(nullptr);
lv_obj_clear_flag(scrSettings, LV_OBJ_FLAG_SCROLLABLE);
applyThemeToScreen(scrSettings);
tabview = lv_tabview_create(scrSettings, LV_DIR_TOP, 32);
lv_obj_set_size(tabview, SCREEN_W, SCREEN_H - 54);
lv_obj_align(tabview, LV_ALIGN_TOP_MID, 0, 0);
styleTabview(tabview);
lv_obj_t *tabColor = lv_tabview_add_tab(tabview, "Color");
lv_obj_t *tabNet = lv_tabview_add_tab(tabview, "Network");
lv_obj_t *tabOsc = lv_tabview_add_tab(tabview, "OSC");
applyThemeToScreen(tabColor);
applyThemeToScreen(tabNet);
applyThemeToScreen(tabOsc);
// Color tab
makeLabel(tabColor, "Foreground", 10, 10);
ddFg = lv_dropdown_create(tabColor);
lv_obj_set_pos(ddFg, 10, 32);
lv_obj_set_width(ddFg, 135);
lv_dropdown_set_options(ddFg, "Green\nAmber\nRed\nWhite\nCyan\nMagenta");
styleDropdown(ddFg);
makeLabel(tabColor, "Background", 165, 10);
ddBg = lv_dropdown_create(tabColor);
lv_obj_set_pos(ddBg, 165, 32);
lv_obj_set_width(ddBg, 135);
lv_dropdown_set_options(ddBg, "Black\nNavy\nMaroon\nDark Green");
styleDropdown(ddBg);
lblBrightness = makeLabel(tabColor, "Brightness: 180", 10, 82);
sliderBrightness = lv_slider_create(tabColor);
lv_obj_set_pos(sliderBrightness, 10, 108);
lv_obj_set_width(sliderBrightness, 290);
lv_slider_set_range(sliderBrightness, 0, 255);
lv_obj_add_event_cb(sliderBrightness, brightnessEvent, LV_EVENT_VALUE_CHANGED, nullptr);
styleSlider(sliderBrightness);
// Network tab
makeLabel(tabNet, "SSID", 10, 8);
taSsid = makeTextArea(tabNet, "", 10, 28, 290, false);
makeLabel(tabNet, "Password", 10, 66);
taPass = makeTextArea(tabNet, "", 10, 86, 290, true);
makeLabel(tabNet, "Timezone", 10, 124);
taTz = makeTextArea(tabNet, "", 10, 144, 290, false);
lblNetInfo = makeLabel(tabNet, "STA: -\nIP: -\nAP: -", 10, 184);
lv_obj_t *btnPortal = lv_btn_create(tabNet);
lv_obj_set_size(btnPortal, 112, 32);
lv_obj_set_pos(btnPortal, 188, 182);
lv_obj_add_event_cb(btnPortal, settingsPortalToggleEvent, LV_EVENT_CLICKED, nullptr);
styleButton(btnPortal);
lv_obj_t *btnPortalLbl = lv_label_create(btnPortal);
lv_label_set_text(btnPortalLbl, "Toggle AP");
styleLabel(btnPortalLbl);
lv_obj_center(btnPortalLbl);
// OSC tab
makeLabel(tabOsc, "Port", 10, 8);
taOscPort = makeTextArea(tabOsc, "8000", 10, 28, 86, false);
makeLabel(tabOsc, "Stale ms", 112, 8);
taOscStale = makeTextArea(tabOsc, "1500", 112, 28, 88, false);
makeLabel(tabOsc, "Clock2 set", 10, 66);
taOsc2Set = makeTextArea(tabOsc, "", 10, 86, 290, false);
makeLabel(tabOsc, "Clock2 reset", 10, 124);
taOsc2Reset = makeTextArea(tabOsc, "", 10, 144, 290, false);
makeLabel(tabOsc, "Clock3 set", 10, 182);
taOsc3Set = makeTextArea(tabOsc, "", 10, 202, 290, false);
makeLabel(tabOsc, "Clock3 reset", 10, 240);
taOsc3Reset = makeTextArea(tabOsc, "", 10, 260, 290, false);
// Bottom buttons on settings screen
lv_obj_t *btnBack = lv_btn_create(scrSettings);
lv_obj_set_size(btnBack, 92, 38);
lv_obj_align(btnBack, LV_ALIGN_BOTTOM_LEFT, 8, -8);
lv_obj_add_event_cb(btnBack, settingsBackEvent, LV_EVENT_CLICKED, nullptr);
styleButton(btnBack);
lv_obj_t *lblBack = lv_label_create(btnBack);
lv_label_set_text(lblBack, "Back");
styleLabel(lblBack);
lv_obj_center(lblBack);
lv_obj_t *btnSave = lv_btn_create(scrSettings);
lv_obj_set_size(btnSave, 130, 38);
lv_obj_align(btnSave, LV_ALIGN_BOTTOM_RIGHT, -8, -8);
lv_obj_add_event_cb(btnSave, settingsSaveEvent, LV_EVENT_CLICKED, nullptr);
styleButton(btnSave);
lv_obj_t *lblSave = lv_label_create(btnSave);
lv_label_set_text(lblSave, "Save");
styleLabel(lblSave);
lv_obj_center(lblSave);
// Shared keyboard
kb = lv_keyboard_create(scrSettings);
lv_obj_set_size(kb, SCREEN_W, 110);
lv_obj_align(kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(kb, keyboardEvent, LV_EVENT_ALL, nullptr);
lv_obj_add_flag(kb, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_bg_color(kb, lv_color_hex(0x101010), LV_PART_MAIN);
lv_obj_set_style_text_color(kb, fgColorFromPreset(cfg.fgPreset), LV_PART_ITEMS);
lv_obj_set_style_bg_color(kb, lv_color_hex(0x202020), LV_PART_ITEMS);
lv_obj_set_style_bg_color(kb, lv_color_hex(0x303030), LV_PART_ITEMS | LV_STATE_PRESSED);
populateSettingsFromCfg();
}
static void mainShortClickEvent(lv_event_t *e) {
(void)e;
populateSettingsFromCfg();
uiState = UI_SETTINGS;
lv_scr_load(scrSettings);
}
static void showBoot() {
uiState = UI_BOOT;
updateBootLabels();
lv_scr_load(scrBoot);
}
static void showMain() {
uiState = UI_MAIN;
lv_scr_load(scrMain);
}
// ----------------------------
// LVGL / display / touch
// ----------------------------
static void my_disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
const int16_t w = static_cast<int16_t>(area->x2 - area->x1 + 1);
const int16_t h = static_cast<int16_t>(area->y2 - area->y1 + 1);
const uint32_t pixelCount = static_cast<uint32_t>(w) * static_cast<uint32_t>(h);
for (uint32_t i = 0; i < pixelCount; ++i) {
reinterpret_cast<uint16_t *>(color_p)[i] = lv_color_to16(color_p[i]);
}
tft.startWrite();
tft.setAddrWindow(area->x1, area->y1, w, h);
tft.writePixels(reinterpret_cast<uint16_t *>(color_p), pixelCount, true, false);
tft.endWrite();
lv_disp_flush_ready(disp);
}
static void my_touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
(void)drv;
if (!touch.tirqTouched() || !touch.touched()) {
data->state = LV_INDEV_STATE_REL;
return;
}
TS_Point p = touch.getPoint();
uint16_t x = map(p.x, touchMinX, touchMaxX, 1, SCREEN_W);
uint16_t y = map(p.y, touchMinY, touchMaxY, 1, SCREEN_H);
if (x > SCREEN_W) x = SCREEN_W;
if (y > SCREEN_H) y = SCREEN_H;
data->state = LV_INDEV_STATE_PR;
data->point.x = x;
data->point.y = y;
}
static void initDisplayAndLvgl() {
Serial.println("initDisplayAndLvgl: start");
pinMode(TFT_BL, OUTPUT);
ledcSetup(BACKLIGHT_PWM_CH, BACKLIGHT_PWM_FREQ, BACKLIGHT_PWM_BITS);
ledcAttachPin(TFT_BL, BACKLIGHT_PWM_CH);
applyBacklight(cfg.brightness);
tftSpi.begin(14, 12, 13, 15);
tft.init(240, 320);
tft.setSPISpeed(24000000);
tft.setRotation(1); // landscape 320x240
tft.invertDisplay(false);
showStartupProbe("Clock app boot", "ST7789 init OK");
delay(1200);
tft.fillScreen(ST77XX_BLACK);
delay(30);
touchSpi.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
touch.begin(touchSpi);
touch.setRotation(1);
lv_init();
lv_disp_draw_buf_init(&drawBuf, drawBufMem, nullptr, SCREEN_W * 20);
static lv_disp_drv_t dispDrv;
lv_disp_drv_init(&dispDrv);
dispDrv.hor_res = SCREEN_W;
dispDrv.ver_res = SCREEN_H;
dispDrv.flush_cb = my_disp_flush;
dispDrv.draw_buf = &drawBuf;
lv_disp_drv_register(&dispDrv);
static lv_indev_drv_t indevDrv;
lv_indev_drv_init(&indevDrv);
indevDrv.type = LV_INDEV_TYPE_POINTER;
indevDrv.read_cb = my_touch_read;
lv_indev_drv_register(&indevDrv);
Serial.println("initDisplayAndLvgl: ready");
}
// ----------------------------
// Main periodic UI updates
// ----------------------------
static void updateMainRows() {
char buf[9];
formatNtp(buf);
if (strncmp(rowNtp.text, buf, 8) != 0) segRowSetText(rowNtp, buf);
stopwatchInvalidateIfStale(sw2);
formatStopwatch(sw2, buf);
if (strncmp(rowSw2.text, buf, 8) != 0) segRowSetText(rowSw2, buf);
stopwatchInvalidateIfStale(sw3);
formatStopwatch(sw3, buf);
if (strncmp(rowSw3.text, buf, 8) != 0) segRowSetText(rowSw3, buf);
}
static void handleFirstNtpSync() {
if (!ntpSyncEvent) return;
ntpSyncEvent = false;
if (!ntpEverSynced) {
ntpEverSynced = true;
ntpSyncFlashUntil = millis() + NTP_SYNC_FLASH_MS;
showMain();
}
}
static void setupUi() {
createBootScreen();
createMainScreen();
createSettingsScreen();
showBoot();
}
// ----------------------------
// Arduino
// ----------------------------
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("setup: boot");
loadConfig();
Serial.println("setup: config loaded");
initDisplayAndLvgl();
Serial.println("setup: display ready");
setupUi();
Serial.println("setup: ui built");
lv_timer_handler();
lv_refr_now(nullptr);
Serial.println("setup: first refresh done");
sw2.valid = false;
sw3.valid = false;
beginWifiConnect();
Serial.println("setup: wifi connect started");
}
void loop() {
ensureWifi();
if (portalStarted) {
webServer.handleClient();
}
pollOsc();
handleFirstNtpSync();
updateBootLabels();
if (uiState == UI_MAIN) {
updateMainRows();
if (oscDataChanged) {
lv_refr_now(nullptr);
oscDataChanged = false;
}
} else if (uiState == UI_SETTINGS) {
updateNetInfoLabel();
}
lv_timer_handler();
delay(5);
}

CYD Triple Clock

Arduino sketch for an ESP32 Cheap Yellow Display (ESP32-2432S028R) triple clock setup.

Includes:

  • NTP-synced wall clock
  • Two OSC-driven stopwatch displays
  • On-device settings UI with saved preferences
  • Fallback Wi-Fi setup AP and config portal

Main sketch:

  • cyd_triple_clock.ino
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment