diff --git a/sketches/doorbell/doorbell.ino b/sketches/doorbell/doorbell.ino index 3236846..9ffcf01 100644 --- a/sketches/doorbell/doorbell.ino +++ b/sketches/doorbell/doorbell.ino @@ -1,604 +1,443 @@ +// ============== KLUBHAUS DOORBELL v3.8.2 ============== +// Fix: Remove ?poll=1 for short-polling, add raw response logging + #include #include -#include #include #include -// ============== CONFIGURATION ============== -const char* ssid = "iot-2GHz"; -const char* password = "lesson-greater"; +// ============== CONFIG ============== +#define WIFI_SSID "iot-2GHz" +#define WIFI_PASS "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"; +// 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 -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 NTFY_SERVER "ntfy.sh" -#define BACKLIGHT_PIN 22 -#define BUTTON_PIN 9 +// 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 COLOR_BLACK 0x0000 -#define COLOR_COCAINE 0xFFFE -#define COLOR_TEAL 0x05F5 -#define COLOR_FUCHSIA 0xFC6E -#define COLOR_MINT 0x674D +#define BUTTON_PIN 9 -#define ALERT_BG_1 COLOR_TEAL -#define ALERT_TEXT_1 COLOR_COCAINE -#define ALERT_BG_2 COLOR_FUCHSIA -#define ALERT_TEXT_2 COLOR_COCAINE +// ============== 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 -#define SCREEN_WIDTH 320 -#define SCREEN_HEIGHT 172 +// ============== 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); -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); +// ============== STATE ============== +enum State { SILENT, ALERTING }; +State currentState = SILENT; +String currentMessage = ""; +String lastProcessedId = ""; +unsigned long lastAlertTime = 0; -enum State { STATE_SILENT, STATE_ALERT }; +// ============== 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 }; -const LogLevel CURRENT_LOG_LEVEL = LOG_DEBUG; +void logString(LogLevel level, const char* component, const char* event, const String& detail) { + const char* levelStr[] = {"DEBUG", "INFO ", "WARN ", "ERROR", "FATAL"}; -State currentState = STATE_SILENT; -State lastPublishedState = STATE_SILENT; + 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)); -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) { + // 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(); - 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 ============== +// 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(BACKLIGHT_PIN, on ? HIGH : LOW); - if (backlightState != on) { - backlightState = on; - logValue(LOG_DEBUG, "BACKLIGHT", "state", on ? 1 : 0); - } + digitalWrite(TFT_BL, on ? HIGH : LOW); + logString(LOG_DEBUG, "BACKLIGHT", on ? "state=1" : "state=0"); } -// ============== EPOCH TIME ============== +void showSilentScreen() { + gfx->fillScreen(COLOR_BLACK); + setBacklight(false); // Power saving in silent mode +} -void initEpochTime() { - logMessage(LOG_INFO, "TIME", "Initializing NTP..."); - configTime(0, 0, "pool.ntp.org", "time.nist.gov"); +void showAlertScreen(const String& message) { + setBacklight(true); - time_t now = 0; - int retries = 0; - while (now < 1000000000 && retries < 30) { + // 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); - 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); - } + showSilentScreen(); } -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); - } +// ============== 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()); } -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); +// ============== 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; } -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; +// 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 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 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(); } -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); +// ============== 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; -void handleButton() { - bool pressed = (digitalRead(BUTTON_PIN) == LOW); + bool pressed = (digitalRead(BUTTON_PIN) == LOW); // Active 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); + 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; } - } - lastButtonState = pressed; + + lastState = pressed; } -// ============== BLINK ============== +// ============== MAIN ============== +void setup() { + Serial.begin(115200); + delay(1000); -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); - } + 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 ==="); } -// ============== TEST ============== +void loop() { + checkButton(); -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); + 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 }