From e78bc02cf1eb07323aa87734599b5f05c419bc47 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Thu, 12 Feb 2026 10:57:11 -0800 Subject: [PATCH] snapshot --- sketches/doorbell/doorbell.ino | 410 +++++++++++---------------------- sketches/mise.toml | 2 +- 2 files changed, 141 insertions(+), 271 deletions(-) diff --git a/sketches/doorbell/doorbell.ino b/sketches/doorbell/doorbell.ino index 9ffcf01..9fc33c8 100644 --- a/sketches/doorbell/doorbell.ino +++ b/sketches/doorbell/doorbell.ino @@ -1,5 +1,5 @@ -// ============== KLUBHAUS DOORBELL v3.8.2 ============== -// Fix: Remove ?poll=1 for short-polling, add raw response logging +// ============== KLUBHAUS DOORBELL v3.8.4 ============== +// Fix: Use ?since=all to retrieve cached messages, decode HTTP errors #include #include @@ -11,10 +11,9 @@ #define WIFI_PASS "lesson-greater" // 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 - +#define NTFY_ALERT_TOPIC "ALERT_klubhaus_topic" +#define NTFY_SILENCE_TOPIC "SILENCE_klubhaus_topic" +#define NTFY_STATUS_TOPIC "STATUS_klubhaus_topic" #define NTFY_SERVER "ntfy.sh" // Display pins for ESP32-C6-LCD-1.47 @@ -24,228 +23,158 @@ #define TFT_DC 15 #define TFT_RST 21 #define TFT_BL 22 - #define BUTTON_PIN 9 -// ============== COLORS (Cocaine Chic) ============== +// ============== COLORS ============== #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 // ============== 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_GFX *gfx = new Arduino_ST7789(bus, TFT_RST, 0, true, 172, 320); -// ============== STATE ============== enum State { SILENT, ALERTING }; State currentState = SILENT; String currentMessage = ""; String lastProcessedId = ""; -unsigned long lastAlertTime = 0; -// ============== 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; +const unsigned long POLL_INTERVAL_MS = 3000; // Faster polling for testing // ============== LOGGING ============== enum LogLevel { LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR, LOG_FATAL }; -void logString(LogLevel level, const char* component, const char* event, const String& detail) { - const char* levelStr[] = {"DEBUG", "INFO ", "WARN ", "ERROR", "FATAL"}; - - 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)); - - // 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(); +void logMsg(LogLevel level, const char* component, const String& msg) { + const char* lvl[] = {"DBG", "INF", "WRN", "ERR", "FTL"}; + Serial.printf("[%s] %s: %s\n", lvl[level], component, msg.c_str()); } -// Fix: Separate overload to avoid recursive call bug -void logString(LogLevel level, const char* component, const char* event) { - logString(level, component, event, String("")); +void logMsg(LogLevel level, const char* component, const char* msg) { + logMsg(level, component, String(msg)); +} + +// ============== HTTP ERROR DECODER ============== +String decodeHttpError(int code) { + // HTTPClient error codes (negative values) + if (code == -1) return "CONNECTION_REFUSED/SEND_FAILED"; + 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"; + // Positive = HTTP status + return "HTTP_" + String(code); } // ============== TIME ============== -unsigned long epochOffsetMs = 0; -bool timeSynced = false; - -unsigned long getEpochMillis() { - if (!timeSynced) return millis(); - return epochOffsetMs + millis(); +unsigned long getEpochMs() { + return millis(); // Simplified for now } -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 ============== +// ============== DISPLAY ============== void initDisplay() { pinMode(TFT_BL, OUTPUT); - digitalWrite(TFT_BL, LOW); // Start with backlight off - + digitalWrite(TFT_BL, LOW); gfx->begin(); - gfx->setRotation(1); // Landscape + gfx->setRotation(1); gfx->fillScreen(COLOR_BLACK); - // Color test - logString(LOG_DEBUG, "DISPLAY", "Running color test..."); + // Test pattern 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(); + delay(500); + digitalWrite(TFT_BL, LOW); } void setBacklight(bool on) { digitalWrite(TFT_BL, on ? HIGH : LOW); - logString(LOG_DEBUG, "BACKLIGHT", on ? "state=1" : "state=0"); } -void showSilentScreen() { +void showSilent() { gfx->fillScreen(COLOR_BLACK); - setBacklight(false); // Power saving in silent mode + setBacklight(false); } -void showAlertScreen(const String& message) { +void showAlert(const String& msg) { setBacklight(true); - - // Auto-size text to fit - int textLen = message.length(); - int fontSize = textLen > 20 ? 2 : (textLen > 10 ? 3 : 4); - + int fontSize = msg.length() > 20 ? 2 : (msg.length() > 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); - setBacklight(false); - showSilentScreen(); + gfx->getTextBounds(msg.c_str(), 0, 0, &x1, &y1, &w, &h); + gfx->setCursor((320-w)/2, (172-h)/2); + gfx->println(msg); } // ============== NETWORK ============== void initWiFi() { - logString(LOG_INFO, "WIFI", "Connecting..."); + logMsg(LOG_INFO, "WIFI", "Connecting..."); WiFi.begin(WIFI_SSID, WIFI_PASS); - while (WiFi.status() != WL_CONNECTED) { - delay(500); - Serial.print("."); + delay(500); Serial.print("."); } - Serial.println(); - logString(LOG_INFO, "WIFI", "Connected", WiFi.localIP().toString()); + logMsg(LOG_INFO, "WIFI", "IP: " + WiFi.localIP().toString()); } -// ============== ntfy.sh HTTP ============== +// ============== ntfy.sh WITH ?since=all ============== -// 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; +bool parseNtfyLine(const String& line, String& outId, String& outMessage, String& outEvent) { + if (line.length() == 0 || line.indexOf('{') < 0) return false; - // Check for JSON object start - if (line.indexOf('{') < 0) return false; + // Quick JSON extraction (no library needed for simple fields) + auto extract = [&](const char* key) -> String { + String search = String("\"") + key + "\":\""; + int start = line.indexOf(search); + if (start < 0) { + // Try number format: "key":123 + 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); + }; - // 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(); - } + outId = extract("id"); + outMessage = extract("message"); + outEvent = extract("event"); return outId.length() > 0; } -// 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 - } - +bool fetchNtfy(const char* topic, String& outId, String& outMessage, String& outEvent) { 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); + // KEY FIX: ?since=all forces retrieval of cached messages + // Without this, we only get "open" subscription events + String url = String("https://") + NTFY_SERVER + "/" + topic + "/json?since=all"; - http.setTimeout(10000); // 10s max + logMsg(LOG_DEBUG, "HTTP", "GET " + url); + + http.setTimeout(8000); http.begin(url); - int httpCode = http.GET(); + int code = 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"); - } + if (code != 200) { + logMsg(LOG_ERROR, "HTTP", String(code) + "=" + decodeHttpError(code) + " | " + url); http.end(); return false; } @@ -253,104 +182,71 @@ bool fetchNtfyJson(const char* topic, String& outId, String& outMessage, unsigne 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); + // Log first 150 chars of raw response + String preview = payload.substring(0, min((int)payload.length(), 150)); + preview.replace("\n", "\\n"); + logMsg(LOG_DEBUG, "RAW", preview); - // Parse NDJSON — split by newlines, process each + // Parse all lines, return LAST valid message (newest) + bool found = false; + int lineStart = 0; 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(); + while (lineStart < payload.length()) { + int lineEnd = payload.indexOf('\n', lineStart); + if (lineEnd < 0) lineEnd = payload.length(); - String line = payload.substring(startIdx, endIdx); + String line = payload.substring(lineStart, lineEnd); 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 - } + if (line.length() > 0) { + String id, msg, evt; + if (parseNtfyLine(line, id, msg, evt)) { + logMsg(LOG_DEBUG, "JSON", + String("L") + lineNum + " evt=" + evt + " id=" + id + " msg=" + (msg.length() ? msg : "EMPTY")); - startIdx = endIdx + 1; + // Only count actual messages, not "open" events + if (evt == "message" && msg.length() > 0) { + outId = id; + outMessage = msg; + outEvent = evt; + found = true; + // Continue to get the LAST (newest) message + } + } + } + lineStart = lineEnd + 1; } - logString(LOG_INFO, "NTFY", "linesParsed", String(lineNum)); - return foundMessage; + return found; } -void postStatus(const char* state, const String& message) { +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\":\"" + message + "\",\"timestamp\":" + String(getEpochMillis()) + "}"; - - logString(LOG_DEBUG, "HTTP", "POST", url); + String payload = "{\"state\":\"" + String(state) + "\",\"message\":\"" + msg + "\"}"; 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); + int code = http.POST(payload); + logMsg(LOG_INFO, "STATUS", String("POST ") + code + " " + payload); http.end(); } // ============== 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"); +void setState(State newState, const String& msg) { + if (currentState == newState && currentMessage == msg) return; currentState = newState; - currentMessage = message; + currentMessage = msg; if (newState == ALERTING) { - lastAlertTime = millis(); - showAlertScreen(message); + showAlert(msg); } else { - showSilentScreen(); + showSilent(); } - - // 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; - - bool pressed = (digitalRead(BUTTON_PIN) == LOW); // Active low - - 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; - } - - lastState = pressed; + postStatus(newState == ALERTING ? "ALERTING" : "SILENT", msg); } // ============== MAIN ============== @@ -358,86 +254,60 @@ void setup() { Serial.begin(115200); delay(1000); - logString(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.2 ==="); - logString(LOG_INFO, "MAIN", "Fix: Short-poll, raw response logging"); - - pinMode(BUTTON_PIN, INPUT_PULLUP); + logMsg(LOG_INFO, "MAIN", "=== KLUBHAUS DOORBELL v3.8.4 ==="); + logMsg(LOG_INFO, "MAIN", "Fix: ?since=all for cached messages"); initDisplay(); initWiFi(); - initTime(); - // Initial status postStatus("SILENT", ""); - - logString(LOG_INFO, "MAIN", "=== READY ==="); + logMsg(LOG_INFO, "MAIN", "READY — send FRONT DOOR from web client"); } void loop() { - checkButton(); - unsigned long now = millis(); // Poll ALERT topic if (now - lastAlertPoll >= POLL_INTERVAL_MS) { lastAlertPoll = now; - String id, message; - unsigned long msgTime; + String id, message, event; - unsigned long startMs = millis(); - bool gotMessage = fetchNtfyJson(NTFY_ALERT_TOPIC, id, message, msgTime); - unsigned long duration = millis() - startMs; + if (fetchNtfy(NTFY_ALERT_TOPIC, id, message, event)) { + logMsg(LOG_INFO, "ALERT", "GOT: id=" + id + " msg=[" + message + "]"); - 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 { + if (id != lastProcessedId) { lastProcessedId = id; - - // Process command - if (message.length() > 0 && message != "SILENCE") { - logString(LOG_INFO, "COMMAND", "ALERT trigger", message.c_str()); + if (message != "SILENCE") { + logMsg(LOG_INFO, "ALERT", "TRIGGER: " + message); setState(ALERTING, message); } + } else { + logMsg(LOG_DEBUG, "ALERT", "Duplicate ID, ignored"); } + } else { + logMsg(LOG_DEBUG, "ALERT", "No messages or error"); } } // Poll SILENCE topic (staggered) - if (now - lastSilencePoll >= POLL_INTERVAL_MS + 2500) { // Offset by 2.5s + if (now - lastSilencePoll >= POLL_INTERVAL_MS + 1500) { lastSilencePoll = now; - String id, message; - unsigned long msgTime; + String id, message, event; - unsigned long startMs = millis(); - bool gotMessage = fetchNtfyJson(NTFY_SILENCE_TOPIC, id, message, msgTime); - unsigned long duration = millis() - startMs; + if (fetchNtfy(NTFY_SILENCE_TOPIC, id, message, event)) { + logMsg(LOG_INFO, "SILENCE", "GOT: id=" + id + " msg=[" + message + "]"); - 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"); + logMsg(LOG_INFO, "SILENCE", "ACKNOWLEDGED"); 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 + delay(50); } diff --git a/sketches/mise.toml b/sketches/mise.toml index 40c1f6c..29d9243 100644 --- a/sketches/mise.toml +++ b/sketches/mise.toml @@ -7,4 +7,4 @@ run = "arduino-cli monitor" dir = "{{cwd}}" [tasks.snap] -run = "git add .; git commit -am snapshot" +run = "git add .;git commit -am snapshot"