#include #include #include #include #include // ============== CONFIGURATION ============== const char* ssid = "iot-2GHz"; const char* password = "lesson-greater"; const char* COMMAND_POLL_URL = "http://ntfy.sh/ALERT_klubhaus_topic/json?poll=1"; const char* SILENCE_POLL_URL = "http://ntfy.sh/SILENCE_klubhaus_topic/json?poll=1"; const char* STATUS_POST_URL = "http://ntfy.sh/STATUS_klubhaus_topic"; const unsigned long COMMAND_POLL_INTERVAL = 10000; const unsigned long SILENCE_POLL_INTERVAL = 15000; const unsigned long BLINK_DURATION = 180000; const unsigned long BLINK_PERIOD = 1000; const unsigned long ERROR_BACKOFF = 60000; #define BACKLIGHT_PIN 22 #define BUTTON_PIN 9 #define COLOR_BLACK 0x0000 #define COLOR_COCAINE 0xFFFE #define COLOR_TEAL 0x05F5 #define COLOR_FUCHSIA 0xFC6E #define COLOR_MINT 0x674D #define ALERT_BG_1 COLOR_TEAL #define ALERT_TEXT_1 COLOR_COCAINE #define ALERT_BG_2 COLOR_FUCHSIA #define ALERT_TEXT_2 COLOR_COCAINE #define SCREEN_WIDTH 320 #define SCREEN_HEIGHT 172 Arduino_DataBus *bus = new Arduino_HWSPI(15, 14, 7, 6, -1); Arduino_GFX *gfx = new Arduino_ST7789(bus, 21, 1, true, 172, 320, 34, 0, 34, 0); enum State { STATE_SILENT, STATE_ALERT }; enum LogLevel { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL }; const LogLevel CURRENT_LOG_LEVEL = LOG_DEBUG; State currentState = STATE_SILENT; State lastPublishedState = STATE_SILENT; unsigned long lastCommandPoll = 0; unsigned long lastSilencePoll = 0; unsigned long nextPollTime = 0; unsigned long blinkStartTime = 0; bool blinkPhase = false; bool backlightState = false; String lastCommandId = ""; String lastSilenceId = ""; bool lastButtonState = false; String alertMessage = ""; unsigned long long epochOffsetMs = 0; // ============== PROFESSIONAL LOGGING ============== void formatIsoTimestamp(char* buf, size_t len); unsigned long long getEpochMs() { if (epochOffsetMs == 0) return millis(); return epochOffsetMs + millis(); } void logMessage(LogLevel level, const char* component, const char* message) { if (level < CURRENT_LOG_LEVEL) return; const char* levelStr; switch (level) { case LOG_DEBUG: levelStr = "DEBUG"; break; case LOG_INFO: levelStr = "INFO "; break; case LOG_WARN: levelStr = "WARN "; break; case LOG_ERROR: levelStr = "ERROR"; break; case LOG_FATAL: levelStr = "FATAL"; break; default: levelStr = "?????"; break; } char timestamp[32]; formatIsoTimestamp(timestamp, sizeof(timestamp)); Serial.print(timestamp); Serial.print(" ["); Serial.print(levelStr); Serial.print("] "); Serial.print(component); Serial.print(": "); Serial.println(message); } void logValue(LogLevel level, const char* component, const char* name, long long value) { if (level < CURRENT_LOG_LEVEL) return; char buf[128]; snprintf(buf, sizeof(buf), "%s=%lld", name, value); logMessage(level, component, buf); } // FIX: Was recursively calling itself with wrong args void logString(LogLevel level, const char* component, const char* name, const String& value) { if (level < CURRENT_LOG_LEVEL) return; char buf[256]; String escaped = value; escaped.replace("\n", "\\n"); escaped.replace("\r", "\\r"); snprintf(buf, sizeof(buf), "%s=[%s]", name, escaped.c_str()); logMessage(level, component, buf); // FIXED: was logString() before } void formatIsoTimestamp(char* buf, size_t len) { unsigned long long epochMs = getEpochMs(); time_t seconds = epochMs / 1000; unsigned int ms = epochMs % 1000; struct tm* tm = gmtime(&seconds); snprintf(buf, len, "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min, tm->tm_sec, ms); } // ============== SETUP ============== void setup() { Serial.begin(115200); delay(3000); logMessage(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.1 ==="); logMessage(LOG_INFO, "MAIN", "Fix: logString() recursive call bug"); pinMode(BACKLIGHT_PIN, OUTPUT); setBacklight(false); pinMode(BUTTON_PIN, INPUT_PULLUP); gfx->begin(); setBacklight(true); testDisplay(); delay(300); transitionTo(STATE_SILENT); logMessage(LOG_INFO, "WIFI", "Connecting..."); WiFi.begin(ssid, password); int timeout = 0; while (WiFi.status() != WL_CONNECTED && timeout < 40) { delay(500); Serial.print("."); timeout++; } if (WiFi.status() != WL_CONNECTED) { Serial.println(); logMessage(LOG_FATAL, "WIFI", "Connection failed"); showFatalError("OFFLINE"); return; } Serial.println(); char ipBuf[64]; snprintf(ipBuf, sizeof(ipBuf), "Connected: %s", WiFi.localIP().toString().c_str()); logMessage(LOG_INFO, "WIFI", ipBuf); initEpochTime(); publishStatus(); logMessage(LOG_INFO, "MAIN", "=== READY ==="); } // ============== BACKLIGHT CONTROL ============== void setBacklight(bool on) { digitalWrite(BACKLIGHT_PIN, on ? HIGH : LOW); if (backlightState != on) { backlightState = on; logValue(LOG_DEBUG, "BACKLIGHT", "state", on ? 1 : 0); } } // ============== EPOCH TIME ============== void initEpochTime() { logMessage(LOG_INFO, "TIME", "Initializing NTP..."); configTime(0, 0, "pool.ntp.org", "time.nist.gov"); time_t now = 0; int retries = 0; while (now < 1000000000 && retries < 30) { delay(500); now = time(nullptr); retries++; } if (now > 1000000000) { epochOffsetMs = (now * 1000ULL) - millis(); logValue(LOG_INFO, "TIME", "epochOffsetMs", (long long)epochOffsetMs); char ts[32]; formatIsoTimestamp(ts, sizeof(ts)); logString(LOG_INFO, "TIME", "syncedAt", String(ts)); } else { logMessage(LOG_WARN, "TIME", "NTP sync failed — using boot-relative timestamps"); epochOffsetMs = 0; } } // ============== MAIN LOOP ============== void loop() { unsigned long now = millis(); if (now < nextPollTime) { delay(100); return; } handleButton(); switch (currentState) { case STATE_SILENT: if (now - lastSilencePoll >= SILENCE_POLL_INTERVAL) { lastSilencePoll = now; checkSilenceTopic(); } if (now - lastCommandPoll >= COMMAND_POLL_INTERVAL) { lastCommandPoll = now; checkCommandTopic(); } break; case STATE_ALERT: if (now - lastSilencePoll >= SILENCE_POLL_INTERVAL) { lastSilencePoll = now; checkSilenceTopic(); } updateBlink(now); if (now - blinkStartTime >= BLINK_DURATION) { logMessage(LOG_INFO, "STATE", "Alert timeout — transitioning to SILENT"); transitionTo(STATE_SILENT); } break; } delay(50); } // ============== STATUS PUBLISHING ============== void publishStatus() { if (WiFi.status() != WL_CONNECTED) { logMessage(LOG_WARN, "HTTP", "Status publish skipped — no WiFi"); return; } logMessage(LOG_DEBUG, "HTTP", "POST /STATUS_klubhaus_topic"); HTTPClient http; http.begin(STATUS_POST_URL); http.addHeader("Content-Type", "text/plain"); StaticJsonDocument<256> doc; doc["state"] = (currentState == STATE_SILENT) ? "SILENT" : "ALERTING"; doc["message"] = alertMessage; doc["timestamp"] = (unsigned long long)getEpochMs(); String payload; serializeJson(doc, payload); unsigned long reqStart = millis(); int code = http.POST(payload); unsigned long duration = millis() - reqStart; char logBuf[512]; snprintf(logBuf, sizeof(logBuf), "status=%d duration=%lums payload=%s", code, duration, payload.c_str()); logMessage(LOG_INFO, "HTTP", logBuf); http.end(); lastPublishedState = currentState; } void transitionTo(State newState) { if (newState == currentState) return; logMessage(LOG_INFO, "STATE", newState == STATE_SILENT ? "SILENT <- ALERT" : "ALERT <- SILENT"); currentState = newState; switch (currentState) { case STATE_SILENT: alertMessage = ""; gfx->fillScreen(COLOR_BLACK); setBacklight(false); break; case STATE_ALERT: setBacklight(true); blinkStartTime = millis(); blinkPhase = false; drawAlertScreen(ALERT_BG_1, ALERT_TEXT_1); break; } publishStatus(); } // ============== NDJSON PARSING ============== void checkCommandTopic() { logMessage(LOG_DEBUG, "HTTP", "GET /ALERT_klubhaus_topic/json?poll=1"); unsigned long reqStart = millis(); String response = fetchNtfyJson(COMMAND_POLL_URL, "COMMAND"); unsigned long duration = millis() - reqStart; if (response.length() == 0) { logMessage(LOG_DEBUG, "HTTP", "Empty response (no messages)"); return; } int lineStart = 0; int lineCount = 0; bool foundMessage = false; while (lineStart < response.length()) { int lineEnd = response.indexOf('\n', lineStart); if (lineEnd < 0) lineEnd = response.length(); String line = response.substring(lineStart, lineEnd); line.trim(); lineCount++; if (line.length() > 0) { StaticJsonDocument<512> doc; DeserializationError error = deserializeJson(doc, line); if (!error) { String id = doc["id"] | ""; String message = doc["message"] | ""; long long serverTime = doc["time"] | 0; char inspectBuf[256]; snprintf(inspectBuf, sizeof(inspectBuf), "line[%d] id=[%s] message=[%s] time=%lld", lineCount, id.c_str(), message.c_str(), serverTime); logMessage(LOG_DEBUG, "JSON", inspectBuf); String testMsg = message; testMsg.trim(); if (testMsg.length() > 0) { logValue(LOG_INFO, "COMMAND", "linesParsed", lineCount); logValue(LOG_INFO, "COMMAND", "durationMs", duration); logString(LOG_INFO, "COMMAND", "id", id); logString(LOG_INFO, "COMMAND", "message", message); logValue(LOG_INFO, "COMMAND", "serverTime", serverTime); if (id == lastCommandId && id.length() > 0) { logMessage(LOG_DEBUG, "COMMAND", "Duplicate ID — discarded"); return; } lastCommandId = id; if (message.equalsIgnoreCase("SILENCE")) { logMessage(LOG_INFO, "COMMAND", "SILENCE command — acknowledging"); transitionTo(STATE_SILENT); flashConfirm("SILENCE", COLOR_MINT, COLOR_BLACK); } else { logString(LOG_INFO, "COMMAND", "ALERT trigger", message); alertMessage = message; transitionTo(STATE_ALERT); } foundMessage = true; break; } } else { char errBuf[128]; snprintf(errBuf, sizeof(errBuf), "line[%d] parse error: %s", lineCount, error.c_str()); logMessage(LOG_WARN, "JSON", errBuf); } } lineStart = lineEnd + 1; } if (!foundMessage) { logValue(LOG_DEBUG, "COMMAND", "noValidMessageInLines", lineCount); } } void checkSilenceTopic() { logMessage(LOG_DEBUG, "HTTP", "GET /SILENCE_klubhaus_topic/json?poll=1"); unsigned long reqStart = millis(); String response = fetchNtfyJson(SILENCE_POLL_URL, "SILENCE"); unsigned long duration = millis() - reqStart; if (response.length() == 0) return; int lineStart = 0; int lineCount = 0; while (lineStart < response.length()) { int lineEnd = response.indexOf('\n', lineStart); if (lineEnd < 0) lineEnd = response.length(); String line = response.substring(lineStart, lineEnd); line.trim(); lineCount++; if (line.length() > 0) { StaticJsonDocument<512> doc; DeserializationError error = deserializeJson(doc, line); if (!error) { String id = doc["id"] | ""; String message = doc["message"] | ""; String testMsg = message; testMsg.trim(); if (testMsg.length() > 0) { logValue(LOG_INFO, "SILENCE", "linesParsed", lineCount); logValue(LOG_INFO, "SILENCE", "durationMs", duration); logString(LOG_INFO, "SILENCE", "id", id); logString(LOG_INFO, "SILENCE", "message", message); if (id == lastSilenceId && id.length() > 0) return; lastSilenceId = id; if (currentState == STATE_ALERT) { logMessage(LOG_INFO, "SILENCE", "Force silence — acknowledging"); transitionTo(STATE_SILENT); flashConfirm("SILENCED", COLOR_MINT, COLOR_BLACK); } return; } } } lineStart = lineEnd + 1; } } String fetchNtfyJson(const char* url, const char* topicName) { HTTPClient http; http.begin(url); http.setTimeout(12000); int code = http.GET(); if (code == 204) { logMessage(LOG_DEBUG, "HTTP", "204 No Content"); http.end(); return ""; } if (code == 429) { logMessage(LOG_WARN, "HTTP", "429 Rate Limited — backing off 60s"); nextPollTime = millis() + ERROR_BACKOFF; http.end(); return ""; } if (code >= 500) { char errBuf[64]; snprintf(errBuf, sizeof(errBuf), "%d Server Error — backing off 60s", code); logMessage(LOG_ERROR, "HTTP", errBuf); nextPollTime = millis() + ERROR_BACKOFF; http.end(); return ""; } if (code != 200) { char errBuf[64]; snprintf(errBuf, sizeof(errBuf), "Unexpected status %d", code); logMessage(LOG_ERROR, "HTTP", errBuf); http.end(); return ""; } String payload = http.getString(); http.end(); return payload; } // ============== VISUALS ============== void flashConfirm(const char* text, uint16_t bgColor, uint16_t textColor) { logString(LOG_DEBUG, "DISPLAY", "flashConfirm", String(text)); setBacklight(true); gfx->fillScreen(bgColor); drawCenteredText(text, 3, textColor, bgColor, 70); delay(500); gfx->fillScreen(COLOR_BLACK); if (currentState == STATE_SILENT) { setBacklight(false); } } void drawAlertScreen(uint16_t bgColor, uint16_t textColor) { gfx->fillScreen(bgColor); drawCenteredText("ALERT", 3, textColor, bgColor, 20); if (alertMessage.length() > 0) { drawAutoSizedMessage(alertMessage, textColor, bgColor, 70); } } void drawAutoSizedMessage(String message, uint16_t textColor, uint16_t bgColor, int yPos) { int textSize = calculateOptimalTextSize(message, SCREEN_WIDTH - 40, SCREEN_HEIGHT - yPos - 20); gfx->setTextSize(textSize); gfx->setTextColor(textColor, bgColor); gfx->setTextBound(20, yPos, SCREEN_WIDTH - 20, SCREEN_HEIGHT - 10); gfx->setCursor(20, yPos); gfx->println(message); } int calculateOptimalTextSize(String message, int maxWidth, int maxHeight) { for (int size = 5; size >= 1; size--) { gfx->setTextSize(size); int charWidth = 6 * size; int lineHeight = 8 * size; int charsPerLine = maxWidth / charWidth; int numLines = (message.length() + charsPerLine - 1) / charsPerLine; int totalHeight = numLines * lineHeight; if (totalHeight <= maxHeight && charsPerLine >= 4) return size; } return 1; } void drawCenteredText(const char* text, int textSize, uint16_t textColor, uint16_t bgColor, int yPos) { gfx->setTextSize(textSize); gfx->setTextColor(textColor, bgColor); int textWidth = strlen(text) * 6 * textSize; int xPos = (SCREEN_WIDTH - textWidth) / 2; if (xPos < 20) xPos = 20; gfx->setCursor(xPos, yPos); gfx->println(text); } void showFatalError(const char* text) { logString(LOG_DEBUG, "DISPLAY", "fatalError", String(text)); setBacklight(true); gfx->fillScreen(COLOR_BLACK); drawCenteredText(text, 3, COLOR_FUCHSIA, COLOR_BLACK, 70); delay(3000); setBacklight(false); } // ============== BUTTON ============== void handleButton() { bool pressed = (digitalRead(BUTTON_PIN) == LOW); if (pressed && !lastButtonState) { logMessage(LOG_INFO, "BUTTON", "PRESSED"); setBacklight(true); gfx->fillScreen(COLOR_COCAINE); drawCenteredText("TEST MODE", 2, COLOR_BLACK, COLOR_COCAINE, 70); } else if (!pressed && lastButtonState) { logMessage(LOG_INFO, "BUTTON", "RELEASED"); if (currentState == STATE_ALERT) { logMessage(LOG_INFO, "BUTTON", "Action: reset to SILENT"); transitionTo(STATE_SILENT); flashConfirm("SILENCED", COLOR_MINT, COLOR_BLACK); } else { gfx->fillScreen(COLOR_BLACK); setBacklight(false); } } lastButtonState = pressed; } // ============== BLINK ============== void updateBlink(unsigned long now) { unsigned long elapsed = now - blinkStartTime; bool newPhase = ((elapsed / BLINK_PERIOD) % 2) == 1; if (newPhase != blinkPhase) { blinkPhase = newPhase; uint16_t bgColor = blinkPhase ? ALERT_BG_2 : ALERT_BG_1; uint16_t textColor = blinkPhase ? ALERT_TEXT_2 : ALERT_TEXT_1; drawAlertScreen(bgColor, textColor); } } // ============== TEST ============== void testDisplay() { logMessage(LOG_DEBUG, "DISPLAY", "Running color test..."); uint16_t colors[] = {COLOR_TEAL, COLOR_FUCHSIA, COLOR_COCAINE, COLOR_MINT, COLOR_BLACK}; for (int i = 0; i < 5; i++) { gfx->fillScreen(colors[i]); delay(100); } gfx->fillScreen(COLOR_BLACK); drawCenteredText("KLUBHAUS", 3, COLOR_TEAL, COLOR_BLACK, 50); drawCenteredText("DOORBELL", 3, COLOR_FUCHSIA, COLOR_BLACK, 90); delay(400); gfx->fillScreen(COLOR_BLACK); }