/* * KLUBHAUS ALERT DEVICE v3.9.1-minimal * * Minimal fix for boot loop: message age filtering + ID deduplication * Preserves original code structure from v3.8.x */ #include #include #include #include #include #include // ============== CONFIGURATION ============== String wifiSSID = "IoT-2GHz"; String wifiPassword = "lesson-greater"; const char* ALERT_TOPIC = "https://ntfy.sh/ALERT_klubhaus_topic/json?since=all"; const char* SILENCE_TOPIC = "https://ntfy.sh/SILENCE_klubhaus_topic/json?since=all"; const char* ADMIN_TOPIC = "https://ntfy.sh/ADMIN_klubhaus_topic/json?since=all"; const char* STATUS_TOPIC = "https://ntfy.sh/STATUS_klubhaus_topic"; const unsigned long POLL_INTERVAL_MS = 5000; const unsigned long BLINK_INTERVAL_MS = 500; const unsigned long BUTTON_DEBOUNCE_MS = 50; const unsigned long BUTTON_LONG_PRESS_MS = 1000; const unsigned long BUTTON_DOUBLE_PRESS_MS = 300; const unsigned long STALE_MESSAGE_THRESHOLD_S = 600; // 10 minutes (in seconds!) const unsigned long BOOT_GRACE_PERIOD_MS = 30000; const unsigned long NTP_SYNC_INTERVAL_MS = 3600000; const int SCREEN_WIDTH = 320; const int SCREEN_HEIGHT = 172; const uint16_t COLOR_NEON_TEAL = 0x07D7; const uint16_t COLOR_HOT_FUCHSIA = 0xF81F; const uint16_t COLOR_COCAINE_WHITE = 0xFFDF; const uint16_t COLOR_MIDNIGHT_BLACK = 0x0000; const uint16_t COLOR_MINT_FLASH = 0x67F5; // ============== GLOBALS ============== TFT_eSPI tft = TFT_eSPI(); WiFiUDP ntpUDP; NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_SENDING }; DeviceState currentState = STATE_SILENT; String currentMessage = ""; bool displayOn = true; bool ledModeOnly = false; const int BUTTON_PIN = 0; unsigned long buttonLastPress = 0; unsigned long buttonPressStart = 0; int buttonPressCount = 0; bool buttonPressed = false; unsigned long lastBlinkToggle = 0; bool blinkState = false; uint16_t currentAlertColor = COLOR_NEON_TEAL; unsigned long bootTime = 0; bool inBootGracePeriod = true; bool ntpSynced = false; HTTPClient http; unsigned long lastPoll = 0; // ============== NEW: DEDUPLICATION ============== String lastProcessedId = ""; time_t lastKnownEpoch = 0; // ============== SETUP ============== void setup() { Serial.begin(115200); while (!Serial && millis() < 3000) { ; } bootTime = millis(); Serial.println("[BOOT] KLUBHAUS ALERT v3.9.1-minimal"); Serial.println("[BOOT] Fix: message age filter + ID dedup"); tft.init(); tft.setRotation(1); tft.fillScreen(COLOR_MIDNIGHT_BLACK); tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK); tft.setTextDatum(MC_DATUM); tft.setTextSize(2); tft.drawString("KLUBHAUS", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 20); tft.setTextSize(1); tft.drawString("v3.9.1-minimal", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 10); tft.drawString("Booting...", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 25); pinMode(BUTTON_PIN, INPUT_PULLUP); WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str()); int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 30) { delay(500); attempts++; } timeClient.begin(); if (timeClient.update()) { ntpSynced = true; lastKnownEpoch = timeClient.getEpochTime(); Serial.println("[BOOT] NTP synced: " + String(lastKnownEpoch)); } Serial.println("[BOOT] Setup complete, grace period: 30s"); } // ============== LOOP ============== void loop() { unsigned long now = millis(); if (Serial.available()) { String cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd == "CLEAR_DEDUP") { lastProcessedId = ""; Serial.println("[CMD] Deduplication cleared"); } } if (timeClient.update()) { ntpSynced = true; lastKnownEpoch = timeClient.getEpochTime(); } if (inBootGracePeriod && (now - bootTime >= BOOT_GRACE_PERIOD_MS)) { inBootGracePeriod = false; Serial.println("[BOOT] Grace period ended"); } if (now - lastPoll >= POLL_INTERVAL_MS) { lastPoll = now; if (WiFi.status() == WL_CONNECTED && ntpSynced) { pollTopic(ALERT_TOPIC, handleAlertMessage, "ALERT"); pollTopic(SILENCE_TOPIC, handleSilenceMessage, "SILENCE"); pollTopic(ADMIN_TOPIC, handleAdminMessage, "ADMIN"); } } handleButton(); updateDisplay(); delay(10); } // ============== POLLING ============== void pollTopic(const char* url, void (*handler)(const String&), const char* topicName) { http.begin(url); http.setTimeout(10000); int httpCode = http.GET(); if (httpCode == HTTP_CODE_OK) { String response = http.getString(); if (response.length() > 0) { parseMessages(response, topicName, handler); } } http.end(); } // ============== PARSING WITH AGE FILTER & DEDUP ============== void parseMessages(String& response, const char* topicName, void (*handler)(const String&)) { // Grace period: ignore everything if (inBootGracePeriod) { return; } // No time sync: can't validate, skip to be safe if (!ntpSynced || lastKnownEpoch == 0) { return; } int lineStart = 0; while (lineStart < response.length()) { int lineEnd = response.indexOf('\n', lineStart); if (lineEnd == -1) lineEnd = response.length(); String line = response.substring(lineStart, lineEnd); line.trim(); if (line.length() > 0 && line.indexOf('{') >= 0) { StaticJsonDocument<1024> doc; DeserializationError err = deserializeJson(doc, line); if (!err) { const char* msgId = doc["id"]; const char* message = doc["message"]; time_t msgTime = doc["time"] | 0; // ntfy sends epoch SECONDS if (message && strlen(message) > 0) { String msgStr = String(message); String idStr = msgId ? String(msgId) : ""; // DEDUP: skip if same ID already processed if (idStr.length() > 0 && idStr == lastProcessedId) { Serial.println("[DEDUP] Skipping duplicate: " + idStr.substring(0, 8)); lineStart = lineEnd + 1; continue; } // AGE CHECK: compare message time to current wall clock if (msgTime > 0) { time_t ageSeconds = lastKnownEpoch - msgTime; if (ageSeconds > (time_t)STALE_MESSAGE_THRESHOLD_S) { Serial.println("[STALE] Rejecting " + String(ageSeconds) + "s old message: " + msgStr.substring(0, 20)); lineStart = lineEnd + 1; continue; } } // VALID: process and record ID Serial.println("[" + String(topicName) + "] Processing: " + msgStr.substring(0, 40)); if (idStr.length() > 0) { lastProcessedId = idStr; } handler(msgStr); } } } lineStart = lineEnd + 1; } } // ============== HANDLERS ============== void handleAlertMessage(const String& message) { if (currentState == STATE_ALERTING && currentMessage == message) return; currentState = STATE_ALERTING; currentMessage = message; displayOn = true; publishStatus("ALERTING", message); if (!ledModeOnly) { drawAlertScreen(); } } void handleSilenceMessage(const String& message) { currentState = STATE_SILENT; currentMessage = ""; displayOn = true; publishStatus("SILENT", "silenced"); if (!ledModeOnly) { drawSilentScreen(); } } void handleAdminMessage(const String& message) { Serial.println("[ADMIN] Command: " + message); if (message == "MODE_SCREEN") { ledModeOnly = false; if (currentState == STATE_SILENT) drawSilentScreen(); else drawAlertScreen(); } else if (message == "MODE_LED") { ledModeOnly = true; tft.fillScreen(COLOR_MIDNIGHT_BLACK); } else if (message == "SILENCE") { handleSilenceMessage("admin"); } else if (message == "PING") { publishStatus("PONG", "ping"); } else if (message == "REBOOT") { Serial.println("[ADMIN] Rebooting..."); delay(100); ESP.restart(); } } // ============== STATUS ============== void publishStatus(const char* state, const String& message) { if (WiFi.status() != WL_CONNECTED) return; StaticJsonDocument<256> doc; doc["state"] = state; doc["message"] = message; doc["timestamp"] = (long long)timeClient.getEpochTime() * 1000LL; doc["led_mode"] = ledModeOnly; String payload; serializeJson(doc, payload); HTTPClient statusHttp; statusHttp.begin(STATUS_TOPIC); statusHttp.addHeader("Content-Type", "application/json"); statusHttp.POST(payload); statusHttp.end(); } // ============== DISPLAY ============== void updateDisplay() { if (ledModeOnly) { if (currentState == STATE_ALERTING) { unsigned long now = millis(); if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { lastBlinkToggle = now; blinkState = !blinkState; digitalWrite(2, blinkState ? HIGH : LOW); } } return; } if (currentState == STATE_ALERTING) { unsigned long now = millis(); if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { lastBlinkToggle = now; blinkState = !blinkState; currentAlertColor = blinkState ? COLOR_NEON_TEAL : COLOR_HOT_FUCHSIA; drawAlertScreen(); digitalWrite(2, blinkState ? HIGH : LOW); } } } void drawAlertScreen() { if (ledModeOnly) return; tft.fillScreen(COLOR_MIDNIGHT_BLACK); tft.setTextColor(currentAlertColor, COLOR_MIDNIGHT_BLACK); int textSize = 4; if (currentMessage.length() > 10) textSize = 3; if (currentMessage.length() > 20) textSize = 2; tft.setTextSize(textSize); tft.setTextDatum(MC_DATUM); if (currentMessage.length() > 15) { int mid = currentMessage.length() / 2; int spacePos = currentMessage.lastIndexOf(' ', mid); if (spacePos < 0) spacePos = mid; tft.drawString(currentMessage.substring(0, spacePos), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 15); tft.drawString(currentMessage.substring(spacePos + 1), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 15); } else { tft.drawString(currentMessage, SCREEN_WIDTH/2, SCREEN_HEIGHT/2); } tft.setTextSize(1); tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK); tft.setTextDatum(TL_DATUM); tft.drawString("ALERT", 5, 5); tft.setTextDatum(TR_DATUM); tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5); } void drawSilentScreen() { if (ledModeOnly) return; tft.fillScreen(COLOR_MIDNIGHT_BLACK); tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK); tft.setTextSize(2); tft.setTextDatum(MC_DATUM); tft.drawString("SILENT", SCREEN_WIDTH/2, SCREEN_HEIGHT/2); tft.setTextSize(1); tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK); tft.setTextDatum(TL_DATUM); tft.drawString("KLUBHAUS", 5, 5); tft.setTextDatum(TR_DATUM); tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5); } // ============== BUTTON ============== void handleButton() { bool reading = (digitalRead(BUTTON_PIN) == LOW); if (reading != buttonPressed) { if (reading) { buttonPressStart = millis(); if (millis() - buttonLastPress < BUTTON_DOUBLE_PRESS_MS) { buttonPressCount++; } else { buttonPressCount = 1; } buttonLastPress = millis(); if (buttonPressCount == 2) { ledModeOnly = !ledModeOnly; buttonPressCount = 0; if (ledModeOnly) { tft.fillScreen(COLOR_MIDNIGHT_BLACK); } else { if (currentState == STATE_SILENT) drawSilentScreen(); else drawAlertScreen(); } return; } } else { unsigned long duration = millis() - buttonPressStart; if (duration >= BUTTON_DEBOUNCE_MS && duration < BUTTON_LONG_PRESS_MS) { if (currentState == STATE_ALERTING) { handleSilenceMessage("button"); } else { handleAlertMessage("TEST ALERT"); } } } buttonPressed = reading; } }