/* * ============================================ * XIAO ESP32S3 – Alarm Clock Firmware v3 * Gabriel Stacey-Chartrand | Fab Academy * MQTT over WebSockets (WSS) – port 8884 * ============================================ * * Component | Signal | XIAO Pin | GPIO * --------------|---------|----------|------ * MAX7219 (x4) | CLK | D8 | GPIO7 * MAX7219 (x4) | DIN | D10 | GPIO9 * MAX7219 (x4) | CS | D6 | GPIO43 * DS1302 | SCLK | D3 | GPIO4 * DS1302 | I/O | D4 | GPIO5 * DS1302 | CE | D5 | GPIO6 * Button | SIG | D7 | GPIO44 (ext. 10kΩ pull-up to 3V3) * * MQTT subscribe topics: * s_c/alarm/set payload: "HH:MM" * s_c/alarm/status payload: "ON" / "OFF" * s_c/alarm/pomodoro payload: "WORK_START:Xmin" / "BREAK_START:Xmin" / * "WORK_END" / "BREAK_END" / "PAUSED" / "RESET" * * MQTT publish topics (board -> interface): * s_c/alarm/status payload: "ON" / "OFF" (when button toggles alarm) * s_c/alarm/pomodoro payload: "PAUSED" / "RESET" (when button acts) * * Libraries required: * - MD_MAX72XX by MajicDesigns (NO MD_Parola) * - Rtc by Makuna (RtcDS1302 + ThreeWire) * - WebSockets by Markus Sattler * - MQTTPubSubClient by hideakitai * ============================================ */ #include #include #include #include #include #include #include // ── Enums (declared early so all functions can use them) ────── enum PomState { POM_IDLE, POM_WORK, POM_BREAK, POM_PAUSED_WORK, POM_PAUSED_BREAK }; enum DispMode { DISP_CLOCK, DISP_ALARM_SET, DISP_ALARM_SHOW, DISP_ALARM_FIRING, DISP_POM_WORK, DISP_POM_BREAK, DISP_POM_PAUSED, DISP_STATUS_MSG }; // ── Forward declarations ────────────────────────────────────── void setMode(DispMode m); void connectMQTT(); void onShortPress(); void onHoldPress(); // ── Wi-Fi & MQTT ───────────────────────────────────────────── const char* WIFI_SSID = "SSID"; const char* WIFI_PASS = "PASSWORD"; const char* MQTT_BROKER = "broker.hivemq.com"; const int MQTT_PORT = 8884; const char* MQTT_CLIENT = "gsc_alarm_esp32s3_7x3q"; const char* TOPIC_SET = "s_c/alarm/set"; const char* TOPIC_STATUS = "s_c/alarm/status"; const char* TOPIC_POM = "s_c/alarm/pomodoro"; // ── NTP ────────────────────────────────────────────────────── const char* NTP_SERVER = "pool.ntp.org"; const long GMT_OFFSET_SEC = 3600; // UTC+1 const int DST_OFFSET_SEC = 3600; // +1 CEST const unsigned long NTP_INTERVAL = 3600000UL; // ── Pins ───────────────────────────────────────────────────── #define LED_PIN 21 #define MAX_CLK_PIN 7 #define MAX_DIN_PIN 9 #define MAX_CS_PIN 43 #define MAX_DEVICES 4 #define DS_SCLK_PIN 4 #define DS_IO_PIN 5 #define DS_CE_PIN 6 #define BUTTON_PIN 44 // ── Display constants ───────────────────────────────────────── #define COLS 32 // 4 modules x 8 columns #define CHAR_SPACING 1 // blank columns between characters #define BTN_DEBOUNCE 20 #define BTN_HOLD_MS 2000 // ── Objects ────────────────────────────────────────────────── MD_MAX72XX mx = MD_MAX72XX(MD_MAX72XX::FC16_HW, MAX_DIN_PIN, MAX_CLK_PIN, MAX_CS_PIN, MAX_DEVICES); ThreeWire rtcWire(DS_IO_PIN, DS_SCLK_PIN, DS_CE_PIN); RtcDS1302 rtc(rtcWire); WebSocketsClient wsClient; MQTTPubSubClient mqtt; // ───────────────────────────────────────────────────────────── // CUSTOM 5-WIDE FONT // Each entry is 5 columns of 8-bit data (MSB = top row). // Full A-Z uppercase + 0-9 + symbols needed for all display strings. // // Strings used: "WIFI" "NTP" "MQTT" "OK" "ON" "OFF" "RST" // "HH.MM" (digits + dot) "HH MM" (digits + space) // ───────────────────────────────────────────────────────────── #define FONT_W 5 // Index mapping: // 0-9 -> digits 0-9 // 10 -> ' ' space // 11 -> '.' dot // 12 -> 'A' // 13 -> 'B' // 14 -> 'C' // 15 -> 'D' // 16 -> 'E' // 17 -> 'F' // 18 -> 'G' // 19 -> 'H' // 20 -> 'I' // 21 -> 'J' // 22 -> 'K' // 23 -> 'L' // 24 -> 'M' // 25 -> 'N' // 26 -> 'O' // 27 -> 'P' // 28 -> 'Q' // 29 -> 'R' // 30 -> 'S' // 31 -> 'T' // 32 -> 'U' // 33 -> 'V' // 34 -> 'W' // 35 -> 'X' // 36 -> 'Y' // 37 -> 'Z' // 38 -> '-' const uint8_t FONT[][FONT_W] PROGMEM = { // 0 (columns reversed to correct FC16_HW mirror) { 0x3E, 0x45, 0x49, 0x51, 0x3E }, // 1 { 0x00, 0x40, 0x7F, 0x42, 0x00 }, // 2 { 0x46, 0x49, 0x51, 0x61, 0x42 }, // 3 { 0x31, 0x4B, 0x45, 0x41, 0x21 }, // 4 { 0x10, 0x7F, 0x12, 0x14, 0x18 }, // 5 { 0x39, 0x45, 0x45, 0x45, 0x27 }, // 6 { 0x30, 0x49, 0x49, 0x4A, 0x3C }, // 7 { 0x03, 0x05, 0x09, 0x71, 0x01 }, // 8 { 0x36, 0x49, 0x49, 0x49, 0x36 }, // 9 { 0x1E, 0x29, 0x49, 0x49, 0x06 }, // 10 ' ' { 0x00, 0x00, 0x00, 0x00, 0x00 }, // 11 '.' – two-pixel colon (bits 5 and 2 = rows 2 and 5 from top) { 0x00, 0x24, 0x24, 0x00, 0x00 }, // 12 'A' { 0x7E, 0x11, 0x11, 0x11, 0x7E }, // 13 'B' { 0x36, 0x49, 0x49, 0x49, 0x7F }, // 14 'C' { 0x22, 0x41, 0x41, 0x41, 0x3E }, // 15 'D' { 0x3E, 0x41, 0x41, 0x41, 0x7F }, // 16 'E' { 0x41, 0x49, 0x49, 0x49, 0x7F }, // 17 'F' { 0x01, 0x09, 0x09, 0x09, 0x7F }, // 18 'G' { 0x7A, 0x49, 0x49, 0x41, 0x3E }, // 19 'H' { 0x7F, 0x08, 0x08, 0x08, 0x7F }, // 20 'I' { 0x00, 0x41, 0x7F, 0x41, 0x00 }, // 21 'J' { 0x01, 0x3F, 0x41, 0x40, 0x20 }, // 22 'K' { 0x41, 0x22, 0x14, 0x08, 0x7F }, // 23 'L' { 0x40, 0x40, 0x40, 0x40, 0x7F }, // 24 'M' { 0x7F, 0x02, 0x04, 0x02, 0x7F }, // 25 'N' { 0x7F, 0x10, 0x08, 0x04, 0x7F }, // 26 'O' { 0x3E, 0x41, 0x41, 0x41, 0x3E }, // 27 'P' { 0x06, 0x09, 0x09, 0x09, 0x7F }, // 28 'Q' { 0x5E, 0x21, 0x51, 0x41, 0x3E }, // 29 'R' { 0x46, 0x29, 0x19, 0x09, 0x7F }, // 30 'S' { 0x31, 0x49, 0x49, 0x49, 0x46 }, // 31 'T' { 0x01, 0x01, 0x7F, 0x01, 0x01 }, // 32 'U' { 0x3F, 0x40, 0x40, 0x40, 0x3F }, // 33 'V' { 0x1F, 0x20, 0x40, 0x20, 0x1F }, // 34 'W' { 0x7F, 0x20, 0x18, 0x20, 0x7F }, // 35 'X' { 0x63, 0x14, 0x08, 0x14, 0x63 }, // 36 'Y' { 0x03, 0x04, 0x78, 0x04, 0x03 }, // 37 'Z' { 0x43, 0x45, 0x49, 0x51, 0x61 }, // 38 '-' { 0x00, 0x08, 0x08, 0x08, 0x00 }, }; int fontIndex(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'A' && c <= 'Z') return 12 + (c - 'A'); if (c == ' ') return 10; if (c == '.' || c == ':') return 11; if (c == '-') return 38; return 10; // unknown -> space } // ───────────────────────────────────────────────────────────── // DISPLAY PRIMITIVES (all use UPDATE=OFF/ON pattern) // ───────────────────────────────────────────────────────────── uint8_t textWidth(const char* str) { if (!str || !*str) return 0; uint8_t len = (uint8_t)strlen(str); return len * FONT_W + (len - 1) * CHAR_SPACING; } // Draw string centred on the 32-column display at given intensity void drawText(const char* str, uint8_t intensity = 5) { mx.control(MD_MAX72XX::INTENSITY, intensity); mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::OFF); mx.clear(); uint8_t totalW = textWidth(str); int startCol = ((int)COLS - (int)totalW) / 2; if (startCol < 0) startCol = 0; // With FC16_HW the physical column order is reversed, // so we iterate the string backwards and write left-to-right // to make characters appear in the correct sequence. int len = (int)strlen(str); int col = startCol; for (int ci = len - 1; ci >= 0 && col < COLS; ci--) { int fi = fontIndex(str[ci]); for (int i = 0; i < FONT_W && col < COLS; i++, col++) { mx.setColumn(col, pgm_read_byte(&FONT[fi][i])); } if (ci > 0) { for (int i = 0; i < CHAR_SPACING && col < COLS; i++, col++) { mx.setColumn(col, 0x00); } } } mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::ON); } // Draw progress bar: litCols columns from the RIGHT, shrinking leftward. void drawBar(uint8_t litCols, uint8_t intensity = 5) { mx.control(MD_MAX72XX::INTENSITY, intensity); mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::OFF); mx.clear(); uint8_t unlitCols = COLS - litCols; for (uint8_t col = 0; col < COLS; col++) { mx.setColumn(col, col < unlitCols ? 0x00 : 0xFF); } mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::ON); } // Pause symbol: two 3-column × 8-row bars centred in the 32-column display, // separated by 4 empty columns. // Layout (logical cols): 11 dark | cols 11-13 lit | 4 dark | cols 18-20 lit | 11 dark // The FC16_HW column reversal is symmetric, so the symbol centres correctly. void drawPauseSymbol(uint8_t intensity) { mx.control(MD_MAX72XX::INTENSITY, intensity); mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::OFF); mx.clear(); for (uint8_t col = 11; col <= 13; col++) mx.setColumn(col, 0xFF); for (uint8_t col = 18; col <= 20; col++) mx.setColumn(col, 0xFF); mx.control(MD_MAX72XX::UPDATE, MD_MAX72XX::ON); } // ───────────────────────────────────────────────────────────── // PULSE ENGINE (phase derived from absolute elapsed time — // no accumulation, immune to loop-delay jitter) // ───────────────────────────────────────────────────────────── uint8_t pulseInten(unsigned long elapsedMs, float speedHz, uint8_t maxVal = 14) { float phase = (elapsedMs / 1000.0f) * 2.0f * 3.14159f * speedHz; float v = (sinf(phase) + 1.0f) / 2.0f; return (uint8_t)(v * (float)maxVal) + 1; } // ───────────────────────────────────────────────────────────── // STATE // ───────────────────────────────────────────────────────────── bool alarmOn = false; uint8_t alarmHour = 7; uint8_t alarmMinute = 0; bool alarmFiring = false; PomState pomState = POM_IDLE; uint32_t pomTotalSecs = 0; uint32_t pomRemSecs = 0; unsigned long pomLastTick = 0; DispMode dispMode = DISP_CLOCK; DispMode prevMode = DISP_CLOCK; unsigned long modeStart = 0; char statusMsgBuf[8] = ""; // ───────────────────────────────────────────────────────────── // DISPLAY TIMING GLOBALS // ───────────────────────────────────────────────────────────── unsigned long lastClockRedraw = 0; unsigned long lastPulseRedraw = 0; uint8_t lastClockSec = 255; // ───────────────────────────────────────────────────────────── // TIME HELPERS // ───────────────────────────────────────────────────────────── void getRtcTime(uint8_t &h, uint8_t &m, uint8_t &s) { RtcDateTime now = rtc.GetDateTime(); h = now.Hour(); m = now.Minute(); s = now.Second(); } void syncNtp() { struct tm ti; if (!getLocalTime(&ti, 5000)) { Serial.println("NTP sync failed"); return; } RtcDateTime dt(ti.tm_year - 100, ti.tm_mon + 1, ti.tm_mday, ti.tm_hour, ti.tm_min, ti.tm_sec); rtc.SetDateTime(dt); Serial.printf("NTP synced: %02d:%02d:%02d\n", ti.tm_hour, ti.tm_min, ti.tm_sec); } // ───────────────────────────────────────────────────────────── // MODE TRANSITIONS // ───────────────────────────────────────────────────────────── void setMode(DispMode m) { if (dispMode != DISP_STATUS_MSG) prevMode = dispMode; dispMode = m; modeStart = millis(); lastPulseRedraw = 0; } // ───────────────────────────────────────────────────────────── // MQTT HELPERS // ───────────────────────────────────────────────────────────── void mqttPublish(const char* topic, const char* payload) { if (mqtt.isConnected()) { mqtt.publish(topic, payload); Serial.printf("PUB [%s]: %s\n", topic, payload); } } // ───────────────────────────────────────────────────────────── // MQTT CALLBACKS // ───────────────────────────────────────────────────────────── void onMqttMessage(const String& topic, const String& payload) { Serial.printf("SUB [%s]: %s\n", topic.c_str(), payload.c_str()); if (topic == TOPIC_SET) { int h = payload.substring(0, 2).toInt(); int m = payload.substring(3, 5).toInt(); if (h >= 0 && h < 24 && m >= 0 && m < 60) { alarmHour = (uint8_t)h; alarmMinute = (uint8_t)m; setMode(DISP_ALARM_SET); } } else if (topic == TOPIC_STATUS) { alarmOn = (payload == "ON"); // Flash ON/OFF briefly using DISP_ALARM_SHOW reuse pattern — // store the message and show it for 800ms non-blocking snprintf(statusMsgBuf, sizeof(statusMsgBuf), alarmOn ? "ON" : "OFF"); setMode(DISP_STATUS_MSG); } else if (topic == TOPIC_POM) { if (payload.startsWith("WORK_START:")) { int mins = payload.substring(11, payload.indexOf("min")).toInt(); pomTotalSecs = (uint32_t)mins * 60; pomRemSecs = pomTotalSecs; pomState = POM_WORK; pomLastTick = millis(); setMode(DISP_POM_WORK); } else if (payload.startsWith("BREAK_START:")) { int mins = payload.substring(12, payload.indexOf("min")).toInt(); pomTotalSecs = (uint32_t)mins * 60; pomRemSecs = pomTotalSecs; pomState = POM_BREAK; pomLastTick = millis(); setMode(DISP_POM_BREAK); } else if (payload == "PAUSED") { if (pomState == POM_WORK) { pomState = POM_PAUSED_WORK; setMode(DISP_POM_PAUSED); } if (pomState == POM_BREAK) { pomState = POM_PAUSED_BREAK; setMode(DISP_POM_PAUSED); } } else if (payload == "RESUMED") { if (pomState == POM_PAUSED_WORK) { pomState = POM_WORK; pomLastTick = millis(); setMode(DISP_POM_WORK); } if (pomState == POM_PAUSED_BREAK) { pomState = POM_BREAK; pomLastTick = millis(); setMode(DISP_POM_BREAK); } } else if (payload == "RESET") { pomState = POM_IDLE; pomRemSecs = 0; pomTotalSecs = 0; snprintf(statusMsgBuf, sizeof(statusMsgBuf), "RST"); prevMode = DISP_CLOCK; dispMode = DISP_STATUS_MSG; modeStart = millis(); lastPulseRedraw = 0; } } } void connectMQTT() { Serial.print("Connecting to MQTT"); while (!mqtt.connect(MQTT_CLIENT)) { Serial.print("."); delay(1000); } Serial.println(" connected!"); mqtt.subscribe(TOPIC_SET, [](const String& p, const size_t){ onMqttMessage(TOPIC_SET, p); }); mqtt.subscribe(TOPIC_STATUS, [](const String& p, const size_t){ onMqttMessage(TOPIC_STATUS, p); }); mqtt.subscribe(TOPIC_POM, [](const String& p, const size_t){ onMqttMessage(TOPIC_POM, p); }); } // ───────────────────────────────────────────────────────────── // BUTTON // ───────────────────────────────────────────────────────────── bool btnLastRaw = HIGH; bool btnDown = false; unsigned long btnDownAt = 0; bool btnHoldFired = false; void onShortPress() { Serial.println("BTN short"); if (alarmFiring) { alarmFiring = false; alarmOn = false; mqttPublish(TOPIC_STATUS, "OFF"); // Return to pomodoro if it was running, otherwise clock if (pomState == POM_WORK || pomState == POM_PAUSED_WORK) setMode(DISP_POM_WORK); else if (pomState == POM_BREAK || pomState == POM_PAUSED_BREAK) setMode(DISP_POM_BREAK); else if (pomState == POM_PAUSED_WORK || pomState == POM_PAUSED_BREAK) setMode(DISP_POM_PAUSED); else setMode(DISP_CLOCK); return; } if (pomState == POM_WORK || pomState == POM_BREAK) { pomState = (pomState == POM_WORK) ? POM_PAUSED_WORK : POM_PAUSED_BREAK; setMode(DISP_POM_PAUSED); mqttPublish(TOPIC_POM, "PAUSED"); return; } if (pomState == POM_PAUSED_WORK) { pomState = POM_WORK; pomLastTick = millis(); setMode(DISP_POM_WORK); mqttPublish(TOPIC_POM, "RESUMED"); return; } if (pomState == POM_PAUSED_BREAK) { pomState = POM_BREAK; pomLastTick = millis(); setMode(DISP_POM_BREAK); mqttPublish(TOPIC_POM, "RESUMED"); return; } // Idle: show alarm time setMode(DISP_ALARM_SHOW); } void onHoldPress() { Serial.println("BTN hold"); if (pomState != POM_IDLE) { pomState = POM_IDLE; pomRemSecs = 0; pomTotalSecs = 0; mqttPublish(TOPIC_POM, "RESET"); snprintf(statusMsgBuf, sizeof(statusMsgBuf), "RST"); prevMode = DISP_CLOCK; dispMode = DISP_STATUS_MSG; modeStart = millis(); lastPulseRedraw = 0; } else { alarmOn = !alarmOn; mqttPublish(TOPIC_STATUS, alarmOn ? "ON" : "OFF"); snprintf(statusMsgBuf, sizeof(statusMsgBuf), alarmOn ? "ON" : "OFF"); setMode(DISP_STATUS_MSG); } } void handleButton() { bool raw = digitalRead(BUTTON_PIN); if (raw == btnLastRaw) return; delay(BTN_DEBOUNCE); raw = digitalRead(BUTTON_PIN); if (raw == btnLastRaw) return; btnLastRaw = raw; if (raw == LOW) { btnDown = true; btnDownAt = millis(); btnHoldFired = false; } else { if (btnDown && !btnHoldFired) onShortPress(); btnDown = false; } } void checkHold() { if (btnDown && !btnHoldFired && millis() - btnDownAt >= BTN_HOLD_MS) { btnHoldFired = true; onHoldPress(); } } // ───────────────────────────────────────────────────────────── // ALARM CHECK // ───────────────────────────────────────────────────────────── void checkAlarm() { if (!alarmOn || alarmFiring) return; uint8_t h, m, s; getRtcTime(h, m, s); if (h == alarmHour && m == alarmMinute && s == 0) { alarmFiring = true; setMode(DISP_ALARM_FIRING); } } // ───────────────────────────────────────────────────────────── // POMODORO TICK // ───────────────────────────────────────────────────────────── void tickPomodoro() { if (pomState != POM_WORK && pomState != POM_BREAK) return; if (millis() - pomLastTick >= 1000) { pomLastTick = millis(); if (pomRemSecs > 0) pomRemSecs--; } } // ───────────────────────────────────────────────────────────── // DISPLAY UPDATE // ───────────────────────────────────────────────────────────── void updateDisplay() { unsigned long now = millis(); switch (dispMode) { case DISP_CLOCK: { uint8_t h, m, s; getRtcTime(h, m, s); if (s != lastClockSec) { lastClockSec = s; char buf[6]; snprintf(buf, sizeof(buf), "%02u%c%02u", h, (s % 2 == 0) ? '.' : ' ', m); drawText(buf); } break; } case DISP_ALARM_SET: { if (now - lastPulseRedraw >= 30) { lastPulseRedraw = now; char buf[6]; snprintf(buf, sizeof(buf), "%02u.%02u", alarmHour, alarmMinute); drawText(buf, pulseInten(now - modeStart, 1.2f)); } if (now - modeStart >= 3000) { // Return to pomodoro if active, otherwise clock if (pomState == POM_WORK) setMode(DISP_POM_WORK); else if (pomState == POM_BREAK) setMode(DISP_POM_BREAK); else if (pomState == POM_PAUSED_WORK || pomState == POM_PAUSED_BREAK) setMode(DISP_POM_PAUSED); else setMode(DISP_CLOCK); } break; } case DISP_ALARM_SHOW: { if (now - modeStart < 50) { char buf[6]; snprintf(buf, sizeof(buf), "%02u.%02u", alarmHour, alarmMinute); drawText(buf); } if (now - modeStart >= 3000) { if (pomState == POM_WORK) setMode(DISP_POM_WORK); else if (pomState == POM_BREAK) setMode(DISP_POM_BREAK); else if (pomState == POM_PAUSED_WORK || pomState == POM_PAUSED_BREAK) setMode(DISP_POM_PAUSED); else setMode(DISP_CLOCK); } break; } case DISP_ALARM_FIRING: { if (now - lastPulseRedraw >= 30) { lastPulseRedraw = now; uint8_t h, m, s; getRtcTime(h, m, s); char buf[6]; snprintf(buf, sizeof(buf), "%02u.%02u", h, m); drawText(buf, pulseInten(now - modeStart, 1.0f)); } break; } case DISP_POM_WORK: { if (now - lastClockRedraw >= 250) { lastClockRedraw = now; uint8_t lit = (pomTotalSecs > 0) ? (uint8_t)((float)pomRemSecs / pomTotalSecs * COLS + 0.5f) : 0; drawBar(lit); } break; } case DISP_STATUS_MSG: { if (now - modeStart < 50) drawText(statusMsgBuf); if (now - modeStart >= 1500) setMode(prevMode); break; } case DISP_POM_BREAK: { // Full display slow breathing — 3 s cycle, full intensity range [1–15] // ~68 ms per intensity step → smooth if (now - lastPulseRedraw >= 30) { lastPulseRedraw = now; drawBar(COLS, pulseInten(now - modeStart, 0.33f)); } break; } case DISP_POM_PAUSED: { // Pause symbol (❚❚) pulsing — 1.67 s cycle, full intensity range [1–15] // gives ~38 ms per step → smooth. if (now - lastPulseRedraw >= 30) { lastPulseRedraw = now; drawPauseSymbol(pulseInten(now - modeStart, 0.6f)); } break; } } } // ───────────────────────────────────────────────────────────── // SETUP // ───────────────────────────────────────────────────────────── unsigned long lastNtpSync = 0; void setup() { Serial.begin(115200); delay(1000); Serial.println("\n=== Alarm Clock v3 ==="); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); pinMode(BUTTON_PIN, INPUT); mx.begin(); mx.control(MD_MAX72XX::INTENSITY, 5); mx.clear(); rtc.Begin(); uint8_t h, m, s; getRtcTime(h, m, s); Serial.printf("RTC: %02u:%02u:%02u\n", h, m, s); drawText("WIFI"); Serial.print("Wi-Fi"); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(" OK IP: " + WiFi.localIP().toString()); drawText("NTP"); configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, NTP_SERVER); syncNtp(); lastNtpSync = millis(); drawText("MQTT"); wsClient.beginSSL(MQTT_BROKER, MQTT_PORT, "/mqtt"); wsClient.setReconnectInterval(2000); mqtt.begin(wsClient); connectMQTT(); drawText("OK"); delay(600); setMode(DISP_CLOCK); } // ───────────────────────────────────────────────────────────── // LOOP // ───────────────────────────────────────────────────────────── void loop() { wsClient.loop(); mqtt.update(); if (!mqtt.isConnected()) { Serial.print("MQTT reconnect"); connectMQTT(); } if (millis() - lastNtpSync >= NTP_INTERVAL) { syncNtp(); lastNtpSync = millis(); } handleButton(); checkHold(); checkAlarm(); tickPomodoro(); updateDisplay(); delay(5); }