diff --git a/sketches/doorbell/doorbell.ino b/sketches/doorbell/doorbell.ino index ab66c9d..4f8306e 100644 --- a/sketches/doorbell/doorbell.ino +++ b/sketches/doorbell/doorbell.ino @@ -8,12 +8,13 @@ const char* ssid = "iot-2GHz"; const char* password = "lesson-greater"; -const char* COMMAND_POLL_URL = "http://ntfy.sh/ALERT_klubhaus_topic/json"; -const char* SILENCE_POLL_URL = "http://ntfy.sh/SILENCE_klubhaus_topic/json"; +// CRITICAL: Use ?poll=1 for explicit short-poll, no hanging connection +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 COMMAND_POLL_INTERVAL = 30000; +const unsigned long SILENCE_POLL_INTERVAL = 5000; const unsigned long BLINK_DURATION = 180000; const unsigned long BLINK_PERIOD = 1000; const unsigned long ERROR_BACKOFF = 60000; @@ -51,7 +52,7 @@ unsigned long lastSilencePoll = 0; unsigned long nextPollTime = 0; unsigned long blinkStartTime = 0; bool blinkPhase = false; -bool backlightState = false; // Explicit tracking +bool backlightState = false; String lastCommandId = ""; String lastSilenceId = ""; @@ -97,8 +98,12 @@ void logValue(LogLevel level, const char* component, const char* name, long long void logString(LogLevel level, const char* component, const char* name, const String& value) { if (level < CURRENT_LOG_LEVEL) return; char buf[256]; - snprintf(buf, sizeof(buf), "%s=[%s]", name, value.c_str()); - logMessage(level, component, buf); + // Escape newlines for clean logging + String escaped = value; + escaped.replace("\n", "\\n"); + escaped.replace("\r", "\\r"); + snprintf(buf, sizeof(buf), "%s=[%s]", name, escaped.c_str()); + logString(LOG_INFO, component, buf); } void formatIsoTimestamp(char* buf, size_t len) { @@ -118,15 +123,15 @@ void setup() { Serial.begin(115200); delay(3000); - logMessage(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.7 ==="); - logMessage(LOG_INFO, "MAIN", "Features: structured logging, epoch timestamps, backlight fix"); + logMessage(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8 ==="); + logMessage(LOG_INFO, "MAIN", "Fix: NDJSON parsing, ?poll=1, find non-empty messages"); pinMode(BACKLIGHT_PIN, OUTPUT); - setBacklight(false); // Start OFF + setBacklight(false); pinMode(BUTTON_PIN, INPUT_PULLUP); gfx->begin(); - setBacklight(true); // ON for test + setBacklight(true); testDisplay(); delay(300); @@ -189,7 +194,7 @@ void initEpochTime() { logValue(LOG_INFO, "TIME", "epochOffsetMs", (long long)epochOffsetMs); char ts[32]; formatIsoTimestamp(ts, sizeof(ts)); - logString(LOG_INFO, "TIME", "syncedAt", ts); + logString(LOG_INFO, "TIME", "syncedAt", String(ts)); } else { logMessage(LOG_WARN, "TIME", "NTP sync failed — using boot-relative timestamps"); epochOffsetMs = 0; @@ -269,7 +274,7 @@ void publishStatus() { char logBuf[512]; snprintf(logBuf, sizeof(logBuf), "status=%d duration=%lums payload=%s", code, duration, payload.c_str()); - logString(LOG_INFO, "HTTP", "response", logBuf); + logMessage(LOG_INFO, "HTTP", logBuf); http.end(); lastPublishedState = currentState; @@ -286,11 +291,11 @@ void transitionTo(State newState) { case STATE_SILENT: alertMessage = ""; gfx->fillScreen(COLOR_BLACK); - setBacklight(false); // EXPLICIT OFF + setBacklight(false); break; case STATE_ALERT: - setBacklight(true); // EXPLICIT ON + setBacklight(true); blinkStartTime = millis(); blinkPhase = false; drawAlertScreen(ALERT_BG_1, ALERT_TEXT_1); @@ -300,64 +305,96 @@ void transitionTo(State newState) { publishStatus(); } -// ============== TOPIC POLLING ============== +// ============== NDJSON PARSING (CRITICAL FIX) ============== void checkCommandTopic() { - logMessage(LOG_DEBUG, "HTTP", "GET /ALERT_klubhaus_topic/json"); + 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", "No new messages (204 or empty)"); + logMessage(LOG_DEBUG, "HTTP", "Empty response (no messages)"); return; } - StaticJsonDocument<512> doc; - DeserializationError error = deserializeJson(doc, response); - if (error) { - char errBuf[128]; - snprintf(errBuf, sizeof(errBuf), "JSON parse failed: %s", error.c_str()); - logMessage(LOG_ERROR, "JSON", errBuf); - return; + // Parse ALL lines of NDJSON, find first with non-empty message + 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); + + // Check if this line has actual content + String testMsg = message; + testMsg.trim(); + + if (testMsg.length() > 0) { + // Found a message with content! + 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); + + // Deduplication check + if (id == lastCommandId && id.length() > 0) { + logMessage(LOG_DEBUG, "COMMAND", "Duplicate ID — discarded"); + return; + } + lastCommandId = id; + + // Process the message + 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; // Stop after first valid message + } + } 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; } - String id = doc["id"] | ""; - String message = doc["message"] | ""; - long long serverTime = doc["time"] | 0; - - message.trim(); - - char logBuf[512]; - snprintf(logBuf, sizeof(logBuf), "duration=%lums id=[%s] message=[%s] serverTime=%lld", - duration, id.c_str(), message.c_str(), serverTime); - logString(LOG_INFO, "COMMAND", "received", logBuf); - - if (message.length() == 0) { - logMessage(LOG_DEBUG, "COMMAND", "Empty message — discarded"); - return; - } - - 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); + if (!foundMessage) { + logValue(LOG_DEBUG, "COMMAND", "noValidMessageInLines", lineCount); } } void checkSilenceTopic() { - logMessage(LOG_DEBUG, "HTTP", "GET /SILENCE_klubhaus_topic/json"); + logMessage(LOG_DEBUG, "HTTP", "GET /SILENCE_klubhaus_topic/json?poll=1"); unsigned long reqStart = millis(); String response = fetchNtfyJson(SILENCE_POLL_URL, "SILENCE"); @@ -365,35 +402,56 @@ void checkSilenceTopic() { if (response.length() == 0) return; - StaticJsonDocument<512> doc; - DeserializationError error = deserializeJson(doc, response); - if (error) return; + // Parse ALL lines + int lineStart = 0; + int lineCount = 0; - String id = doc["id"] | ""; - String message = doc["message"] | ""; + while (lineStart < response.length()) { + int lineEnd = response.indexOf('\n', lineStart); + if (lineEnd < 0) lineEnd = response.length(); - message.trim(); + String line = response.substring(lineStart, lineEnd); + line.trim(); + lineCount++; - char logBuf[512]; - snprintf(logBuf, sizeof(logBuf), "duration=%lums id=[%s] message=[%s]", - duration, id.c_str(), message.c_str()); - logString(LOG_INFO, "SILENCE", "received", logBuf); + if (line.length() > 0) { + StaticJsonDocument<512> doc; + DeserializationError error = deserializeJson(doc, line); - if (message.length() == 0) return; - if (id == lastSilenceId && id.length() > 0) return; - lastSilenceId = id; + if (!error) { + String id = doc["id"] | ""; + String message = doc["message"] | ""; - if (currentState == STATE_ALERT) { - logMessage(LOG_INFO, "SILENCE", "Force silence — acknowledging"); - transitionTo(STATE_SILENT); - flashConfirm("SILENCED", COLOR_MINT, COLOR_BLACK); + 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; // Stop after first valid + } + } + } + + lineStart = lineEnd + 1; } } String fetchNtfyJson(const char* url, const char* topicName) { HTTPClient http; http.begin(url); - http.setTimeout(8000); + http.setTimeout(12000); // INCREASED: ntfy poll can wait up to 5s, give headroom int code = http.GET(); @@ -430,17 +488,14 @@ String fetchNtfyJson(const char* url, const char* topicName) { String payload = http.getString(); http.end(); - // ntfy JSON streaming: take first line only - int newline = payload.indexOf('\n'); - if (newline > 0) payload = payload.substring(0, newline); - + // DO NOT truncate — return full NDJSON for parsing return payload; } // ============== VISUALS ============== void flashConfirm(const char* text, uint16_t bgColor, uint16_t textColor) { - logString(LOG_DEBUG, "DISPLAY", "flashConfirm", text); + logString(LOG_DEBUG, "DISPLAY", "flashConfirm", String(text)); setBacklight(true); gfx->fillScreen(bgColor); drawCenteredText(text, 3, textColor, bgColor, 70); @@ -492,7 +547,7 @@ void drawCenteredText(const char* text, int textSize, uint16_t textColor, uint16 } void showFatalError(const char* text) { - logString(LOG_FATAL, "DISPLAY", "fatalError", text); + logString(LOG_DEBUG, "DISPLAY", "fatalError", String(text)); setBacklight(true); gfx->fillScreen(COLOR_BLACK); drawCenteredText(text, 3, COLOR_FUCHSIA, COLOR_BLACK, 70); @@ -543,7 +598,6 @@ void updateBlink(unsigned long now) { void testDisplay() { logMessage(LOG_DEBUG, "DISPLAY", "Running color test..."); uint16_t colors[] = {COLOR_TEAL, COLOR_FUCHSIA, COLOR_COCAINE, COLOR_MINT, COLOR_BLACK}; - const char* names[] = {"TEAL", "FUCHSIA", "COCAINE", "MINT", "BLACK"}; for (int i = 0; i < 5; i++) { gfx->fillScreen(colors[i]); delay(100);