diff --git a/sketches/doorbell/doorbell.ino b/sketches/doorbell/doorbell.ino index c2aa749..eb3552f 100644 --- a/sketches/doorbell/doorbell.ino +++ b/sketches/doorbell/doorbell.ino @@ -1,420 +1,214 @@ /* - * 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 + * KLUBHAUS ALERT v3.9.2-STABLE + * Ultra-defensive: minimal HTTP, no NTP dependency, graceful degradation */ #include #include #include #include -#include -#include -// ============== CONFIGURATION ============== -String wifiSSID = "Dobro Veče"; -String wifiPassword = "goodnight"; +// ============== CONFIG ============== +const char* WIFI_SSID = "Dobro Veče"; +const char* WIFI_PASS = "goodnight"; -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; +const char* ALERT_URL = "https://ntfy.sh/ALERT_klubhaus_topic/json?since=all"; +const char* SILENCE_URL = "https://ntfy.sh/SILENCE_klubhaus_topic/json?since=all"; // ============== GLOBALS ============== -TFT_eSPI tft = TFT_eSPI(); -WiFiUDP ntpUDP; -NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); +TFT_eSPI tft; -enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_SENDING }; -DeviceState currentState = STATE_SILENT; -String currentMessage = ""; -bool displayOn = true; -bool ledModeOnly = false; +enum State { SILENT, ALERTING }; +State state = SILENT; +String alertMessage = ""; -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; +unsigned long lastReconnect = 0; +unsigned long bootMillis = 0; +bool wifiReady = false; // ============== SETUP ============== void setup() { Serial.begin(115200); - while (!Serial && millis() < 3000) { ; } + delay(1000); // Let USB stabilize - bootTime = millis(); - - Serial.println("[BOOT] KLUBHAUS ALERT v3.9.1-minimal"); - Serial.println("[BOOT] Fix: message age filter + ID dedup"); + bootMillis = millis(); + Serial.println("\n=== KLUBHAUS v3.9.2-STABLE ==="); + Serial.println("Boot at millis=" + String(bootMillis)); + // Init display early for feedback tft.init(); tft.setRotation(1); - tft.fillScreen(COLOR_MIDNIGHT_BLACK); - tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK); - tft.setTextDatum(MC_DATUM); + tft.fillScreen(0x0000); + tft.setTextColor(0xFFDF, 0x0000); + tft.setTextDatum(4); // 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); + tft.drawString("BOOTING...", 160, 86); - pinMode(BUTTON_PIN, INPUT_PULLUP); + // Connect WiFi with timeout + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID, WIFI_PASS); - WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str()); + Serial.print("WiFi connecting"); int attempts = 0; - while (WiFi.status() != WL_CONNECTED && attempts < 30) { + while (WiFi.status() != WL_CONNECTED && attempts < 50) { delay(500); + Serial.print("."); attempts++; + if (attempts % 10 == 0) { + Serial.println("\nWiFi status: " + String(WiFi.status())); + } } - timeClient.begin(); - if (timeClient.update()) { - ntpSynced = true; - lastKnownEpoch = timeClient.getEpochTime(); - Serial.println("[BOOT] NTP synced: " + String(lastKnownEpoch)); + if (WiFi.status() == WL_CONNECTED) { + wifiReady = true; + Serial.println("\nWiFi OK: " + WiFi.localIP().toString()); + tft.drawString("WIFI OK", 160, 110); + } else { + Serial.println("\nWiFi FAILED, continuing offline"); + tft.drawString("WIFI FAIL", 160, 110); } - Serial.println("[BOOT] Setup complete, grace period: 30s"); + delay(1000); + drawSilent(); + Serial.println("Setup complete, entering loop"); } // ============== 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"); + // WiFi reconnect logic (throttled) + if (!wifiReady && now - lastReconnect > 30000) { + lastReconnect = now; + Serial.println("WiFi reconnect attempt..."); + WiFi.reconnect(); + if (WiFi.status() == WL_CONNECTED) { + wifiReady = true; + Serial.println("WiFi reconnected"); } } - 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) { + // Poll ntfy (throttled, guarded) + if (wifiReady && now - lastPoll > 10000) { // 10 second min interval lastPoll = now; - if (WiFi.status() == WL_CONNECTED && ntpSynced) { - pollTopic(ALERT_TOPIC, handleAlertMessage, "ALERT"); - pollTopic(SILENCE_TOPIC, handleSilenceMessage, "SILENCE"); - pollTopic(ADMIN_TOPIC, handleAdminMessage, "ADMIN"); - } + pollNtfy(); } - handleButton(); - updateDisplay(); - - delay(10); + delay(100); // Yield to RTOS } -// ============== POLLING ============== -void pollTopic(const char* url, void (*handler)(const String&), const char* topicName) { - http.begin(url); - http.setTimeout(10000); +// ============== NTFY POLLING (DEFENSIVE) ============== +void pollNtfy() { + Serial.println("Polling ntfy..."); - int httpCode = http.GET(); - if (httpCode == HTTP_CODE_OK) { - String response = http.getString(); - if (response.length() > 0) { - parseMessages(response, topicName, handler); + HTTPClient http; + http.setTimeout(8000); // 8 second timeout + http.setConnectTimeout(5000); // 5 second connect timeout + + // Poll ALERT first + if (http.begin(ALERT_URL)) { + int code = http.GET(); + Serial.println("ALERT HTTP: " + String(code)); + + if (code == 200) { + String body = http.getString(); + Serial.println("ALERT body len: " + String(body.length())); + processBody(body, "ALERT"); } + http.end(); + } + + delay(100); // Brief pause between requests + + // Poll SILENCE + if (http.begin(SILENCE_URL)) { + int code = http.GET(); + Serial.println("SILENCE HTTP: " + String(code)); + + if (code == 200) { + String body = http.getString(); + processBody(body, "SILENCE"); + } + http.end(); } - 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; - } +void processBody(const String& body, const char* type) { + // Simple line-by-line parsing, no heavy JSON + int pos = 0; + while (pos < body.length()) { + int end = body.indexOf('\n', pos); + if (end == -1) end = body.length(); - // 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); + String line = body.substring(pos, end); line.trim(); - if (line.length() > 0 && line.indexOf('{') >= 0) { - StaticJsonDocument<1024> doc; - DeserializationError err = deserializeJson(doc, line); + // Look for "message":"..." pattern + int msgStart = line.indexOf("\"message\":\""); + if (msgStart >= 0) { + msgStart += 11; // Skip "message":" + int msgEnd = line.indexOf("\"", msgStart); + if (msgEnd > msgStart) { + String msg = line.substring(msgStart, msgEnd); - if (!err) { - const char* msgId = doc["id"]; - const char* message = doc["message"]; - time_t msgTime = doc["time"] | 0; // ntfy sends epoch SECONDS + // Unescape simple cases + msg.replace("\\n", "\n"); + msg.replace("\\\"", "\""); - if (message && strlen(message) > 0) { - String msgStr = String(message); - String idStr = msgId ? String(msgId) : ""; + Serial.println(String(type) + " msg: " + msg); - // 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); + if (strcmp(type, "ALERT") == 0 && msg.length() > 0) { + alertMessage = msg; + state = ALERTING; + drawAlert(); + } + else if (strcmp(type, "SILENCE") == 0) { + state = SILENT; + alertMessage = ""; + drawSilent(); } } } - lineStart = lineEnd + 1; + + pos = end + 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); +void drawSilent() { + tft.fillScreen(0x0000); + tft.setTextColor(0xFFDF, 0x0000); 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); + tft.setTextDatum(4); + tft.drawString("SILENT", 160, 86); } -// ============== BUTTON ============== -void handleButton() { - bool reading = (digitalRead(BUTTON_PIN) == LOW); +void drawAlert() { + tft.fillScreen(0x0000); - if (reading != buttonPressed) { - if (reading) { - buttonPressStart = millis(); + // Blink color + uint16_t color = (millis() / 500) % 2 ? 0x07D7 : 0xF81F; + tft.setTextColor(color, 0x0000); - if (millis() - buttonLastPress < BUTTON_DOUBLE_PRESS_MS) { - buttonPressCount++; - } else { - buttonPressCount = 1; - } - buttonLastPress = millis(); + // Auto size + int size = 4; + if (alertMessage.length() > 8) size = 3; + if (alertMessage.length() > 16) size = 2; - 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; + tft.setTextSize(size); + tft.setTextDatum(4); - if (duration >= BUTTON_DEBOUNCE_MS && duration < BUTTON_LONG_PRESS_MS) { - if (currentState == STATE_ALERTING) { - handleSilenceMessage("button"); - } else { - handleAlertMessage("TEST ALERT"); - } - } - } + // Simple wrap + if (alertMessage.length() > 20) { + int mid = alertMessage.length() / 2; + int split = alertMessage.lastIndexOf(' ', mid); + if (split < 0) split = mid; - buttonPressed = reading; + tft.drawString(alertMessage.substring(0, split), 160, 70); + tft.drawString(alertMessage.substring(split + 1), 160, 102); + } else { + tft.drawString(alertMessage, 160, 86); } }