// ============== KLUBHAUS DOORBELL v3.8.2 ============== // Fix: Remove ?poll=1 for short-polling, add raw response logging #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" // Web POSTs here, device polls #define NTFY_SILENCE_TOPIC "SILENCE_klubhaus_topic" // Web POSTs here, device polls #define NTFY_STATUS_TOPIC "STATUS_klubhaus_topic" // Device POSTs here, web listens #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 (Cocaine Chic) ============== #define COLOR_TEAL 0x0BD3D3 #define COLOR_FUCHSIA 0xF890E7 #define COLOR_WHITE 0xFFF8FA #define COLOR_MINT 0x64E8BA #define COLOR_BLACK 0x0A0A0A #define COLOR_DARK_GRAY 0x1A1A1A // ============== 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 /* rotation */, true /* IPS */, 172, 320); // ============== STATE ============== enum State { SILENT, ALERTING }; State currentState = SILENT; String currentMessage = ""; String lastProcessedId = ""; unsigned long lastAlertTime = 0; // ============== TIMING ============== unsigned long lastAlertPoll = 0; unsigned long lastSilencePoll = 0; const unsigned long POLL_INTERVAL_MS = 5000; // 5 second short-poll const unsigned long ERROR_BACKOFF_MS = 60000; // 60s on error unsigned long errorBackoffUntil = 0; // ============== LOGGING ============== enum LogLevel { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL }; void logString(LogLevel level, const char* component, const char* event, const String& detail) { const char* levelStr[] = {"DEBUG", "INFO ", "WARN ", "ERROR", "FATAL"}; unsigned long epochMs = getEpochMillis(); char ts[32]; snprintf(ts, sizeof(ts), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", (int)(epochMs / 31536000000ULL + 1970), // year approx 1, 1, 0, 0, 0, (int)(epochMs % 1000)); // Simplified timestamp — use NTP time if available Serial.printf("%s [%s] %s: %s", ts, levelStr[level], component, event); if (detail.length() > 0) { Serial.printf(" | %s", detail.c_str()); } Serial.println(); } // Fix: Separate overload to avoid recursive call bug void logString(LogLevel level, const char* component, const char* event) { logString(level, component, event, String("")); } // ============== TIME ============== unsigned long epochOffsetMs = 0; bool timeSynced = false; unsigned long getEpochMillis() { if (!timeSynced) return millis(); return epochOffsetMs + millis(); } void initTime() { logString(LOG_INFO, "TIME", "Initializing NTP..."); configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // Wait for sync (max 10s) for (int i = 0; i < 20 && !timeSynced; i++) { struct tm timeinfo; if (getLocalTime(&timeinfo, 500)) { time_t now = time(nullptr); epochOffsetMs = (unsigned long)now * 1000UL - millis(); timeSynced = true; logString(LOG_INFO, "TIME", "synced", String(epochOffsetMs)); } delay(500); } } // ============== DISPLAY FUNCTIONS ============== void initDisplay() { pinMode(TFT_BL, OUTPUT); digitalWrite(TFT_BL, LOW); // Start with backlight off gfx->begin(); gfx->setRotation(1); // Landscape gfx->fillScreen(COLOR_BLACK); // Color test logString(LOG_DEBUG, "DISPLAY", "Running color test..."); gfx->fillRect(0, 0, 80, 172, COLOR_TEAL); gfx->fillRect(80, 0, 80, 172, COLOR_FUCHSIA); gfx->fillRect(160, 0, 80, 172, COLOR_WHITE); gfx->fillRect(240, 0, 80, 172, COLOR_MINT); delay(1000); setBacklight(false); showSilentScreen(); } void setBacklight(bool on) { digitalWrite(TFT_BL, on ? HIGH : LOW); logString(LOG_DEBUG, "BACKLIGHT", on ? "state=1" : "state=0"); } void showSilentScreen() { gfx->fillScreen(COLOR_BLACK); setBacklight(false); // Power saving in silent mode } void showAlertScreen(const String& message) { setBacklight(true); // Auto-size text to fit int textLen = message.length(); int fontSize = textLen > 20 ? 2 : (textLen > 10 ? 3 : 4); gfx->fillScreen(COLOR_TEAL); gfx->setTextColor(COLOR_WHITE); gfx->setTextSize(fontSize); // Center text int16_t x1, y1; uint16_t w, h; gfx->getTextBounds(message.c_str(), 0, 0, &x1, &y1, &w, &h); int x = (320 - w) / 2; int y = (172 - h) / 2; gfx->setCursor(x, y); gfx->println(message); } void flashConfirm(const char* text) { logString(LOG_DEBUG, "DISPLAY", "flashConfirm", 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); showSilentScreen(); } // ============== NETWORK ============== void initWiFi() { logString(LOG_INFO, "WIFI", "Connecting..."); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); logString(LOG_INFO, "WIFI", "Connected", WiFi.localIP().toString()); } // ============== ntfy.sh HTTP ============== // NEW: Parse single NDJSON line, return true if valid message found bool parseNtfyLine(const String& line, String& outId, String& outMessage, unsigned long& outTime) { if (line.length() == 0) return false; // Check for JSON object start if (line.indexOf('{') < 0) return false; // Extract id int idStart = line.indexOf("\"id\":\""); if (idStart < 0) return false; idStart += 6; int idEnd = line.indexOf("\"", idStart); outId = line.substring(idStart, idEnd); // Extract message int msgStart = line.indexOf("\"message\":\""); if (msgStart < 0) { // Some messages might not have message field (keepalive, etc) outMessage = ""; } else { msgStart += 11; int msgEnd = line.indexOf("\"", msgStart); outMessage = line.substring(msgStart, msgEnd); } // Extract time int timeStart = line.indexOf("\"time\":"); if (timeStart >= 0) { timeStart += 7; int timeEnd = line.indexOf(",", timeStart); if (timeEnd < 0) timeEnd = line.indexOf("}", timeStart); String timeStr = line.substring(timeStart, timeEnd); outTime = timeStr.toInt(); } return outId.length() > 0; } // NEW: Fetch and parse ntfy topic — short poll without ?poll=1 bool fetchNtfyJson(const char* topic, String& outId, String& outMessage, unsigned long& outTime) { if (millis() < errorBackoffUntil) { return false; // In backoff period } HTTPClient http; String url = String("https://") + NTFY_SERVER + "/" + topic + "/json"; // NOTE: No ?poll=1 — we want short-poll for ESP32 reliability logString(LOG_DEBUG, "HTTP", "GET", url); http.setTimeout(10000); // 10s max http.begin(url); int httpCode = http.GET(); if (httpCode != 200) { logString(LOG_ERROR, "HTTP", "status=" + String(httpCode), url); if (httpCode == 500 || httpCode == 502 || httpCode == 503) { errorBackoffUntil = millis() + ERROR_BACKOFF_MS; logString(LOG_WARN, "HTTP", "Server error — backing off 60s"); } http.end(); return false; } String payload = http.getString(); http.end(); // NEW: Log raw response for debugging (truncated) String payloadPreview = payload.substring(0, min((int)payload.length(), 200)); payloadPreview.replace("\n", "\\n"); logString(LOG_DEBUG, "HTTP", "rawResponse", payloadPreview); // Parse NDJSON — split by newlines, process each int lineNum = 0; bool foundMessage = false; int startIdx = 0; while (startIdx < payload.length()) { int endIdx = payload.indexOf('\n', startIdx); if (endIdx < 0) endIdx = payload.length(); String line = payload.substring(startIdx, endIdx); line.trim(); lineNum++; if (line.length() > 0 && parseNtfyLine(line, outId, outMessage, outTime)) { logString(LOG_DEBUG, "JSON", String("line[") + lineNum + "] id=[" + outId + "] msg=[" + outMessage + "]"); foundMessage = true; // Continue parsing to consume all lines, but we'll use the last valid one } startIdx = endIdx + 1; } logString(LOG_INFO, "NTFY", "linesParsed", String(lineNum)); return foundMessage; } void postStatus(const char* state, const String& message) { HTTPClient http; String url = String("https://") + NTFY_SERVER + "/" + NTFY_STATUS_TOPIC; String payload = "{\"state\":\"" + String(state) + "\",\"message\":\"" + message + "\",\"timestamp\":" + String(getEpochMillis()) + "}"; logString(LOG_DEBUG, "HTTP", "POST", url); http.begin(url); http.addHeader("Content-Type", "application/json"); int httpCode = http.POST(payload); logString(LOG_INFO, "HTTP", "status=" + String(httpCode) + " dur=" + String(millis() % 1000) + "ms", payload); http.end(); } // ============== STATE MACHINE ============== void setState(State newState, const String& message) { if (currentState == newState && currentMessage == message) return; logString(LOG_INFO, "STATE", newState == ALERTING ? "ALERT <- SILENT" : "SILENT <- ALERT"); currentState = newState; currentMessage = message; if (newState == ALERTING) { lastAlertTime = millis(); showAlertScreen(message); } else { showSilentScreen(); } // Publish status on every transition postStatus(newState == ALERTING ? "ALERTING" : "SILENT", message); } // ============== BUTTON ============== void checkButton() { static bool lastState = HIGH; static unsigned long pressStart = 0; static bool wasPressed = false; bool pressed = (digitalRead(BUTTON_PIN) == LOW); // Active low if (pressed && !lastState) { // Pressed logString(LOG_INFO, "BUTTON", "PRESSED"); setBacklight(true); // Sanity check: flash backlight pressStart = millis(); wasPressed = true; } else if (!pressed && lastState && wasPressed) { // Released unsigned long duration = millis() - pressStart; logString(LOG_INFO, "BUTTON", "RELEASED", String(duration) + "ms"); if (currentState == ALERTING) { // Force silence logString(LOG_INFO, "BUTTON", "Force silence"); setState(SILENT, ""); flashConfirm("SILENCED"); } else { setBacklight(false); // Turn off after sanity check } wasPressed = false; } lastState = pressed; } // ============== MAIN ============== void setup() { Serial.begin(115200); delay(1000); logString(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.2 ==="); logString(LOG_INFO, "MAIN", "Fix: Short-poll, raw response logging"); pinMode(BUTTON_PIN, INPUT_PULLUP); initDisplay(); initWiFi(); initTime(); // Initial status postStatus("SILENT", ""); logString(LOG_INFO, "MAIN", "=== READY ==="); } void loop() { checkButton(); unsigned long now = millis(); // Poll ALERT topic if (now - lastAlertPoll >= POLL_INTERVAL_MS) { lastAlertPoll = now; String id, message; unsigned long msgTime; unsigned long startMs = millis(); bool gotMessage = fetchNtfyJson(NTFY_ALERT_TOPIC, id, message, msgTime); unsigned long duration = millis() - startMs; if (gotMessage) { logString(LOG_INFO, "COMMAND", "durationMs=" + String(duration)); logString(LOG_INFO, "COMMAND", "id=[" + id + "]"); logString(LOG_INFO, "COMMAND", "message=[" + message + "]"); // Deduplication check if (id == lastProcessedId) { logString(LOG_DEBUG, "COMMAND", "Duplicate ID — discarded"); } else { lastProcessedId = id; // Process command if (message.length() > 0 && message != "SILENCE") { logString(LOG_INFO, "COMMAND", "ALERT trigger", message.c_str()); setState(ALERTING, message); } } } } // Poll SILENCE topic (staggered) if (now - lastSilencePoll >= POLL_INTERVAL_MS + 2500) { // Offset by 2.5s lastSilencePoll = now; String id, message; unsigned long msgTime; unsigned long startMs = millis(); bool gotMessage = fetchNtfyJson(NTFY_SILENCE_TOPIC, id, message, msgTime); unsigned long duration = millis() - startMs; if (gotMessage) { logString(LOG_INFO, "SILENCE", "durationMs=" + String(duration)); logString(LOG_INFO, "SILENCE", "id=[" + id + "]"); logString(LOG_INFO, "SILENCE", "message=[" + message + "]"); // Force silence regardless of ID (silence is always processed) if (message.indexOf("silence") >= 0 || message == "SILENCE") { logString(LOG_INFO, "SILENCE", "Force silence — acknowledging"); if (currentState == ALERTING) { setState(SILENT, ""); flashConfirm("SILENCED"); } } } } // Auto-return to silent after alert? No — manual only via button or silence topic delay(10); // Small yield }