// ============== KLUBHAUS DOORBELL v3.8.5 ============== // Fix: Restore button handling, fix double ntfy.sh in URL #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; // ============== 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) { if (code == -1) return "CONNECTION_REFUSED"; 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"; return "HTTP_" + String(code); } // ============== TIME ============== unsigned long getEpochMs() { return millis(); } // ============== DISPLAY ============== void initDisplay() { pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, LOW); gfx->begin(); gfx->setRotation(1); gfx->fillScreen(COLOR_BLACK); 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); } void flashConfirm(const char* text) { setBacklight(true); gfx->fillScreen(COLOR_MINT); gfx->setTextColor(COLOR_BLACK); gfx->setTextSize(2); gfx->setCursor(100, 80); gfx->println(text); delay(500); setBacklight(false); showSilent(); } // ============== 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 ============== bool parseNtfyLine(const String& line, String& outId, String& outMessage, String& outEvent) { if (line.length() == 0 || line.indexOf('{') < 0) return false; auto extract = [&](const char* key) -> String { String search = String("\"") + key + "\":\""; int start = line.indexOf(search); if (start < 0) { 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; } // CRITICAL FIX: Correct URL construction — was double ntfy.sh! bool fetchNtfy(const char* topic, String& outId, String& outMessage, String& outEvent) { HTTPClient http; // FIXED: NTFY_SERVER is "ntfy.sh", topic is "ALERT_klubhaus_topic" // Was: https://ntfy.sh/ntfy.sh/ALERT... — WRONG! // Now: https://ntfy.sh/ALERT... — CORRECT! 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)); http.end(); return false; } String payload = http.getString(); http.end(); String preview = payload.substring(0, min((int)payload.length(), 150)); preview.replace("\n", "\\n"); logMsg(LOG_DEBUG, "RAW", preview); 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")); if (evt == "message" && msg.length() > 0) { outId = id; outMessage = msg; outEvent = evt; found = true; } } } 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); } // ============== BUTTON HANDLING (RESTORED) ============== void checkButton() { static bool lastState = HIGH; static unsigned long pressStart = 0; static bool wasPressed = false; bool pressed = (digitalRead(BUTTON_PIN) == LOW); if (pressed && !lastState) { logMsg(LOG_INFO, "BUTTON", "PRESSED"); setBacklight(true); pressStart = millis(); wasPressed = true; } else if (!pressed && lastState && wasPressed) { unsigned long duration = millis() - pressStart; logMsg(LOG_INFO, "BUTTON", String("RELEASED ") + duration + "ms"); if (currentState == ALERTING) { logMsg(LOG_INFO, "BUTTON", "Force silence"); setState(SILENT, ""); flashConfirm("SILENCED"); } else { setBacklight(false); } wasPressed = false; } lastState = pressed; } // ============== MAIN ============== void setup() { Serial.begin(115200); delay(1000); logMsg(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.5 ==="); logMsg(LOG_INFO, "MAIN", "Fix: Button restored, URL fixed"); pinMode(BUTTON_PIN, INPUT_PULLUP); // RESTORED initDisplay(); initWiFi(); postStatus("SILENT", ""); logMsg(LOG_INFO, "MAIN", "READY — send FRONT DOOR from web client"); } void loop() { checkButton(); // RESTORED unsigned long now = millis(); 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"); } } } 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); }