// // Klubhaus Doorbell - Uno R4 WiFi Edition // Monitors ntfy.sh alert topic and displays messages on LED matrix // #include "Arduino_LED_Matrix.h" #include "WiFiS3.h" #include "ArduinoJson.h" #include "RTC.h" // Add RTC library for time synchronization // WiFi credentials #define WIFI_SSID "iot-2GHz" #define WIFI_PASS "lesson-greater" // ntfy.sh topic #define NTFY_TOPIC "ALERT_klubhaus_topic_test" // Timing constants #define POLL_INTERVAL_MS 5000 // Poll ntfy.sh every 5 seconds #define DISPLAY_DURATION_MS 60000 // Show message for 60 seconds #define SCROLL_SPEED_MS 100 // Scroll speed (lower = faster) - 4 columns/sec #define SILENCE_DURATION_MS 5000 // Stay silenced for 5 seconds #define GAUGE_MAX_AGE_MS 180000 // 3-minute window for gauge #define GAUGE_BUCKET_SEC 15 // 15 seconds per column #define MAX_GAUGE_ENTRIES 12 // Max message timestamps long gaugeTimestamps[MAX_GAUGE_ENTRIES] = {0}; int gaugeCount = 0; // LED matrix dimensions #define MATRIX_COLS 12 #define MATRIX_ROWS 8 // Global objects ArduinoLEDMatrix matrix; WiFiClient client; // State machine enum AppState { STATE_IDLE, STATE_DISPLAYING_MESSAGE }; AppState currentState = STATE_IDLE; // Message state String currentMessage = ""; String lastDisplayedMessage = ""; // Track last displayed message to avoid duplicates String lastMessageId = ""; // Track last message ID for filtering unsigned long messageStartTime = 0; unsigned long lastScrollTime = 0; unsigned long lastPollTime = 0; unsigned long bootTime = 0; unsigned long bootDelayEndTime = 0; // When to start processing messages unsigned long lastDisplayedMessageTime = 0; // Unix timestamp of last displayed message long currentMessageTime = 0; // Unix timestamp of current message being displayed // Non-blocking poll state machine enum PollState { POLL_IDLE, POLL_WAIT, POLL_CONNECT, POLL_SEND, POLL_READ, POLL_PARSE }; PollState pollState = POLL_IDLE; unsigned long pollStepTime = 0; String pollResponse = ""; long readyTime = 0; // Unix timestamp when device became ready (after NTP sync) int queuedMessageCount = 0; bool rtcSynced = false; // Track if RTC has been synced with NTP // Scrolling state int scrollOffset = 0; int messageLength = 0; // Frame buffer - 8 rows x 12 columns uint8_t frameBuffer[MATRIX_ROWS][MATRIX_COLS] = {0}; int getBracketPeriod(long ageSec) { if (ageSec < 15) return 150; if (ageSec < 30) return 200; if (ageSec < 45) return 250; if (ageSec < 60) return 350; if (ageSec < 75) return 500; if (ageSec < 90) return 700; if (ageSec < 105) return 1000; if (ageSec < 120) return 1400; if (ageSec < 135) return 1800; if (ageSec < 150) return 2200; if (ageSec < 165) return 2600; return 3000; } int getBracketDuty(long ageSec) { if (ageSec < 15) return 55; if (ageSec < 30) return 48; if (ageSec < 45) return 40; if (ageSec < 60) return 35; if (ageSec < 75) return 30; if (ageSec < 90) return 25; if (ageSec < 105) return 20; if (ageSec < 120) return 16; if (ageSec < 135) return 12; if (ageSec < 150) return 10; if (ageSec < 165) return 8; return 6; } void gaugeAddMessage(long unixTime) { if (gaugeCount < MAX_GAUGE_ENTRIES) { gaugeTimestamps[gaugeCount++] = unixTime; } else { for (int i = 0; i < MAX_GAUGE_ENTRIES - 1; i++) { gaugeTimestamps[i] = gaugeTimestamps[i + 1]; } gaugeTimestamps[MAX_GAUGE_ENTRIES - 1] = unixTime; } } void gaugePruneOld(long nowUnix) { int w = 0; for (int i = 0; i < gaugeCount; i++) { if (nowUnix - gaugeTimestamps[i] <= GAUGE_MAX_AGE_MS / 1000) { gaugeTimestamps[w++] = gaugeTimestamps[i]; } } gaugeCount = w; } void renderGauge(long nowUnix) { unsigned long ms = millis(); for (int col = 0; col < MATRIX_COLS; col++) { int bucketMin = col * GAUGE_BUCKET_SEC; int bucketMax = bucketMin + GAUGE_BUCKET_SEC; int messagesInBucket = 0; long youngestAge = GAUGE_MAX_AGE_MS / 1000 + 1; for (int i = 0; i < gaugeCount; i++) { long age = nowUnix - gaugeTimestamps[i]; if (age >= bucketMin && age < bucketMax) { messagesInBucket++; if (age < youngestAge) youngestAge = age; } } if (messagesInBucket == 0) { frameBuffer[MATRIX_ROWS - 1][col] = 0; continue; } int periodMs = getBracketPeriod(youngestAge); int dutyPct = getBracketDuty(youngestAge); int phase = ms % periodMs; int onThreshold = periodMs * dutyPct / 100; frameBuffer[MATRIX_ROWS - 1][col] = (phase < onThreshold) ? 1 : 0; } } void setup() { Serial.begin(115200); delay(1000); Serial.println("\n=== Klubhaus Doorbell - Uno R4 WiFi ==="); Serial.print("Build token: "); Serial.println(BUILD_TOKEN); Serial.println("Starting up..."); // Initialize RTC RTC.begin(); Serial.println("RTC initialized"); bootTime = millis(); bootDelayEndTime = bootTime + 10000; // Wait 10 seconds after boot before processing Serial.print("Boot time: "); Serial.print(bootTime); Serial.print(", will start processing at: "); Serial.println(bootDelayEndTime); Serial.println("Initializing LED matrix..."); matrix.begin(); delay(100); matrix.clear(); Serial.println("LED matrix initialized"); Serial.println("Displaying OK..."); displayStaticText("OK"); delay(500); // Display OK for 500ms matrix.clear(); Serial.println("Starting WiFi connection..."); connectToWiFi(); // Sync RTC with NTP after WiFi connection syncRTCWithNTP(); } void loop() { unsigned long now = millis(); // Heartbeat LED static unsigned long lastHeartbeat = 0; static const unsigned long FADE_PERIOD_MS = 10000; static const int MIN_BRIGHTNESS = 51; static const int MAX_BRIGHTNESS = 204; if (now - lastHeartbeat >= 50) { float phase = (float)(now % FADE_PERIOD_MS) / (float)FADE_PERIOD_MS * 2.0 * PI; analogWrite(LED_BUILTIN, MIN_BRIGHTNESS + (int)((MAX_BRIGHTNESS - MIN_BRIGHTNESS) / 2.0 * (1.0 + sin(phase)))); lastHeartbeat = now; } // One RTC read per loop RTCTime ct; RTC.getTime(ct); long nowUnix = ct.getUnixTime(); // Non-blocking poll — one step per loop if (rtcSynced && now >= bootDelayEndTime) { pollNtfy(nowUnix); } // Clear frame, then always render gauge on row 7 memset(frameBuffer, 0, sizeof(frameBuffer)); gaugePruneOld(nowUnix); renderGauge(nowUnix); switch (currentState) { case STATE_IDLE: if (currentMessage.length() > 0) { Serial.print("[DISPLAY] "); Serial.println(currentMessage); currentState = STATE_DISPLAYING_MESSAGE; messageStartTime = now; lastScrollTime = now; scrollOffset = -MATRIX_COLS; messageLength = currentMessage.length(); } break; case STATE_DISPLAYING_MESSAGE: // Always draw text at current scroll position (not just on tick) drawScrollingText(); // Advance scroll on timer tick if (now - lastScrollTime >= SCROLL_SPEED_MS) { scrollOffset += 1; if (scrollOffset > messageLength * 6 + MATRIX_COLS) { scrollOffset = -MATRIX_COLS; } lastScrollTime = now; } if (now - messageStartTime >= DISPLAY_DURATION_MS) { lastDisplayedMessage = currentMessage; lastDisplayedMessageTime = currentMessageTime; currentState = STATE_IDLE; currentMessage = ""; scrollOffset = 0; } break; } // Non-blocking poll — start cycle on timer, advance one step per loop static unsigned long lastPollStart = 0; if (pollState == POLL_IDLE && now - lastPollStart >= POLL_INTERVAL_MS) { if (rtcSynced && now >= bootDelayEndTime && WiFi.status() == WL_CONNECTED) { pollState = POLL_CONNECT; lastPollStart = now; } } if (pollState != POLL_IDLE) { pollNtfy(nowUnix); } matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS); } void connectToWiFi() { Serial.print("Connecting to WiFi: "); Serial.println(WIFI_SSID); if (WiFi.status() == WL_NO_MODULE) { Serial.println("ERROR: Communication with WiFi module failed!"); displayStaticText("ERR"); while (true); } String fv = WiFi.firmwareVersion(); Serial.print("WiFi firmware version: "); Serial.println(fv); Serial.print("Attempting WiFi connection..."); WiFi.begin(WIFI_SSID, WIFI_PASS); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 20) { Serial.print("."); delay(1000); attempts++; } if (WiFi.status() != WL_CONNECTED) { Serial.println("\nERROR: Failed to connect to WiFi"); displayStaticText("WIFI ERR"); while (true); } Serial.println("\nWiFi connected!"); Serial.print("IP address: "); Serial.println(WiFi.localIP()); } void syncRTCWithNTP() { Serial.println("Syncing RTC with NTP..."); unsigned long epochTime = WiFi.getTime(); if (epochTime > 0) { // Set timezone to UTC (no offset) RTCTime timeToSet = RTCTime(epochTime); RTC.setTime(timeToSet); // Get current time to verify RTCTime currentTime; RTC.getTime(currentTime); Serial.print("RTC synced to: "); Serial.println(String(currentTime)); rtcSynced = true; readyTime = epochTime; // Reject messages older than this boot Serial.print("Ready time: "); Serial.println((unsigned long)readyTime); } else { Serial.println("ERROR: Failed to get NTP time"); Serial.println("Make sure WiFi firmware version is at least 0.5.0"); } } void pollNtfy(long nowUnix) { unsigned long now = millis(); switch (pollState) { case POLL_CONNECT: if (client.connect("ntfy.sh", 80)) { pollState = POLL_SEND; } else { pollState = POLL_IDLE; } break; case POLL_SEND: { String url; if (lastMessageId.length() > 0) { url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=" + lastMessageId; } else { url = "/" + String(NTFY_TOPIC) + "/json?poll=1&since=latest"; } client.print(String("GET ") + url + " HTTP/1.1\r\n" + "Host: ntfy.sh\r\n" + "Connection: close\r\n\r\n"); pollStepTime = now; pollResponse = ""; pollState = POLL_READ; break; } case POLL_READ: { // Non-blocking read — only consume what's available, max 20ms per call unsigned long t = millis(); while (client.available() > 0 && (millis() - t) < 20) { pollResponse += (char)client.read(); } // Done when disconnected and we have content, or timeout if (!client.connected() || (pollResponse.length() > 0 && now - pollStepTime > 3000)) { client.stop(); pollState = POLL_PARSE; } else if (now - pollStepTime > 8000) { client.stop(); pollState = POLL_IDLE; } break; } case POLL_PARSE: { int js = pollResponse.indexOf("{"); if (js >= 0) { StaticJsonDocument<1024> doc; if (!deserializeJson(doc, pollResponse.substring(js))) { processMessage(doc, nowUnix); } } pollState = POLL_IDLE; break; } default: break; } } void processMessage(JsonDocument& doc, long nowUnix) { const char* message = doc["message"]; const char* id = doc["id"]; long msgTime = doc["time"]; if (!message || strlen(message) == 0 || msgTime <= 0) return; String msgStr = String(message); if (msgStr == lastDisplayedMessage) { if (id) lastMessageId = String(id); return; } if (lastDisplayedMessageTime > 0 && msgTime <= lastDisplayedMessageTime) { if (id) lastMessageId = String(id); return; } if (readyTime > 0 && msgTime < readyTime) { if (id) lastMessageId = String(id); return; } if (nowUnix - msgTime > 3600) { if (id) lastMessageId = String(id); return; } Serial.print("[ACCEPTED] "); Serial.println(msgStr); gaugePruneOld(nowUnix); gaugeAddMessage(msgTime); if (currentState == STATE_IDLE) { currentMessage = msgStr; currentMessageTime = msgTime; } if (id) lastMessageId = String(id); } void drawScrollingText() { int charWidth = 5; int charSpacing = 1; for (int i = 0; i < messageLength; i++) { char c = currentMessage[i]; int charX = i * (charWidth + charSpacing) - scrollOffset; if (charX > -charWidth && charX < MATRIX_COLS) { drawCharToFrame(c, charX); } } } void drawCharToFrame(char c, int x) { static const uint8_t font[][5] = { // Uppercase A-Z (indices 0-25) {0x7F, 0x41, 0x41, 0x41, 0x7F}, // 'A' {0x7F, 0x49, 0x49, 0x49, 0x36}, // 'B' {0x3E, 0x41, 0x41, 0x41, 0x22}, // 'C' {0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'D' {0x7F, 0x49, 0x49, 0x49, 0x41}, // 'E' {0x7F, 0x09, 0x09, 0x09, 0x01}, // 'F' {0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'G' {0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'H' {0x00, 0x41, 0x7F, 0x41, 0x00}, // 'I' {0x20, 0x40, 0x41, 0x3F, 0x00}, // 'J' {0x7F, 0x08, 0x14, 0x22, 0x41}, // 'K' {0x7F, 0x40, 0x40, 0x40, 0x40}, // 'L' {0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'M' {0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'N' {0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'O' {0x7F, 0x09, 0x09, 0x09, 0x06}, // 'P' {0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'Q' {0x7F, 0x09, 0x19, 0x29, 0x46}, // 'R' {0x46, 0x49, 0x49, 0x49, 0x31}, // 'S' {0x01, 0x01, 0x7F, 0x01, 0x01}, // 'T' {0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'U' {0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'V' {0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'W' {0x63, 0x14, 0x08, 0x14, 0x63}, // 'X' {0x07, 0x08, 0x70, 0x08, 0x07}, // 'Y' {0x61, 0x51, 0x49, 0x45, 0x43}, // 'Z' // Lowercase a-z (indices 26-51) {0x7F, 0x09, 0x09, 0x09, 0x7F}, // 'a' {0x7F, 0x49, 0x49, 0x49, 0x36}, // 'b' {0x3E, 0x41, 0x41, 0x41, 0x22}, // 'c' {0x7F, 0x41, 0x41, 0x22, 0x1C}, // 'd' {0x7F, 0x49, 0x49, 0x49, 0x41}, // 'e' {0x7F, 0x09, 0x09, 0x09, 0x01}, // 'f' {0x3E, 0x41, 0x49, 0x49, 0x7A}, // 'g' {0x7F, 0x08, 0x08, 0x08, 0x7F}, // 'h' {0x00, 0x41, 0x7F, 0x41, 0x00}, // 'i' {0x20, 0x40, 0x41, 0x3F, 0x00}, // 'j' {0x7F, 0x08, 0x14, 0x22, 0x41}, // 'k' {0x7F, 0x40, 0x40, 0x40, 0x40}, // 'l' {0x7F, 0x02, 0x0C, 0x02, 0x7F}, // 'm' {0x7F, 0x04, 0x08, 0x10, 0x7F}, // 'n' {0x3E, 0x41, 0x41, 0x41, 0x3E}, // 'o' {0x7F, 0x09, 0x09, 0x09, 0x06}, // 'p' {0x3E, 0x41, 0x51, 0x21, 0x5E}, // 'q' {0x7F, 0x09, 0x19, 0x29, 0x46}, // 'r' {0x46, 0x49, 0x49, 0x49, 0x31}, // 's' {0x01, 0x01, 0x7F, 0x01, 0x01}, // 't' {0x3F, 0x40, 0x40, 0x40, 0x3F}, // 'u' {0x1F, 0x20, 0x40, 0x20, 0x1F}, // 'v' {0x7F, 0x20, 0x18, 0x20, 0x7F}, // 'w' {0x63, 0x14, 0x08, 0x14, 0x63}, // 'x' {0x07, 0x08, 0x70, 0x08, 0x07}, // 'y' {0x61, 0x51, 0x49, 0x45, 0x43}, // 'z' // Numbers and space (indices 52-62) {0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0' {0x00, 0x42, 0x7F, 0x40, 0x00}, // '1' {0x42, 0x61, 0x51, 0x49, 0x46}, // '2' {0x21, 0x41, 0x45, 0x4B, 0x31}, // '3' {0x18, 0x14, 0x12, 0x7F, 0x10}, // '4' {0x27, 0x45, 0x45, 0x45, 0x39}, // '5' {0x3C, 0x4A, 0x49, 0x49, 0x30}, // '6' {0x01, 0x71, 0x09, 0x05, 0x03}, // '7' {0x36, 0x49, 0x49, 0x49, 0x36}, // '8' {0x06, 0x49, 0x49, 0x29, 0x1E}, // '9' {0x00, 0x00, 0x00, 0x00, 0x00}, // ' ' (space) }; int fontIndex = -1; if (c >= 'A' && c <= 'Z') { fontIndex = c - 'A'; } else if (c >= 'a' && c <= 'z') { fontIndex = 26 + (c - 'a'); } else if (c >= '0' && c <= '9') { fontIndex = 52 + (c - '0'); } else if (c == ' ') { fontIndex = 62; } else { return; } if (fontIndex >= 0) { for (int col = 0; col < 5; col++) { uint8_t colData = font[fontIndex][col]; int drawX = x + col; if (drawX >= 0 && drawX < MATRIX_COLS) { for (int row = 0; row < MATRIX_ROWS; row++) { if (colData & (1 << row)) { frameBuffer[row][drawX] = 1; } } } } } } void displayStaticText(String text) { memset(frameBuffer, 0, sizeof(frameBuffer)); int textWidth = text.length() * 6; int startX = max(0, (MATRIX_COLS - textWidth) / 2); for (int i = 0; i < text.length(); i++) { drawCharToFrame(text[i], startX + i * 6); } matrix.renderBitmap(frameBuffer, MATRIX_ROWS, MATRIX_COLS); }