// ============== KLUBHAUS DOORBELL v4.1.0 ============== // Features: Configurable log levels, faster serial, optimized polling #include #include #include #include // ============== CONFIG ============== #define WIFI_SSID "iot-2GHz" #define WIFI_PASS "lesson-greater" // ntfy.sh topics #define NTFY_ALERT_TOPIC "ALERT_klubhaus_topic" #define NTFY_SILENCE_TOPIC "SILENCE_klubhaus_topic" #define NTFY_STATUS_TOPIC "STATUS_klubhaus_topic" #define NTFY_ADMIN_TOPIC "ADMIN_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 #define LED_PIN 8 // ============== COLORS ============== #define COLOR_TEAL 0x0BD3D3 #define COLOR_FUCHSIA 0xF890E7 #define COLOR_WHITE 0xFFF8FA #define COLOR_MINT 0x64E8BA #define COLOR_BLACK 0x0A0A0A // ============== LOGGING ============== // Configurable at runtime - stored in RAM (could use Preferences for persistence) enum LogLevel { LOG_DEBUG = 0, LOG_INFO = 1, LOG_WARN = 2, LOG_ERROR = 3, LOG_FATAL = 4, LOG_NONE = 5 }; LogLevel currentLogLevel = LOG_INFO; // Default: INFO and above (no DEBUG spam) const char* LOG_NAMES[] = {"DBG", "INF", "WRN", "ERR", "FTL", "OFF"}; // Fast log function - checks level first, minimal overhead #define LOG(level, component, msg) do { \ if (level >= currentLogLevel) logMsg(level, component, msg); \ } while(0) // ============== 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 lastProcessedAlertId = ""; String lastProcessedAdminId = ""; unsigned long lastAlertPoll = 0; unsigned long lastSilencePoll = 0; unsigned long lastAdminPoll = 0; const unsigned long POLL_INTERVAL_MS = 3000; const unsigned long ADMIN_POLL_INTERVAL_MS = 10000; // ============== FALLBACK MODE ============== enum DisplayMode { MODE_SCREEN, MODE_LED_ONLY }; DisplayMode currentDisplayMode = MODE_SCREEN; bool screenAvailable = true; // ============== SERIAL COMMANDS ============== String serialBuffer = ""; static const size_t SERIAL_BUFFER_MAX = 64; // Limit buffer size // ============== FAST LOGGING ============== void logMsg(LogLevel level, const char* component, const String& msg) { // Use printf for single-call output (faster than multiple Serial calls) Serial.printf("[%s] %s: %s\n", LOG_NAMES[level], component, msg.c_str()); } void logMsg(LogLevel level, const char* component, const char* msg) { logMsg(level, component, String(msg)); } // ============== HTTP ERROR DECODER ============== // Only compile error strings when needed (LOG_DEBUG level) String decodeHttpError(int code) { #ifdef DEBUG_HTTP_CODES 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"; #endif return "HTTP_" + String(code); } // ============== LED CONTROL ============== void initLED() { pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); } void setLED(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); } void flashLED(int times, int onMs = 200, int offMs = 200) { for (int i = 0; i < times; i++) { setLED(true); delay(onMs); setLED(false); if (i < times - 1) delay(offMs); } } void pulseLED(int durationMs) { unsigned long start = millis(); while (millis() - start < durationMs) { setLED(true); delay(100); setLED(false); delay(100); } } // ============== 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); LOG(LOG_INFO, "DISPLAY", "Screen initialized"); } void setBacklight(bool on) { if (currentDisplayMode == MODE_LED_ONLY) return; digitalWrite(TFT_BL, on ? HIGH : LOW); } void showSilent() { if (currentDisplayMode == MODE_LED_ONLY) { setLED(false); return; } if (!screenAvailable) return; gfx->fillScreen(COLOR_BLACK); setBacklight(false); } void showAlert(const String& msg) { if (currentDisplayMode == MODE_LED_ONLY) { pulseLED(5000); return; } if (!screenAvailable) { flashLED(5, 300, 200); return; } 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) { if (currentDisplayMode == MODE_LED_ONLY) { flashLED(3, 100, 100); return; } if (!screenAvailable) { flashLED(2, 500, 200); return; } 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() { LOG(LOG_INFO, "WIFI", "Connecting..."); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); LOG(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; // Fast extraction without creating intermediate strings when possible 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; } bool fetchNtfy(const char* topic, String& outId, String& outMessage, String& outEvent) { HTTPClient http; String url = String("https://") + NTFY_SERVER + "/" + topic + "/json?since=all"; LOG(LOG_DEBUG, "HTTP", "GET " + url); http.setTimeout(8000); http.begin(url); int code = http.GET(); if (code != 200) { LOG(LOG_ERROR, "HTTP", String(code) + "=" + decodeHttpError(code)); http.end(); return false; } String payload = http.getString(); http.end(); // Only log full payload at DEBUG level #if 0 // Disabled - too slow String preview = payload.substring(0, min((int)payload.length(), 150)); preview.replace("\n", "\\n"); LOG(LOG_DEBUG, "RAW", preview); #endif 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)) { LOG(LOG_DEBUG, "JSON", String("L") + lineNum + " evt=" + evt + " id=" + id); 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); LOG(LOG_INFO, "STATUS", String("POST ") + code + " " + payload); http.end(); } // ============== LOG LEVEL CONTROL ============== void setLogLevel(LogLevel level) { currentLogLevel = level; LOG(LOG_INFO, "CONFIG", String("Log level set to ") + LOG_NAMES[level]); } void setLogLevel(const String& levelStr) { if (levelStr == "DEBUG" || levelStr == "0") setLogLevel(LOG_DEBUG); else if (levelStr == "INFO" || levelStr == "1") setLogLevel(LOG_INFO); else if (levelStr == "WARN" || levelStr == "WARNING" || levelStr == "2") setLogLevel(LOG_WARN); else if (levelStr == "ERROR" || levelStr == "3") setLogLevel(LOG_ERROR); else if (levelStr == "FATAL" || levelStr == "4") setLogLevel(LOG_FATAL); else if (levelStr == "NONE" || levelStr == "OFF" || levelStr == "5") setLogLevel(LOG_NONE); else { LOG(LOG_WARN, "CONFIG", "Unknown log level: " + levelStr); } } // ============== ADMIN / CONFIG ============== void processAdminCommand(const String& cmd) { LOG(LOG_INFO, "ADMIN", "Processing: " + cmd); // Log level commands if (cmd.startsWith("LOG ")) { String level = cmd.substring(4); level.trim(); setLogLevel(level); postStatus("CONFIG", "log=" + level); return; } if (cmd == "MODE_SCREEN") { currentDisplayMode = MODE_SCREEN; LOG(LOG_INFO, "ADMIN", "Switched to SCREEN mode"); postStatus("CONFIG", "mode=screen"); flashConfirm("SCREEN MODE"); } else if (cmd == "MODE_LED") { currentDisplayMode = MODE_LED_ONLY; LOG(LOG_INFO, "ADMIN", "Switched to LED mode"); postStatus("CONFIG", "mode=led"); showSilent(); flashConfirm("LED MODE"); } else if (cmd == "SILENCE" || cmd == "SILENT") { if (currentState == ALERTING) { setState(SILENT, ""); flashConfirm("SILENCED"); } } else if (cmd == "PING") { postStatus("PONG", "device=klubhaus"); } else if (cmd == "REBOOT") { LOG(LOG_INFO, "ADMIN", "Rebooting..."); postStatus("CONFIG", "rebooting"); delay(500); ESP.restart(); } else if (cmd.startsWith("WIFI ")) { LOG(LOG_WARN, "ADMIN", "WiFi change requires manual reboot"); postStatus("CONFIG", "wifi_change_pending"); } else { LOG(LOG_WARN, "ADMIN", "Unknown command: " + cmd); } } void checkAdminTopic() { String id, message, event; if (fetchNtfy(NTFY_ADMIN_TOPIC, id, message, event)) { LOG(LOG_INFO, "ADMIN", "GOT: id=" + id + " msg=[" + message + "]"); if (id != lastProcessedAdminId) { lastProcessedAdminId = id; processAdminCommand(message); } else { LOG(LOG_DEBUG, "ADMIN", "Duplicate ID, ignored"); } } } // ============== SERIAL COMMANDS ============== void processSerialCommand(const String& cmd) { LOG(LOG_INFO, "SERIAL", "Command: " + cmd); if (cmd == "help" || cmd == "?") { Serial.println("=== KLUBHAUS COMMANDS ==="); Serial.println("mode screen - Use screen + LED"); Serial.println("mode led - LED only (screen off)"); Serial.println("log - Set log level (debug/info/warn/error/fatal/none)"); Serial.println("silence - Force silence"); Serial.println("ping - Test connectivity"); Serial.println("reboot - Restart device"); Serial.println("status - Show current state"); Serial.println("============================"); } else if (cmd == "mode screen") { currentDisplayMode = MODE_SCREEN; LOG(LOG_INFO, "SERIAL", "Switched to SCREEN mode"); postStatus("CONFIG", "mode=screen"); flashConfirm("SCREEN MODE"); } else if (cmd == "mode led") { currentDisplayMode = MODE_LED_ONLY; LOG(LOG_INFO, "SERIAL", "Switched to LED mode"); postStatus("CONFIG", "mode=led"); showSilent(); flashConfirm("LED MODE"); } else if (cmd.startsWith("log ")) { String level = cmd.substring(4); level.trim(); setLogLevel(level); Serial.printf("Log level: %s\n", LOG_NAMES[currentLogLevel]); } else if (cmd == "silence") { if (currentState == ALERTING) { setState(SILENT, ""); flashConfirm("SILENCED"); } } else if (cmd == "ping") { postStatus("PONG", "device=klubhaus serial=true"); Serial.println("pong"); } else if (cmd == "reboot") { LOG(LOG_INFO, "SERIAL", "Rebooting..."); ESP.restart(); } else if (cmd == "status") { Serial.printf("State: %s\n", currentState == ALERTING ? "ALERTING" : "SILENT"); Serial.printf("Mode: %s\n", currentDisplayMode == MODE_SCREEN ? "SCREEN" : "LED_ONLY"); Serial.printf("Log: %s\n", LOG_NAMES[currentLogLevel]); Serial.printf("Message: %s\n", currentMessage.c_str()); Serial.printf("Screen: %s\n", screenAvailable ? "OK" : "BROKEN/UNAVAILABLE"); Serial.printf("WiFi: %s\n", WiFi.isConnected() ? "CONNECTED" : "DISCONNECTED"); Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str()); } else { Serial.println("Unknown. Type 'help'"); } } void checkSerial() { // Fast check - avoid String building when possible while (Serial.available()) { char c = Serial.read(); if (c == '\n' || c == '\r') { if (serialBuffer.length() > 0) { serialBuffer.trim(); // Convert to lowercase for matching, but keep original for values String cmdLower = serialBuffer; cmdLower.toLowerCase(); processSerialCommand(cmdLower); serialBuffer = ""; } } else if (c >= 32 && c < 127) { // Printable ASCII only if (serialBuffer.length() < SERIAL_BUFFER_MAX) { serialBuffer += c; } } // Ignore non-printable characters (including backspace for speed) } } // ============== 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 ============== void checkButton() { static bool lastState = HIGH; static unsigned long pressStart = 0; static bool wasPressed = false; static int pressCount = 0; static unsigned long firstPressTime = 0; bool pressed = (digitalRead(BUTTON_PIN) == LOW); if (pressed && !lastState) { pressStart = millis(); wasPressed = true; if (pressCount == 0) { firstPressTime = millis(); pressCount = 1; } else if (millis() - firstPressTime < 500) { pressCount = 2; LOG(LOG_INFO, "BUTTON", "DOUBLE PRESS - TOGLING MODE"); if (currentDisplayMode == MODE_SCREEN) { currentDisplayMode = MODE_LED_ONLY; showSilent(); flashConfirm("LED MODE"); LOG(LOG_INFO, "BUTTON", "Switched to LED mode"); postStatus("CONFIG", "mode=led (button)"); } else { currentDisplayMode = MODE_SCREEN; flashConfirm("SCREEN MODE"); LOG(LOG_INFO, "BUTTON", "Switched to SCREEN mode"); postStatus("CONFIG", "mode=screen (button)"); } } } else if (!pressed && lastState && wasPressed) { unsigned long duration = millis() - pressStart; if (pressCount == 1) { if (millis() - firstPressTime > 500) { if (currentState == ALERTING) { LOG(LOG_INFO, "BUTTON", "Force silence"); setState(SILENT, ""); flashConfirm("SILENCED"); } else { flashConfirm("READY"); } pressCount = 0; } } else if (pressCount == 2) { pressCount = 0; } wasPressed = false; } if (pressCount == 1 && millis() - firstPressTime > 500) { if (currentState == ALERTING) { LOG(LOG_INFO, "BUTTON", "Force silence (single)"); setState(SILENT, ""); flashConfirm("SILENCED"); } else { flashConfirm("READY"); } pressCount = 0; } lastState = pressed; } // ============== MAIN ============== void setup() { Serial.begin(115200); // Don't wait for serial - continue boot immediately // This makes the device responsive even without serial monitor delay(100); // Brief delay for hardware init LOG(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v4.1.0 ==="); LOG(LOG_INFO, "MAIN", "Log level: INFO (set via 'log debug' for verbose)"); pinMode(BUTTON_PIN, INPUT_PULLUP); initLED(); bool forceLedMode = (digitalRead(BUTTON_PIN) == LOW); if (forceLedMode) { LOG(LOG_INFO, "MAIN", "Button held at boot - forcing LED mode"); currentDisplayMode = MODE_LED_ONLY; screenAvailable = false; delay(500); } else { initDisplay(); } initWiFi(); postStatus("SILENT", ""); LOG(LOG_INFO, "MAIN", "READY - Double-press: toggle mode | Serial: 'help'"); } void loop() { checkButton(); checkSerial(); 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)) { LOG(LOG_INFO, "ALERT", "GOT: id=" + id + " msg=[" + message + "]"); if (id != lastProcessedAlertId) { lastProcessedAlertId = id; if (message != "SILENCE") { LOG(LOG_INFO, "ALERT", "TRIGGER: " + message); setState(ALERTING, message); } } else { LOG(LOG_DEBUG, "ALERT", "Duplicate ID, ignored"); } } } // 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)) { LOG(LOG_INFO, "SILENCE", "GOT: id=" + id); if (message.indexOf("silence") >= 0 || message == "SILENCE") { LOG(LOG_INFO, "SILENCE", "ACKNOWLEDGED"); if (currentState == ALERTING) { setState(SILENT, ""); } } } } // Poll admin topic if (now - lastAdminPoll >= ADMIN_POLL_INTERVAL_MS) { lastAdminPoll = now; checkAdminTopic(); } // Yield to allow WiFi stack processing delay(1); }