|
|
|
/* |
|
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); |
|
} |