// ============== KLUBHAUS DOORBELL v3.8.4 ============== // Fix: Use ?since=all to retrieve cached messages, decode HTTP errors #include #include #include #include // ============== CONFIG ============== #define WIFI_SSID "iot-2GHz" #define WIFI_PASS "lesson-greater" // ntfy.sh topics — naming indicates direction #define NTFY_ALERT_TOPIC "ALERT_klubhaus_topic" #define NTFY_SILENCE_TOPIC "SILENCE_klubhaus_topic" #define NTFY_STATUS_TOPIC "STATUS_klubhaus_topic" #define NTFY_SERVER "ntfy.sh" // Display pins for ESP32-C6-LCD-1.47 #define TFT_MOSI 6 #define TFT_SCLK 7 #define TFT_CS 14 #define TFT_DC 15 #define TFT_RST 21 #define TFT_BL 22 #define BUTTON_PIN 9 // ============== COLORS ============== #define COLOR_TEAL 0x0BD3D3 #define COLOR_FUCHSIA 0xF890E7 #define COLOR_WHITE 0xFFF8FA #define COLOR_MINT 0x64E8BA #define COLOR_BLACK 0x0A0A0A // ============== DISPLAY ============== Arduino_DataBus *bus = new Arduino_SWSPI(TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, GFX_NOT_DEFINED); Arduino_GFX *gfx = new Arduino_ST7789(bus, TFT_RST, 0, true, 172, 320); enum State { SILENT, ALERTING }; State currentState = SILENT; String currentMessage = ""; String lastProcessedId = ""; unsigned long lastAlertPoll = 0; unsigned long lastSilencePoll = 0; const unsigned long POLL_INTERVAL_MS = 3000; // Faster polling for testing // ============== LOGGING ============== enum LogLevel { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL }; void logMsg(LogLevel level, const char* component, const String& msg) { const char* lvl[] = {"DBG", "INF", "WRN", "ERR", "FTL"}; Serial.printf("[%s] %s: %s\n", lvl[level], component, msg.c_str()); } void logMsg(LogLevel level, const char* component, const char* msg) { logMsg(level, component, String(msg)); } // ============== HTTP ERROR DECODER ============== String decodeHttpError(int code) { // HTTPClient error codes (negative values) if (code == -1) return "CONNECTION_REFUSED/SEND_FAILED"; if (code == -2) return "SEND_PAYLOAD_FAILED"; if (code == -3) return "NOT_CONNECTED"; if (code == -4) return "CONNECTION_LOST"; if (code == -5) return "NO_STREAM"; if (code == -6) return "NO_HTTP_SERVER"; if (code == -7) return "TOO_LESS_RAM"; if (code == -8) return "ENCODING"; if (code == -9) return "STREAM_WRITE"; if (code == -10) return "READ_TIMEOUT"; if (code == -11) return "CONNECTION_TIMEOUT"; // Positive = HTTP status return "HTTP_" + String(code); } // ============== TIME ============== unsigned long getEpochMs() { return millis(); // Simplified for now } // ============== DISPLAY ============== void initDisplay() { pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, LOW); gfx->begin(); gfx->setRotation(1); gfx->fillScreen(COLOR_BLACK); // Test pattern gfx->fillRect(0, 0, 80, 172, COLOR_TEAL); gfx->fillRect(80, 0, 80, 172, COLOR_FUCHSIA); delay(500); digitalWrite(TFT_BL, LOW); } void setBacklight(bool on) { digitalWrite(TFT_BL, on ? HIGH : LOW); } void showSilent() { gfx->fillScreen(COLOR_BLACK); setBacklight(false); } void showAlert(const String& msg) { setBacklight(true); int fontSize = msg.length() > 20 ? 2 : (msg.length() > 10 ? 3 : 4); gfx->fillScreen(COLOR_TEAL); gfx->setTextColor(COLOR_WHITE); gfx->setTextSize(fontSize); int16_t x1, y1; uint16_t w, h; gfx->getTextBounds(msg.c_str(), 0, 0, &x1, &y1, &w, &h); gfx->setCursor((320-w)/2, (172-h)/2); gfx->println(msg); } // ============== NETWORK ============== void initWiFi() { logMsg(LOG_INFO, "WIFI", "Connecting..."); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); logMsg(LOG_INFO, "WIFI", "IP: " + WiFi.localIP().toString()); } // ============== ntfy.sh WITH ?since=all ============== bool parseNtfyLine(const String& line, String& outId, String& outMessage, String& outEvent) { if (line.length() == 0 || line.indexOf('{') < 0) return false; // Quick JSON extraction (no library needed for simple fields) auto extract = [&](const char* key) -> String { String search = String("\"") + key + "\":\""; int start = line.indexOf(search); if (start < 0) { // Try number format: "key":123 search = String("\"") + key + "\":"; start = line.indexOf(search); if (start < 0) return ""; start += search.length(); int end = line.indexOf(",", start); if (end < 0) end = line.indexOf("}", start); return line.substring(start, end); } start += search.length(); int end = line.indexOf("\"", start); return line.substring(start, end); }; outId = extract("id"); outMessage = extract("message"); outEvent = extract("event"); return outId.length() > 0; } bool fetchNtfy(const char* topic, String& outId, String& outMessage, String& outEvent) { HTTPClient http; // KEY FIX: ?since=all forces retrieval of cached messages // Without this, we only get "open" subscription events String url = String("https://") + NTFY_SERVER + "/" + topic + "/json?since=all"; logMsg(LOG_DEBUG, "HTTP", "GET " + url); http.setTimeout(8000); http.begin(url); int code = http.GET(); if (code != 200) { logMsg(LOG_ERROR, "HTTP", String(code) + "=" + decodeHttpError(code) + " | " + url); http.end(); return false; } String payload = http.getString(); http.end(); // Log first 150 chars of raw response String preview = payload.substring(0, min((int)payload.length(), 150)); preview.replace("\n", "\\n"); logMsg(LOG_DEBUG, "RAW", preview); // Parse all lines, return LAST valid message (newest) bool found = false; int lineStart = 0; int lineNum = 0; while (lineStart < payload.length()) { int lineEnd = payload.indexOf('\n', lineStart); if (lineEnd < 0) lineEnd = payload.length(); String line = payload.substring(lineStart, lineEnd); line.trim(); lineNum++; if (line.length() > 0) { String id, msg, evt; if (parseNtfyLine(line, id, msg, evt)) { logMsg(LOG_DEBUG, "JSON", String("L") + lineNum + " evt=" + evt + " id=" + id + " msg=" + (msg.length() ? msg : "EMPTY")); // Only count actual messages, not "open" events if (evt == "message" && msg.length() > 0) { outId = id; outMessage = msg; outEvent = evt; found = true; // Continue to get the LAST (newest) message } } } lineStart = lineEnd + 1; } return found; } void postStatus(const char* state, const String& msg) { HTTPClient http; String url = String("https://") + NTFY_SERVER + "/" + NTFY_STATUS_TOPIC; String payload = "{\"state\":\"" + String(state) + "\",\"message\":\"" + msg + "\"}"; http.begin(url); http.addHeader("Content-Type", "application/json"); int code = http.POST(payload); logMsg(LOG_INFO, "STATUS", String("POST ") + code + " " + payload); http.end(); } // ============== STATE MACHINE ============== void setState(State newState, const String& msg) { if (currentState == newState && currentMessage == msg) return; currentState = newState; currentMessage = msg; if (newState == ALERTING) { showAlert(msg); } else { showSilent(); } postStatus(newState == ALERTING ? "ALERTING" : "SILENT", msg); } // ============== MAIN ============== void setup() { Serial.begin(115200); delay(1000); logMsg(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.4 ==="); logMsg(LOG_INFO, "MAIN", "Fix: ?since=all for cached messages"); initDisplay(); initWiFi(); postStatus("SILENT", ""); logMsg(LOG_INFO, "MAIN", "READY — send FRONT DOOR from web client"); } void loop() { unsigned long now = millis(); // Poll ALERT topic if (now - lastAlertPoll >= POLL_INTERVAL_MS) { lastAlertPoll = now; String id, message, event; if (fetchNtfy(NTFY_ALERT_TOPIC, id, message, event)) { logMsg(LOG_INFO, "ALERT", "GOT: id=" + id + " msg=[" + message + "]"); if (id != lastProcessedId) { lastProcessedId = id; if (message != "SILENCE") { logMsg(LOG_INFO, "ALERT", "TRIGGER: " + message); setState(ALERTING, message); } } else { logMsg(LOG_DEBUG, "ALERT", "Duplicate ID, ignored"); } } else { logMsg(LOG_DEBUG, "ALERT", "No messages or error"); } } // Poll SILENCE topic (staggered) if (now - lastSilencePoll >= POLL_INTERVAL_MS + 1500) { lastSilencePoll = now; String id, message, event; if (fetchNtfy(NTFY_SILENCE_TOPIC, id, message, event)) { logMsg(LOG_INFO, "SILENCE", "GOT: id=" + id + " msg=[" + message + "]"); if (message.indexOf("silence") >= 0 || message == "SILENCE") { logMsg(LOG_INFO, "SILENCE", "ACKNOWLEDGED"); if (currentState == ALERTING) { setState(SILENT, ""); } } } } delay(50); }