From 96bcf8c4034406b6cd2229f74e8c4ccbe806ea47 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 12 Feb 2026 21:32:44 -0800 Subject: [PATCH] snapshot --- sketches/doorbell/doorbell.ino | 1018 ++++++++++++-------------------- 1 file changed, 389 insertions(+), 629 deletions(-) diff --git a/sketches/doorbell/doorbell.ino b/sketches/doorbell/doorbell.ino index 83fd14c..c2aa749 100644 --- a/sketches/doorbell/doorbell.ino +++ b/sketches/doorbell/doorbell.ino @@ -1,660 +1,420 @@ -// ============== KLUBHAUS DOORBELL v4.1.0 ============== -// Features: Configurable log levels, faster serial, optimized polling +/* + * KLUBHAUS ALERT DEVICE v3.9.1-minimal + * + * Minimal fix for boot loop: message age filtering + ID deduplication + * Preserves original code structure from v3.8.x + */ #include #include -#include -#include +#include +#include +#include +#include -// ============== CONFIG ============== -#define WIFI_SSID "Dobro Veče" -#define WIFI_PASS "goodnight" +// ============== CONFIGURATION ============== +String wifiSSID = "Dobro Veče"; +String wifiPassword = "goodnight"; -// 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" +const char* ALERT_TOPIC = "https://ntfy.sh/ALERT_klubhaus_topic/json?since=all"; +const char* SILENCE_TOPIC = "https://ntfy.sh/SILENCE_klubhaus_topic/json?since=all"; +const char* ADMIN_TOPIC = "https://ntfy.sh/ADMIN_klubhaus_topic/json?since=all"; +const char* STATUS_TOPIC = "https://ntfy.sh/STATUS_klubhaus_topic"; -// 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 +const unsigned long POLL_INTERVAL_MS = 5000; +const unsigned long BLINK_INTERVAL_MS = 500; +const unsigned long BUTTON_DEBOUNCE_MS = 50; +const unsigned long BUTTON_LONG_PRESS_MS = 1000; +const unsigned long BUTTON_DOUBLE_PRESS_MS = 300; +const unsigned long STALE_MESSAGE_THRESHOLD_S = 600; // 10 minutes (in seconds!) +const unsigned long BOOT_GRACE_PERIOD_MS = 30000; +const unsigned long NTP_SYNC_INTERVAL_MS = 3600000; -// ============== COLORS ============== -#define COLOR_TEAL 0x0BD3D3 -#define COLOR_FUCHSIA 0xF890E7 -#define COLOR_WHITE 0xFFF8FA -#define COLOR_MINT 0x64E8BA -#define COLOR_BLACK 0x0A0A0A +const int SCREEN_WIDTH = 320; +const int SCREEN_HEIGHT = 172; -// ============== 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 uint16_t COLOR_NEON_TEAL = 0x07D7; +const uint16_t COLOR_HOT_FUCHSIA = 0xF81F; +const uint16_t COLOR_COCAINE_WHITE = 0xFFDF; +const uint16_t COLOR_MIDNIGHT_BLACK = 0x0000; +const uint16_t COLOR_MINT_FLASH = 0x67F5; -const char* LOG_NAMES[] = {"DBG", "INF", "WRN", "ERR", "FTL", "OFF"}; +// ============== GLOBALS ============== +TFT_eSPI tft = TFT_eSPI(); +WiFiUDP ntpUDP; +NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); -// 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; +enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_SENDING }; +DeviceState currentState = STATE_SILENT; String currentMessage = ""; -String lastProcessedAlertId = ""; -String lastProcessedAdminId = ""; +bool displayOn = true; +bool ledModeOnly = false; -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; +const int BUTTON_PIN = 0; +unsigned long buttonLastPress = 0; +unsigned long buttonPressStart = 0; +int buttonPressCount = 0; +bool buttonPressed = false; -// ============== FALLBACK MODE ============== -enum DisplayMode { MODE_SCREEN, MODE_LED_ONLY }; -DisplayMode currentDisplayMode = MODE_SCREEN; -bool screenAvailable = true; +unsigned long lastBlinkToggle = 0; +bool blinkState = false; +uint16_t currentAlertColor = COLOR_NEON_TEAL; -// ============== SERIAL COMMANDS ============== -String serialBuffer = ""; -static const size_t SERIAL_BUFFER_MAX = 64; // Limit buffer size +unsigned long bootTime = 0; +bool inBootGracePeriod = true; +bool ntpSynced = false; -// ============== 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()); +HTTPClient http; +unsigned long lastPoll = 0; + +// ============== NEW: DEDUPLICATION ============== +String lastProcessedId = ""; +time_t lastKnownEpoch = 0; + +// ============== SETUP ============== +void setup() { + Serial.begin(115200); + while (!Serial && millis() < 3000) { ; } + + bootTime = millis(); + + Serial.println("[BOOT] KLUBHAUS ALERT v3.9.1-minimal"); + Serial.println("[BOOT] Fix: message age filter + ID dedup"); + + tft.init(); + tft.setRotation(1); + tft.fillScreen(COLOR_MIDNIGHT_BLACK); + tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextSize(2); + tft.drawString("KLUBHAUS", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 20); + tft.setTextSize(1); + tft.drawString("v3.9.1-minimal", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 10); + tft.drawString("Booting...", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 25); + + pinMode(BUTTON_PIN, INPUT_PULLUP); + + WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str()); + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 30) { + delay(500); + attempts++; + } + + timeClient.begin(); + if (timeClient.update()) { + ntpSynced = true; + lastKnownEpoch = timeClient.getEpochTime(); + Serial.println("[BOOT] NTP synced: " + String(lastKnownEpoch)); + } + + Serial.println("[BOOT] Setup complete, grace period: 30s"); } -void logMsg(LogLevel level, const char* component, const char* msg) { - logMsg(level, component, String(msg)); -} +// ============== LOOP ============== +void loop() { + unsigned long now = millis(); -// ============== 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); + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd == "CLEAR_DEDUP") { + lastProcessedId = ""; + Serial.println("[CMD] Deduplication cleared"); } + } + + if (timeClient.update()) { + ntpSynced = true; + lastKnownEpoch = timeClient.getEpochTime(); + } + + if (inBootGracePeriod && (now - bootTime >= BOOT_GRACE_PERIOD_MS)) { + inBootGracePeriod = false; + Serial.println("[BOOT] Grace period ended"); + } + + if (now - lastPoll >= POLL_INTERVAL_MS) { + lastPoll = now; + if (WiFi.status() == WL_CONNECTED && ntpSynced) { + pollTopic(ALERT_TOPIC, handleAlertMessage, "ALERT"); + pollTopic(SILENCE_TOPIC, handleSilenceMessage, "SILENCE"); + pollTopic(ADMIN_TOPIC, handleAdminMessage, "ADMIN"); + } + } + + handleButton(); + updateDisplay(); + + delay(10); } -void pulseLED(int durationMs) { - unsigned long start = millis(); - while (millis() - start < durationMs) { - setLED(true); - delay(100); - setLED(false); - delay(100); +// ============== POLLING ============== +void pollTopic(const char* url, void (*handler)(const String&), const char* topicName) { + http.begin(url); + http.setTimeout(10000); + + int httpCode = http.GET(); + if (httpCode == HTTP_CODE_OK) { + String response = http.getString(); + if (response.length() > 0) { + parseMessages(response, topicName, handler); } + } + http.end(); +} + +// ============== PARSING WITH AGE FILTER & DEDUP ============== +void parseMessages(String& response, const char* topicName, void (*handler)(const String&)) { + // Grace period: ignore everything + if (inBootGracePeriod) { + return; + } + + // No time sync: can't validate, skip to be safe + if (!ntpSynced || lastKnownEpoch == 0) { + return; + } + + int lineStart = 0; + while (lineStart < response.length()) { + int lineEnd = response.indexOf('\n', lineStart); + if (lineEnd == -1) lineEnd = response.length(); + + String line = response.substring(lineStart, lineEnd); + line.trim(); + + if (line.length() > 0 && line.indexOf('{') >= 0) { + StaticJsonDocument<1024> doc; + DeserializationError err = deserializeJson(doc, line); + + if (!err) { + const char* msgId = doc["id"]; + const char* message = doc["message"]; + time_t msgTime = doc["time"] | 0; // ntfy sends epoch SECONDS + + if (message && strlen(message) > 0) { + String msgStr = String(message); + String idStr = msgId ? String(msgId) : ""; + + // DEDUP: skip if same ID already processed + if (idStr.length() > 0 && idStr == lastProcessedId) { + Serial.println("[DEDUP] Skipping duplicate: " + idStr.substring(0, 8)); + lineStart = lineEnd + 1; + continue; + } + + // AGE CHECK: compare message time to current wall clock + if (msgTime > 0) { + time_t ageSeconds = lastKnownEpoch - msgTime; + + if (ageSeconds > (time_t)STALE_MESSAGE_THRESHOLD_S) { + Serial.println("[STALE] Rejecting " + String(ageSeconds) + "s old message: " + msgStr.substring(0, 20)); + lineStart = lineEnd + 1; + continue; + } + } + + // VALID: process and record ID + Serial.println("[" + String(topicName) + "] Processing: " + msgStr.substring(0, 40)); + + if (idStr.length() > 0) { + lastProcessedId = idStr; + } + + handler(msgStr); + } + } + } + lineStart = lineEnd + 1; + } +} + +// ============== HANDLERS ============== +void handleAlertMessage(const String& message) { + if (currentState == STATE_ALERTING && currentMessage == message) return; + + currentState = STATE_ALERTING; + currentMessage = message; + displayOn = true; + + publishStatus("ALERTING", message); + + if (!ledModeOnly) { + drawAlertScreen(); + } +} + +void handleSilenceMessage(const String& message) { + currentState = STATE_SILENT; + currentMessage = ""; + displayOn = true; + + publishStatus("SILENT", "silenced"); + + if (!ledModeOnly) { + drawSilentScreen(); + } +} + +void handleAdminMessage(const String& message) { + Serial.println("[ADMIN] Command: " + message); + + if (message == "MODE_SCREEN") { + ledModeOnly = false; + if (currentState == STATE_SILENT) drawSilentScreen(); + else drawAlertScreen(); + } + else if (message == "MODE_LED") { + ledModeOnly = true; + tft.fillScreen(COLOR_MIDNIGHT_BLACK); + } + else if (message == "SILENCE") { + handleSilenceMessage("admin"); + } + else if (message == "PING") { + publishStatus("PONG", "ping"); + } + else if (message == "REBOOT") { + Serial.println("[ADMIN] Rebooting..."); + delay(100); + ESP.restart(); + } +} + +// ============== STATUS ============== +void publishStatus(const char* state, const String& message) { + if (WiFi.status() != WL_CONNECTED) return; + + StaticJsonDocument<256> doc; + doc["state"] = state; + doc["message"] = message; + doc["timestamp"] = (long long)timeClient.getEpochTime() * 1000LL; + doc["led_mode"] = ledModeOnly; + + String payload; + serializeJson(doc, payload); + + HTTPClient statusHttp; + statusHttp.begin(STATUS_TOPIC); + statusHttp.addHeader("Content-Type", "application/json"); + statusHttp.POST(payload); + statusHttp.end(); } // ============== 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; +void updateDisplay() { + if (ledModeOnly) { + if (currentState == STATE_ALERTING) { + unsigned long now = millis(); + if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { + lastBlinkToggle = now; + blinkState = !blinkState; + digitalWrite(2, blinkState ? HIGH : LOW); + } } + 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(); - + if (currentState == STATE_ALERTING) { 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"); - } - } + if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { + lastBlinkToggle = now; + blinkState = !blinkState; + currentAlertColor = blinkState ? COLOR_NEON_TEAL : COLOR_HOT_FUCHSIA; + drawAlertScreen(); + digitalWrite(2, blinkState ? HIGH : LOW); } - - // 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); + } +} + +void drawAlertScreen() { + if (ledModeOnly) return; + + tft.fillScreen(COLOR_MIDNIGHT_BLACK); + tft.setTextColor(currentAlertColor, COLOR_MIDNIGHT_BLACK); + + int textSize = 4; + if (currentMessage.length() > 10) textSize = 3; + if (currentMessage.length() > 20) textSize = 2; + + tft.setTextSize(textSize); + tft.setTextDatum(MC_DATUM); + + if (currentMessage.length() > 15) { + int mid = currentMessage.length() / 2; + int spacePos = currentMessage.lastIndexOf(' ', mid); + if (spacePos < 0) spacePos = mid; + tft.drawString(currentMessage.substring(0, spacePos), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 15); + tft.drawString(currentMessage.substring(spacePos + 1), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 15); + } else { + tft.drawString(currentMessage, SCREEN_WIDTH/2, SCREEN_HEIGHT/2); + } + + tft.setTextSize(1); + tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK); + tft.setTextDatum(TL_DATUM); + tft.drawString("ALERT", 5, 5); + tft.setTextDatum(TR_DATUM); + tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5); +} + +void drawSilentScreen() { + if (ledModeOnly) return; + + tft.fillScreen(COLOR_MIDNIGHT_BLACK); + tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK); + tft.setTextSize(2); + tft.setTextDatum(MC_DATUM); + tft.drawString("SILENT", SCREEN_WIDTH/2, SCREEN_HEIGHT/2); + + tft.setTextSize(1); + tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK); + tft.setTextDatum(TL_DATUM); + tft.drawString("KLUBHAUS", 5, 5); + tft.setTextDatum(TR_DATUM); + tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5); +} + +// ============== BUTTON ============== +void handleButton() { + bool reading = (digitalRead(BUTTON_PIN) == LOW); + + if (reading != buttonPressed) { + if (reading) { + buttonPressStart = millis(); + + if (millis() - buttonLastPress < BUTTON_DOUBLE_PRESS_MS) { + buttonPressCount++; + } else { + buttonPressCount = 1; + } + buttonLastPress = millis(); + + if (buttonPressCount == 2) { + ledModeOnly = !ledModeOnly; + buttonPressCount = 0; + if (ledModeOnly) { + tft.fillScreen(COLOR_MIDNIGHT_BLACK); + } else { + if (currentState == STATE_SILENT) drawSilentScreen(); + else drawAlertScreen(); + } + return; + } + } else { + unsigned long duration = millis() - buttonPressStart; + + if (duration >= BUTTON_DEBOUNCE_MS && duration < BUTTON_LONG_PRESS_MS) { + if (currentState == STATE_ALERTING) { + handleSilenceMessage("button"); + } else { + handleAlertMessage("TEST ALERT"); + } + } + } + + buttonPressed = reading; + } }