diff --git a/sketches/doorbell-touch/doorbell-touch.ino b/sketches/doorbell-touch/doorbell-touch.ino index 30a4b3f..df6dc56 100644 --- a/sketches/doorbell-touch/doorbell-touch.ino +++ b/sketches/doorbell-touch/doorbell-touch.ino @@ -1,16 +1,12 @@ /* - * Doorbell Touch — Waveshare ESP32-S3-Touch-LCD-4.3 (non-B) + * KLUBHAUS ALERT v4.1 — Touch Edition * - * Ported from ESP32-C6 + 1.44" TFT_eSPI version. + * Target: Waveshare ESP32-S3-Touch-LCD-4.3 (non-B) * - * Features: - * - Polls ntfy.sh topics (ALERT, SILENCE, ADMIN, STATUS) - * - 800×480 RGB display with blinking neon teal / hot fuchsia alerts - * - GT911 touch: tap to silence, double-tap to wake status screen - * - Backlight completely OFF when not alerting - * - Multi-WiFi support - * - NTP time sync, message age filtering, deduplication - * - 30-second boot grace period ignores stale messages + * v4.1 fixes: + * - Fixed variadic lambda crash in drawStatusScreen + * - Single tap for all interactions + * - DEBUG_MODE with _test topic suffix */ #include @@ -24,50 +20,69 @@ #include // ===================================================================== -// WiFi — add as many networks as you like +// DEBUG MODE — set to true to use _test topic suffix +// ===================================================================== +#define DEBUG_MODE 1 + +// ===================================================================== +// WiFi // ===================================================================== struct WiFiCred { const char *ssid; const char *pass; }; - WiFiCred wifiNetworks[] = { { "Dobro Veče", "goodnight" }, { "iot-2GHz", "lesson-greater" }, }; const int NUM_WIFI = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); - WiFiMulti wifiMulti; // ===================================================================== -// ntfy.sh Topics +// ntfy.sh Topics — since=10s covers our 5s poll interval // ===================================================================== -#define NTFY_BASE "https://ntfy.sh" -#define ALERT_TOPIC "ALERT_klubhaus_topic" -#define SILENCE_TOPIC "SILENCE_klubhaus_topic" -#define ADMIN_TOPIC "ADMIN_klubhaus_topic" -#define STATUS_TOPIC "STATUS_klubhaus_topic" +#define NTFY_BASE "https://ntfy.sh" +#if DEBUG_MODE + #define TOPIC_SUFFIX "_test" +#else + #define TOPIC_SUFFIX "" +#endif + +#define ALERT_URL NTFY_BASE "/ALERT_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define SILENCE_URL NTFY_BASE "/SILENCE_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define ADMIN_URL NTFY_BASE "/ADMIN_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define STATUS_URL NTFY_BASE "/STATUS_klubhaus_topic" TOPIC_SUFFIX +// // ===================================================================== // Timing // ===================================================================== -#define POLL_INTERVAL_MS 5000 -#define BOOT_GRACE_MS 30000 -#define BLINK_INTERVAL_MS 500 -#define ALERT_TIMEOUT_SEC 300 // 5 min auto-silence -#define MSG_MAX_AGE_SEC 60 // ignore messages older than 60s -#define WAKE_DISPLAY_MS 5000 // show status screen for 5s -#define DOUBLE_TAP_WINDOW_MS 500 // max gap between taps -#define TOUCH_DEBOUNCE_MS 250 +#define POLL_INTERVAL_MS 5000 +#define BLINK_INTERVAL_MS 500 +#define STALE_MSG_THRESHOLD_S 600 +#define NTP_SYNC_INTERVAL_MS 3600000 +#define ALERT_TIMEOUT_MS 300000 +#define WAKE_DISPLAY_MS 5000 +#define TOUCH_DEBOUNCE_MS 300 + +#if DEBUG_MODE + #define BOOT_GRACE_MS 5000 +#else + #define BOOT_GRACE_MS 30000 +#endif // ===================================================================== -// RGB565 Colors +// Screen & Colors // ===================================================================== -#define COL_BLACK 0x0000 -#define COL_WHITE 0xFFFF -#define COL_NEON_TEAL 0x07FA // #00FFD0 -#define COL_HOT_PINK 0xF814 // #FF00A0 -#define COL_DARK_GRAY 0x2104 -#define COL_GREEN 0x07E0 -#define COL_RED 0xF800 -#define COL_YELLOW 0xFFE0 +#define SCREEN_WIDTH 800 +#define SCREEN_HEIGHT 480 + +#define COL_NEON_TEAL 0x07D7 +#define COL_HOT_FUCHSIA 0xF81F +#define COL_WHITE 0xFFDF +#define COL_BLACK 0x0000 +#define COL_MINT 0x67F5 +#define COL_DARK_GRAY 0x2104 +#define COL_GREEN 0x07E0 +#define COL_RED 0xF800 +#define COL_YELLOW 0xFFE0 // ===================================================================== // I2C @@ -76,17 +91,16 @@ WiFiMulti wifiMulti; #define I2C_SCL 9 // ===================================================================== -// CH422G IO Expander (direct Wire — no library needed) +// CH422G IO Expander // ===================================================================== -#define CH422G_SYS 0x24 // system / mode register -#define CH422G_OUT 0x38 // output register -#define CH422G_OE 0x01 // enable push-pull outputs +#define CH422G_SYS 0x24 +#define CH422G_OUT 0x38 +#define CH422G_OE 0x01 +#define IO_TP_RST (1 << 1) +#define IO_LCD_BL (1 << 2) +#define IO_LCD_RST (1 << 3) -#define IO_TP_RST (1 << 1) // touch reset -#define IO_LCD_BL (1 << 2) // backlight -#define IO_LCD_RST (1 << 3) // LCD reset - -static uint8_t ioState = 0; // shadow of CH422G output register +static uint8_t ioState = 0; void ch422g_write(uint8_t addr, uint8_t data) { Wire.beginTransmission(addr); @@ -98,18 +112,16 @@ void setBacklight(bool on) { if (on) ioState |= IO_LCD_BL; else ioState &= ~IO_LCD_BL; ch422g_write(CH422G_OUT, ioState); - Serial.printf("Backlight %s\n", on ? "ON" : "OFF"); } // ===================================================================== -// GT911 Touch (direct I2C — avoids driver_ng conflict) +// GT911 Touch (direct I2C) // ===================================================================== #define GT911_ADDR1 0x14 #define GT911_ADDR2 0x5D -#define GT911_IRQ 4 -static uint8_t gt911Addr = 0; -static bool touchAvailable = false; +static uint8_t gt911Addr = 0; +static bool touchAvailable = false; void gt911_writeReg(uint16_t reg, uint8_t val) { Wire.beginTransmission(gt911Addr); @@ -120,27 +132,22 @@ void gt911_writeReg(uint16_t reg, uint8_t val) { } bool gt911_init() { - pinMode(GT911_IRQ, INPUT); - - // Probe both possible addresses for (uint8_t addr : { GT911_ADDR1, GT911_ADDR2 }) { Wire.beginTransmission(addr); if (Wire.endTransmission() == 0) { gt911Addr = addr; touchAvailable = true; - Serial.printf("GT911 found at 0x%02X\n", addr); + Serial.printf("[HW] GT911 at 0x%02X\n", addr); return true; } } - Serial.println("GT911 not found — touch disabled"); + Serial.println("[HW] GT911 not found"); return false; } -// Returns true if a touch point was read bool gt911_read(int16_t *x, int16_t *y) { if (!touchAvailable) return false; - // Read status register 0x814E Wire.beginTransmission(gt911Addr); Wire.write(0x81); Wire.write(0x4E); Wire.endTransmission(false); @@ -148,70 +155,77 @@ bool gt911_read(int16_t *x, int16_t *y) { if (!Wire.available()) return false; uint8_t status = Wire.read(); - bool ready = status & 0x80; - uint8_t pts = status & 0x0F; + bool ready = status & 0x80; + uint8_t pts = status & 0x0F; - // Always clear the status flag gt911_writeReg(0x814E, 0x00); if (!ready || pts == 0) return false; - // Read first touch point from 0x8150 (need 4 bytes: xl, xh, yl, yh) Wire.beginTransmission(gt911Addr); Wire.write(0x81); Wire.write(0x50); Wire.endTransmission(false); Wire.requestFrom(gt911Addr, (uint8_t)4); if (Wire.available() < 4) return false; - uint8_t xl = Wire.read(); - uint8_t xh = Wire.read(); - uint8_t yl = Wire.read(); - uint8_t yh = Wire.read(); - + uint8_t xl = Wire.read(), xh = Wire.read(); + uint8_t yl = Wire.read(), yh = Wire.read(); *x = xl | (xh << 8); *y = yl | (yh << 8); return true; } // ===================================================================== -// RGB Display — correct pin map for 4.3 non-B +// RGB Display — correct pins for 4.3 non-B // ===================================================================== Arduino_ESP32RGBPanel *rgbpanel = new Arduino_ESP32RGBPanel( - 5 /* DE */, 3 /* VSYNC */, 46 /* HSYNC */, 7 /* PCLK */, - 1 /* R0 */, 2 /* R1 */, 42 /* R2 */, 41 /* R3 */, 40 /* R4 */, - 39 /* G0 */, 0 /* G1 */, 45 /* G2 */, 48 /* G3 */, 47 /* G4 */, 21 /* G5 */, - 14 /* B0 */, 38 /* B1 */, 18 /* B2 */, 17 /* B3 */, 10 /* B4 */, - 0, 8, 4, 8, // hsync: pol, fp, pw, bp - 0, 8, 4, 8, // vsync: pol, fp, pw, bp - 1, 16000000 // pclk_neg, speed + 5, 3, 46, 7, + 1, 2, 42, 41, 40, + 39, 0, 45, 48, 47, 21, + 14, 38, 18, 17, 10, + 0, 8, 4, 8, + 0, 8, 4, 8, + 1, 16000000 ); -Arduino_RGB_Display *gfx = new Arduino_RGB_Display(800, 480, rgbpanel, 0, true); +Arduino_RGB_Display *gfx = new Arduino_RGB_Display( + SCREEN_WIDTH, SCREEN_HEIGHT, rgbpanel, 0, true +); // ===================================================================== // NTP // ===================================================================== WiFiUDP ntpUDP; -NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000); +NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); // ===================================================================== -// Application State +// State // ===================================================================== -enum State { ST_IDLE, ST_ALERTING, ST_SILENCED, ST_WAKE }; +enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_WAKE }; +DeviceState currentState = STATE_SILENT; -State state = ST_IDLE; -String alertMessage = ""; -String lastMsgId = ""; -unsigned long bootTime = 0; -unsigned long lastPoll = 0; -unsigned long alertStart = 0; -unsigned long blinkTimer = 0; -bool blinkPhase = false; -unsigned long wakeStart = 0; +String currentMessage = ""; +String lastAlertId = ""; +String lastSilenceId = ""; +String lastAdminId = ""; +time_t lastKnownEpoch = 0; +bool ntpSynced = false; -// Touch double-tap tracking -unsigned long lastTapMs = 0; -int tapCount = 0; +unsigned long bootTime = 0; +bool inBootGrace = true; + +unsigned long lastPoll = 0; +unsigned long lastBlinkToggle = 0; +bool blinkState = false; +unsigned long alertStart = 0; +unsigned long wakeStart = 0; + +// Deferred status publishing — avoids nested HTTPS connections +bool pendingStatus = false; +String pendingStatusState = ""; +String pendingStatusMsg = ""; + +HTTPClient http; // ===================================================================== // Drawing Helpers @@ -219,192 +233,339 @@ int tapCount = 0; void drawCentered(const char *txt, int y, int sz, uint16_t col) { gfx->setTextSize(sz); gfx->setTextColor(col); - int w = strlen(txt) * 6 * sz; // approximate pixel width - gfx->setCursor(max(0, (800 - w) / 2), y); + int w = strlen(txt) * 6 * sz; + gfx->setCursor(max(0, (SCREEN_WIDTH - w) / 2), y); gfx->print(txt); } -void drawAlertScreen(bool phase) { - uint16_t bg = phase ? COL_NEON_TEAL : COL_HOT_PINK; - uint16_t fg = phase ? COL_BLACK : COL_WHITE; +// Safe line drawing — replaces the variadic lambda that caused crashes +void drawInfoLine(int x, int y, uint16_t col, const char *text) { + gfx->setTextSize(2); + gfx->setTextColor(col); + gfx->setCursor(x, y); + gfx->print(text); +} + +void drawHeaderBar(uint16_t col, const char *label) { + gfx->setTextSize(2); + gfx->setTextColor(col); + gfx->setCursor(10, 10); + gfx->print(label); + + String t = timeClient.getFormattedTime(); + gfx->setCursor(SCREEN_WIDTH - t.length() * 12 - 10, 10); + gfx->print(t); +} + +void drawAlertScreen() { + uint16_t bg = blinkState ? COL_NEON_TEAL : COL_HOT_FUCHSIA; + uint16_t fg = blinkState ? COL_BLACK : COL_WHITE; gfx->fillScreen(bg); + drawHeaderBar(fg, "ALERT"); - drawCentered("! DOORBELL !", 60, 6, fg); + int sz = 8; + if (currentMessage.length() > 10) sz = 6; + if (currentMessage.length() > 20) sz = 4; + if (currentMessage.length() > 35) sz = 3; - if (alertMessage.length() > 0) { - gfx->setTextSize(3); - gfx->setTextColor(fg); - // Simple word-wrap: just let it overflow for now — message is usually short - gfx->setCursor(40, 220); - gfx->print(alertMessage.c_str()); + if (currentMessage.length() > 15) { + int mid = currentMessage.length() / 2; + int sp = currentMessage.lastIndexOf(' ', mid); + if (sp < 0) sp = mid; + String l1 = currentMessage.substring(0, sp); + String l2 = currentMessage.substring(sp + 1); + int lh = 8 * sz + 10; + int y1 = (SCREEN_HEIGHT - lh * 2) / 2; + drawCentered(l1.c_str(), y1, sz, fg); + drawCentered(l2.c_str(), y1 + lh, sz, fg); + } else { + drawCentered(currentMessage.c_str(), + (SCREEN_HEIGHT - 8 * sz) / 2, sz, fg); } - drawCentered("TAP ANYWHERE TO SILENCE", 430, 2, fg); + drawCentered("TAP TO SILENCE", SCREEN_HEIGHT - 35, 2, fg); } void drawStatusScreen() { gfx->fillScreen(COL_BLACK); + drawHeaderBar(COL_MINT, "KLUBHAUS"); - drawCentered("DOORBELL MONITOR", 20, 3, COL_NEON_TEAL); - gfx->drawFastHLine(50, 65, 700, COL_DARK_GRAY); + drawCentered("MONITORING", 140, 5, COL_WHITE); - gfx->setTextSize(2); - int y = 90; - auto line = [&](uint16_t col, const char *fmt, ...) { - char buf[128]; - va_list ap; - va_start(ap, fmt); - vsnprintf(buf, sizeof(buf), fmt, ap); - va_end(ap); - gfx->setTextColor(col); - gfx->setCursor(60, y); - gfx->print(buf); - y += 36; - }; + char buf[128]; + int y = 240; + int spacing = 32; + int x = 60; - line(COL_WHITE, "WiFi: %s", WiFi.isConnected() ? WiFi.SSID().c_str() : "DISCONNECTED"); - line(COL_WHITE, "IP: %s", WiFi.isConnected() ? WiFi.localIP().toString().c_str() : "---"); - line(COL_WHITE, "RSSI: %d dBm", WiFi.RSSI()); - line(COL_WHITE, "Heap: %d KB", ESP.getFreeHeap() / 1024); - line(COL_WHITE, "PSRAM: %d MB free", ESP.getFreePsram() / (1024 * 1024)); - line(COL_WHITE, "Time: %s UTC", timeClient.getFormattedTime().c_str()); - line(COL_WHITE, "Up: %lu min", (millis() - bootTime) / 60000); + snprintf(buf, sizeof(buf), "WiFi: %s (%d dBm)", + WiFi.isConnected() ? WiFi.SSID().c_str() : "DOWN", WiFi.RSSI()); + drawInfoLine(x, y, COL_WHITE, buf); + y += spacing; - y += 10; - const char *stStr = - state == ST_IDLE ? "IDLE — Monitoring" : - state == ST_SILENCED ? "SILENCED" : - state == ST_ALERTING ? "ALERTING" : "AWAKE"; - uint16_t stCol = - state == ST_IDLE ? COL_GREEN : - state == ST_SILENCED ? COL_YELLOW : - state == ST_ALERTING ? COL_RED : COL_NEON_TEAL; - line(stCol, "State: %s", stStr); + snprintf(buf, sizeof(buf), "IP: %s", + WiFi.isConnected() ? WiFi.localIP().toString().c_str() : "---"); + drawInfoLine(x, y, COL_WHITE, buf); + y += spacing; - if (alertMessage.length() > 0) { - y += 10; - line(COL_DARK_GRAY, "Last: %s", alertMessage.c_str()); + snprintf(buf, sizeof(buf), "Up: %lu min Heap: %d KB", + (millis() - bootTime) / 60000, ESP.getFreeHeap() / 1024); + drawInfoLine(x, y, COL_WHITE, buf); + y += spacing; + + snprintf(buf, sizeof(buf), "NTP: %s UTC", + ntpSynced ? timeClient.getFormattedTime().c_str() : "not synced"); + drawInfoLine(x, y, COL_WHITE, buf); + y += spacing; + + snprintf(buf, sizeof(buf), "State: %s", + currentState == STATE_SILENT ? "SILENT" : + currentState == STATE_ALERTING ? "ALERTING" : "WAKE"); + uint16_t stCol = currentState == STATE_ALERTING ? COL_RED : + currentState == STATE_SILENT ? COL_GREEN : COL_NEON_TEAL; + drawInfoLine(x, y, stCol, buf); + y += spacing; + + if (currentMessage.length() > 0) { + snprintf(buf, sizeof(buf), "Last: %.40s", currentMessage.c_str()); + drawInfoLine(x, y, COL_DARK_GRAY, buf); + y += spacing; } - drawCentered("tap to dismiss | auto-sleeps in 5s", 445, 1, COL_DARK_GRAY); + #if DEBUG_MODE + drawInfoLine(x, y, COL_YELLOW, "DEBUG MODE - _test topics"); + #endif + + drawCentered("tap to dismiss | auto-sleeps in 5s", + SCREEN_HEIGHT - 25, 1, COL_DARK_GRAY); } // ===================================================================== -// ntfy.sh Communication +// Status Publishing — deferred to avoid nested HTTPS // ===================================================================== -void ntfyPublish(const char *topic, const char *msg) { - if (!WiFi.isConnected()) return; - HTTPClient http; - http.begin(String(NTFY_BASE) + "/" + topic); - http.addHeader("Content-Type", "text/plain"); - http.POST(msg); +void queueStatus(const char *st, const String &msg) { + pendingStatus = true; + pendingStatusState = st; + pendingStatusMsg = msg; + Serial.printf("[STATUS] Queued: %s — %s\n", st, msg.c_str()); +} + +void flushStatus() { + if (!pendingStatus || !WiFi.isConnected()) return; + pendingStatus = false; + + JsonDocument doc; + doc["state"] = pendingStatusState; + doc["message"] = pendingStatusMsg; + doc["timestamp"] = (long long)timeClient.getEpochTime() * 1000LL; + + String payload; + serializeJson(doc, payload); + + HTTPClient statusHttp; + statusHttp.begin(STATUS_URL); + statusHttp.addHeader("Content-Type", "application/json"); + int code = statusHttp.POST(payload); + statusHttp.end(); + + Serial.printf("[STATUS] Sent (%d): %s\n", code, pendingStatusState.c_str()); + + // Free memory immediately + pendingStatusState = ""; + pendingStatusMsg = ""; +} + +// ===================================================================== +// Message Parsing — per-topic dedup +// ===================================================================== +void parseMessages(String &response, const char *topicName, + void (*handler)(const String&), + String &lastId) +{ + if (inBootGrace) return; + if (!ntpSynced || lastKnownEpoch == 0) return; + + int lineStart = 0; + while (lineStart < (int)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) { + JsonDocument doc; + if (!deserializeJson(doc, line)) { + const char *event = doc["event"]; + const char *msgId = doc["id"]; + const char *message = doc["message"]; + time_t msgTime = doc["time"] | 0; + + if (event && strcmp(event, "message") != 0) { + lineStart = lineEnd + 1; + continue; + } + + if (message && strlen(message) > 0) { + String msgStr = String(message); + String idStr = msgId ? String(msgId) : ""; + + // Per-topic dedup + if (idStr.length() > 0 && idStr == lastId) { + lineStart = lineEnd + 1; + continue; + } + + // Age check + if (msgTime > 0) { + time_t age = lastKnownEpoch - msgTime; + if (age > (time_t)STALE_MSG_THRESHOLD_S) { + Serial.printf("[%s] Stale (%lds): %.30s\n", + topicName, (long)age, msgStr.c_str()); + lineStart = lineEnd + 1; + continue; + } + } + + Serial.printf("[%s] %.50s\n", topicName, msgStr.c_str()); + if (idStr.length() > 0) lastId = idStr; + handler(msgStr); + } + } + } + lineStart = lineEnd + 1; + } +} + +// ===================================================================== +// ntfy Polling +// ===================================================================== +void pollTopic(const char *url, + void (*handler)(const String&), + const char *topicName, + String &lastId) +{ + http.begin(url); + http.setTimeout(10000); + int code = http.GET(); + if (code == HTTP_CODE_OK) { + String response = http.getString(); + if (response.length() > 0) + parseMessages(response, topicName, handler, lastId); + } http.end(); } -// Poll a topic; returns the message body of the newest unseen message, or "" -String ntfyPoll(const char *topic) { - if (!WiFi.isConnected()) return ""; +// ===================================================================== +// Message Handlers +// ===================================================================== +void handleAlertMessage(const String &message) { + if (currentState == STATE_ALERTING && currentMessage == message) return; - HTTPClient http; - String url = String(NTFY_BASE) + "/" + topic + "/json?poll=1&since=5s"; - http.begin(url); - http.setTimeout(5000); + currentState = STATE_ALERTING; + currentMessage = message; + alertStart = millis(); + blinkState = false; + lastBlinkToggle = millis(); - int code = http.GET(); - String result = ""; + setBacklight(true); + drawAlertScreen(); + queueStatus("ALERTING", message); + Serial.printf("-> ALERTING: %s\n", message.c_str()); +} - if (code == 200) { - String body = http.getString(); +void handleSilenceMessage(const String &message) { + currentState = STATE_SILENT; + currentMessage = ""; + setBacklight(false); + queueStatus("SILENT", "silenced"); + Serial.println("-> SILENT"); +} - // ntfy returns newline-delimited JSON — grab the last line - int nl = body.lastIndexOf('\n', body.length() - 2); - String last = (nl >= 0) ? body.substring(nl + 1) : body; - last.trim(); +void handleAdminMessage(const String &message) { + Serial.printf("[ADMIN] %s\n", message.c_str()); - if (last.length() > 2) { - JsonDocument doc; - if (!deserializeJson(doc, last) && doc["event"] == "message") { - String id = doc["id"].as(); - long ts = doc["time"].as(); + if (message == "SILENCE") { + handleSilenceMessage("admin"); + } + else if (message == "PING") { + queueStatus("PONG", "ping"); + } + else if (message == "test") { + handleAlertMessage("TEST ALERT"); + } + else if (message == "status") { + char buf[256]; + snprintf(buf, sizeof(buf), + "State:%s WiFi:%s RSSI:%d Heap:%dKB Up:%lus", + currentState == STATE_SILENT ? "SILENT" : + currentState == STATE_ALERTING ? "ALERT" : "WAKE", + WiFi.SSID().c_str(), WiFi.RSSI(), + ESP.getFreeHeap() / 1024, (millis() - bootTime) / 1000); + queueStatus("STATUS", buf); + } + else if (message == "wake") { + currentState = STATE_WAKE; + wakeStart = millis(); + setBacklight(true); + drawStatusScreen(); + } + else if (message == "REBOOT") { + queueStatus("REBOOTING", "admin"); + delay(200); + ESP.restart(); + } +} - // Dedup - if (id != lastMsgId) { - // Age check - long now = timeClient.getEpochTime(); - if (now <= 0 || (now - ts) <= MSG_MAX_AGE_SEC) { - lastMsgId = id; - result = doc["message"].as(); - } - } +// ===================================================================== +// Touch — trigger on finger DOWN only, not while held +// ===================================================================== +void handleTouch() { + int16_t tx, ty; + bool touching = gt911_read(&tx, &ty); + + static bool wasTouching = false; + static unsigned long lastAction = 0; + unsigned long now = millis(); + + // Only act on the transition: NOT touching → touching (new finger down) + if (touching && !wasTouching) { + if (now - lastAction >= TOUCH_DEBOUNCE_MS) { + lastAction = now; + + Serial.printf("[TOUCH] x=%d y=%d state=%d\n", tx, ty, currentState); + + switch (currentState) { + case STATE_ALERTING: + handleSilenceMessage("touch"); + break; + + case STATE_SILENT: + currentState = STATE_WAKE; + wakeStart = now; + setBacklight(true); + drawStatusScreen(); + Serial.println("-> WAKE"); + break; + + case STATE_WAKE: + currentState = STATE_SILENT; + setBacklight(false); + Serial.println("-> SILENT (dismiss)"); + break; } } } - http.end(); - return result; + wasTouching = touching; } // ===================================================================== -// Touch Handling -// ===================================================================== -void handleTouch() { - int16_t tx, ty; - if (!gt911_read(&tx, &ty)) return; - - unsigned long now = millis(); - static unsigned long lastDebounce = 0; - if (now - lastDebounce < TOUCH_DEBOUNCE_MS) return; - lastDebounce = now; - - Serial.printf("Touch x=%d y=%d state=%d\n", tx, ty, state); - - switch (state) { - case ST_ALERTING: - // Single tap → silence - state = ST_SILENCED; - setBacklight(false); - ntfyPublish(STATUS_TOPIC, "Silenced by touch"); - Serial.println("-> SILENCED (touch)"); - break; - - case ST_IDLE: - case ST_SILENCED: - // Double-tap detection → wake - if (now - lastTapMs < DOUBLE_TAP_WINDOW_MS) - tapCount++; - else - tapCount = 1; - lastTapMs = now; - - if (tapCount >= 2) { - state = ST_WAKE; - wakeStart = now; - setBacklight(true); - drawStatusScreen(); - Serial.println("-> WAKE (double-tap)"); - tapCount = 0; - } - break; - - case ST_WAKE: - // Tap while awake → go back to sleep immediately - state = ST_IDLE; - setBacklight(false); - Serial.println("-> IDLE (tap dismiss)"); - break; - } -} - -// ===================================================================== -// WiFi Connection +// WiFi // ===================================================================== void connectWiFi() { - Serial.println("Connecting WiFi..."); - for (int i = 0; i < NUM_WIFI; i++) { + for (int i = 0; i < NUM_WIFI; i++) wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass); - Serial.printf(" + %s\n", wifiNetworks[i].ssid); - } setBacklight(true); gfx->fillScreen(COL_BLACK); @@ -414,19 +575,19 @@ void connectWiFi() { while (wifiMulti.run() != WL_CONNECTED && tries++ < 40) delay(500); if (WiFi.isConnected()) { - Serial.printf("Connected: %s IP: %s\n", + Serial.printf("[WIFI] %s %s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); gfx->fillScreen(COL_BLACK); drawCentered("Connected!", 180, 3, COL_GREEN); char buf[64]; snprintf(buf, sizeof(buf), "%s %s", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); - drawCentered(buf, 250, 2, COL_WHITE); - delay(2000); + drawCentered(buf, 240, 2, COL_WHITE); + delay(1500); } else { - Serial.println("WiFi FAILED"); + Serial.println("[WIFI] FAILED"); gfx->fillScreen(COL_BLACK); - drawCentered("WiFi FAILED — retrying in background", 220, 2, COL_RED); + drawCentered("WiFi FAILED", 220, 3, COL_RED); delay(2000); } } @@ -437,26 +598,22 @@ void connectWiFi() { void initHardware() { Wire.begin(I2C_SDA, I2C_SCL); - // CH422G: enable outputs, assert resets, backlight off ch422g_write(CH422G_SYS, CH422G_OE); delay(10); ioState = 0; ch422g_write(CH422G_OUT, ioState); delay(100); - // Release resets (keep backlight off) ioState = IO_TP_RST | IO_LCD_RST; ch422g_write(CH422G_OUT, ioState); delay(200); - // Display if (!gfx->begin()) { - Serial.println("Display init FAILED"); + Serial.println("[HW] Display FAILED"); while (true) delay(1000); } gfx->fillScreen(COL_BLACK); - // Touch gt911_init(); } @@ -469,9 +626,14 @@ void setup() { while (!Serial && millis() - t < 5000) delay(10); delay(500); + bootTime = millis(); + Serial.println("\n========================================"); - Serial.println(" Doorbell Touch v2.0"); - Serial.println(" Waveshare ESP32-S3-Touch-LCD-4.3"); + Serial.println(" KLUBHAUS ALERT v4.1 — Touch Edition"); + #if DEBUG_MODE + Serial.println(" *** DEBUG MODE — _test topics ***"); + #endif + Serial.printf(" Grace period: %d ms\n", BOOT_GRACE_MS); Serial.println("========================================"); Serial.printf("PSRAM: %d MB\n", ESP.getPsramSize() / (1024 * 1024)); @@ -482,149 +644,111 @@ void setup() { initHardware(); - // Boot splash setBacklight(true); gfx->fillScreen(COL_BLACK); - drawCentered("KLUBHAUS", 140, 6, COL_NEON_TEAL); - drawCentered("DOORBELL", 220, 6, COL_HOT_PINK); - drawCentered("v2.0", 310, 2, COL_DARK_GRAY); + drawCentered("KLUBHAUS", 120, 6, COL_NEON_TEAL); + drawCentered("ALERT", 200, 6, COL_HOT_FUCHSIA); + drawCentered("v4.1 Touch", 300, 2, COL_DARK_GRAY); + #if DEBUG_MODE + drawCentered("DEBUG MODE", 340, 2, COL_YELLOW); + #endif delay(1500); connectWiFi(); timeClient.begin(); - timeClient.update(); + if (timeClient.update()) { + ntpSynced = true; + lastKnownEpoch = timeClient.getEpochTime(); + Serial.printf("[NTP] Synced: %ld\n", lastKnownEpoch); + } - bootTime = millis(); - lastPoll = 0; + queueStatus("BOOTED", "v4.1 Touch Edition"); - ntfyPublish(STATUS_TOPIC, "Doorbell Touch v2.0 booted"); - - // Enter idle — screen off - state = ST_IDLE; + currentState = STATE_SILENT; setBacklight(false); - Serial.println("Ready — monitoring ntfy.sh\n"); + Serial.println("[BOOT] Ready — monitoring ntfy.sh\n"); } // ===================================================================== -// Main Loop +// Loop // ===================================================================== void loop() { unsigned long now = millis(); - timeClient.update(); + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd == "CLEAR_DEDUP") { + lastProcessedId = ""; + Serial.println("[CMD] Dedup cleared"); + } + } + + if (timeClient.update()) { + ntpSynced = true; + lastKnownEpoch = timeClient.getEpochTime(); + } + + if (inBootGrace && (now - bootTime >= BOOT_GRACE_MS)) { + inBootGrace = false; + Serial.println("[BOOT] Grace period ended — now monitoring"); + } + handleTouch(); - // WiFi auto-reconnect if (!WiFi.isConnected()) { if (wifiMulti.run() == WL_CONNECTED) { - Serial.println("WiFi reconnected"); - ntfyPublish(STATUS_TOPIC, "WiFi reconnected"); + Serial.println("[WIFI] Reconnected"); + queueStatus("RECONNECTED", WiFi.SSID().c_str()); } } - // ---- Poll ntfy.sh ---- + // Poll ntfy.sh if (now - lastPoll >= POLL_INTERVAL_MS) { lastPoll = now; - - // Remote silence - String sil = ntfyPoll(SILENCE_TOPIC); - if (sil.length() > 0 && state == ST_ALERTING) { - state = ST_SILENCED; - setBacklight(false); - Serial.println("-> SILENCED (remote)"); - } - - // Alert - String alert = ntfyPoll(ALERT_TOPIC); - if (alert.length() > 0) { - if (now - bootTime < BOOT_GRACE_MS) { - Serial.printf("Grace period — ignoring: %s\n", alert.c_str()); - } else { - alertMessage = alert; - state = ST_ALERTING; - alertStart = now; - blinkTimer = now; - blinkPhase = false; - setBacklight(true); - drawAlertScreen(blinkPhase); - ntfyPublish(STATUS_TOPIC, ("Alert: " + alert).c_str()); - Serial.printf("-> ALERTING: %s\n", alert.c_str()); - } - } - - // Admin commands - String admin = ntfyPoll(ADMIN_TOPIC); - if (admin.length() > 0) { - Serial.printf("Admin cmd: %s\n", admin.c_str()); - - if (admin == "status") { - char buf[256]; - snprintf(buf, sizeof(buf), - "State:%s WiFi:%s RSSI:%d Heap:%dKB Up:%lus", - state == ST_IDLE ? "IDLE" : state == ST_ALERTING ? "ALERT" : "SILENCED", - WiFi.SSID().c_str(), WiFi.RSSI(), - ESP.getFreeHeap() / 1024, (now - bootTime) / 1000); - ntfyPublish(STATUS_TOPIC, buf); - - } else if (admin == "test") { - alertMessage = "TEST ALERT"; - state = ST_ALERTING; - alertStart = now; - blinkTimer = now; - blinkPhase = false; - setBacklight(true); - drawAlertScreen(blinkPhase); - - } else if (admin == "wake") { - state = ST_WAKE; - wakeStart = now; - setBacklight(true); - drawStatusScreen(); - } + if (WiFi.isConnected() && ntpSynced) { + pollTopic(ALERT_URL, handleAlertMessage, "ALERT", lastAlertId); + pollTopic(SILENCE_URL, handleSilenceMessage, "SILENCE", lastSilenceId); + pollTopic(ADMIN_URL, handleAdminMessage, "ADMIN", lastAdminId); } } - // ---- State machine ---- - switch (state) { - case ST_ALERTING: - if (now - alertStart > (unsigned long)ALERT_TIMEOUT_SEC * 1000) { - state = ST_SILENCED; - setBacklight(false); - ntfyPublish(STATUS_TOPIC, "Auto-silenced (timeout)"); - Serial.println("-> SILENCED (timeout)"); + switch (currentState) { + case STATE_ALERTING: + if (now - alertStart > ALERT_TIMEOUT_MS) { + handleSilenceMessage("timeout"); + queueStatus("SILENT", "auto-timeout"); break; } - if (now - blinkTimer >= BLINK_INTERVAL_MS) { - blinkTimer = now; - blinkPhase = !blinkPhase; - drawAlertScreen(blinkPhase); + if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { + lastBlinkToggle = now; + blinkState = !blinkState; + drawAlertScreen(); } break; - case ST_WAKE: + case STATE_WAKE: if (now - wakeStart > WAKE_DISPLAY_MS) { - state = ST_IDLE; + currentState = STATE_SILENT; setBacklight(false); - Serial.println("-> IDLE (wake timeout)"); } break; - case ST_IDLE: - case ST_SILENCED: - break; // backlight off, nothing to draw + case STATE_SILENT: + break; } - // Heartbeat static unsigned long lastHB = 0; if (now - lastHB >= 30000) { lastHB = now; - Serial.printf("[%lu s] %s | WiFi:%s | heap:%dKB\n", + Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB\n", now / 1000, - state == ST_IDLE ? "IDLE" : state == ST_ALERTING ? "ALERT" : "SILENCED", + currentState == STATE_SILENT ? "SILENT" : + currentState == STATE_ALERTING ? "ALERT" : "WAKE", WiFi.isConnected() ? "OK" : "DOWN", ESP.getFreeHeap() / 1024); } - delay(20); // ~50 Hz touch polling + delay(20); }