/* * KLUBHAUS ALERT v4.2 — Touch Edition * * Target: Waveshare ESP32-S3-Touch-LCD-4.3 (non-B) * * v4.2 fixes: * - OPI PSRAM mode (double bandwidth — fixes WiFi+RGB coexistence) * - Bounce buffer for RGB DMA * - Fixed variadic lambda crash in drawStatusScreen * - Single tap for all interactions * - Deferred status publishing (no nested HTTPS) * - Per-topic message dedup * - since=10s (no stale message replay) * - DEBUG_MODE with _test topic suffix */ #include #include #include #include #include #include #include #include #include #include // ===================================================================== // DEBUG MODE — set to 1 to use _test topic suffix // ===================================================================== #define DEBUG_MODE 1 // ===================================================================== // WiFi // ===================================================================== struct WiFiCred { const char *ssid; const char *pass; }; WiFiCred wifiNetworks[] = { { "Dobro Veče", "goodnight" }, { "berylpunk", "dhgwilliam" }, // { "iot-2GHz", "lesson-greater" }, // blocks outbound TCP }; const int NUM_WIFI = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); WiFiMulti wifiMulti; // ===================================================================== // ntfy.sh Topics — since=10s covers our 5s poll interval // ===================================================================== #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 15000 #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 // ===================================================================== // Screen & Colors // ===================================================================== #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 // ===================================================================== #define I2C_SDA 8 #define I2C_SCL 9 // ===================================================================== // CH422G IO Expander // ===================================================================== #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) static uint8_t ioState = 0; void ch422g_write(uint8_t addr, uint8_t data) { Wire.beginTransmission(addr); Wire.write(data); Wire.endTransmission(); } void setBacklight(bool on) { if (on) ioState |= IO_LCD_BL; else ioState &= ~IO_LCD_BL; ch422g_write(CH422G_OUT, ioState); } // ===================================================================== // GT911 Touch (direct I2C) // ===================================================================== #define GT911_ADDR1 0x14 #define GT911_ADDR2 0x5D static uint8_t gt911Addr = 0; static bool touchAvailable = false; void gt911_writeReg(uint16_t reg, uint8_t val) { Wire.beginTransmission(gt911Addr); Wire.write(reg >> 8); Wire.write(reg & 0xFF); Wire.write(val); Wire.endTransmission(); } bool gt911_init() { for (uint8_t addr : { GT911_ADDR1, GT911_ADDR2 }) { Wire.beginTransmission(addr); if (Wire.endTransmission() == 0) { gt911Addr = addr; touchAvailable = true; Serial.printf("[HW] GT911 at 0x%02X\n", addr); return true; } } Serial.println("[HW] GT911 not found"); return false; } bool gt911_read(int16_t *x, int16_t *y) { if (!touchAvailable) return false; Wire.beginTransmission(gt911Addr); Wire.write(0x81); Wire.write(0x4E); Wire.endTransmission(false); Wire.requestFrom(gt911Addr, (uint8_t)1); if (!Wire.available()) return false; uint8_t status = Wire.read(); bool ready = status & 0x80; uint8_t pts = status & 0x0F; gt911_writeReg(0x814E, 0x00); if (!ready || pts == 0) return false; 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(), xh = Wire.read(); uint8_t yl = Wire.read(), yh = Wire.read(); *x = xl | (xh << 8); *y = yl | (yh << 8); return true; } // ===================================================================== // RGB Display — 4.3 non-B with bounce buffer for WiFi coexistence // ===================================================================== Arduino_ESP32RGBPanel *rgbpanel = new Arduino_ESP32RGBPanel( 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, // ← back to 16MHz false, // useBigEndian 0, // de_idle_high 0, // pclk_idle_high 8000 // bounce_buffer_size_px ); 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, NTP_SYNC_INTERVAL_MS); // ===================================================================== // State // ===================================================================== enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_WAKE }; DeviceState currentState = STATE_SILENT; String currentMessage = ""; String lastAlertId = ""; String lastSilenceId = ""; String lastAdminId = ""; time_t lastKnownEpoch = 0; bool ntpSynced = false; 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; bool pendingStatus = false; String pendingStatusState = ""; String pendingStatusMsg = ""; bool networkOK = false; // ===================================================================== // Drawing Helpers // ===================================================================== void drawCentered(const char *txt, int y, int sz, uint16_t col) { gfx->setTextSize(sz); gfx->setTextColor(col); int w = strlen(txt) * 6 * sz; gfx->setCursor(max(0, (SCREEN_WIDTH - w) / 2), y); gfx->print(txt); } 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"); int sz = 8; if (currentMessage.length() > 10) sz = 6; if (currentMessage.length() > 20) sz = 4; if (currentMessage.length() > 35) sz = 3; 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 TO SILENCE", SCREEN_HEIGHT - 35, 2, fg); } void drawStatusScreen() { gfx->fillScreen(COL_BLACK); drawHeaderBar(COL_MINT, "KLUBHAUS"); drawCentered("MONITORING", 140, 5, COL_WHITE); char buf[128]; int y = 240; int spacing = 32; int x = 60; 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; snprintf(buf, sizeof(buf), "IP: %s", WiFi.isConnected() ? WiFi.localIP().toString().c_str() : "---"); drawInfoLine(x, y, COL_WHITE, buf); y += spacing; snprintf(buf, sizeof(buf), "Up: %lu min Heap: %d KB PSRAM: %d KB", (millis() - bootTime) / 60000, ESP.getFreeHeap() / 1024, ESP.getFreePsram() / 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 Net: %s", currentState == STATE_SILENT ? "SILENT" : currentState == STATE_ALERTING ? "ALERTING" : "WAKE", networkOK ? "OK" : "FAIL"); 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; } #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); } // ===================================================================== // Status Publishing — deferred to avoid nested HTTPS // ===================================================================== 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); WiFiClientSecure client; client.setInsecure(); HTTPClient statusHttp; statusHttp.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); statusHttp.begin(client, 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()); 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) : ""; if (idStr.length() > 0 && idStr == lastId) { lineStart = lineEnd + 1; continue; } 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; } } // ===================================================================== // Network Diagnostics // ===================================================================== void checkNetwork() { Serial.printf("[NET] WiFi: %s RSSI: %d IP: %s\n", WiFi.SSID().c_str(), WiFi.RSSI(), WiFi.localIP().toString().c_str()); Serial.printf("[NET] GW: %s DNS: %s\n", WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP().toString().c_str()); Serial.printf("[NET] Heap: %d KB PSRAM free: %d KB\n", ESP.getFreeHeap() / 1024, ESP.getFreePsram() / 1024); Serial.flush(); IPAddress ip; if (!WiFi.hostByName("ntfy.sh", ip)) { Serial.println("[NET] *** DNS FAILED ***"); networkOK = false; return; } Serial.printf("[NET] ntfy.sh -> %s\n", ip.toString().c_str()); WiFiClientSecure tls; tls.setInsecure(); Serial.println("[NET] Testing TLS to ntfy.sh:443..."); Serial.flush(); if (tls.connect("ntfy.sh", 443, 15000)) { Serial.println("[NET] TLS OK!"); tls.stop(); networkOK = true; } else { Serial.println("[NET] *** TLS FAILED ***"); networkOK = false; } Serial.flush(); } // ===================================================================== // ntfy Polling // ===================================================================== void pollTopic(const char *url, void (*handler)(const String&), const char *topicName, String &lastId) { WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setTimeout(10000); if (!http.begin(client, url)) { Serial.printf("[%s] begin() failed\n", topicName); return; } int code = http.GET(); if (code == HTTP_CODE_OK) { String response = http.getString(); if (response.length() > 0) parseMessages(response, topicName, handler, lastId); } else { Serial.printf("[%s] HTTP %d: %s\n", topicName, code, code < 0 ? http.errorToString(code).c_str() : ""); } http.end(); } // ===================================================================== // Message Handlers // ===================================================================== void handleAlertMessage(const String &message) { if (currentState == STATE_ALERTING && currentMessage == message) return; currentState = STATE_ALERTING; currentMessage = message; alertStart = millis(); blinkState = false; lastBlinkToggle = millis(); setBacklight(true); drawAlertScreen(); queueStatus("ALERTING", message); Serial.printf("-> ALERTING: %s\n", message.c_str()); } void handleSilenceMessage(const String &message) { currentState = STATE_SILENT; currentMessage = ""; setBacklight(false); queueStatus("SILENT", "silenced"); Serial.println("-> SILENT"); } void handleAdminMessage(const String &message) { Serial.printf("[ADMIN] %s\n", message.c_str()); 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 Net:%s", currentState == STATE_SILENT ? "SILENT" : currentState == STATE_ALERTING ? "ALERT" : "WAKE", WiFi.SSID().c_str(), WiFi.RSSI(), ESP.getFreeHeap() / 1024, (millis() - bootTime) / 1000, networkOK ? "OK" : "FAIL"); queueStatus("STATUS", buf); } else if (message == "wake") { currentState = STATE_WAKE; wakeStart = millis(); setBacklight(true); drawStatusScreen(); } else if (message == "REBOOT") { queueStatus("REBOOTING", "admin"); flushStatus(); delay(200); ESP.restart(); } } // ===================================================================== // Touch — trigger on finger DOWN only // ===================================================================== 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(); 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; } } } wasTouching = touching; } // ===================================================================== // WiFi // ===================================================================== void connectWiFi() { for (int i = 0; i < NUM_WIFI; i++) wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass); setBacklight(true); gfx->fillScreen(COL_BLACK); drawCentered("Connecting to WiFi...", 220, 2, COL_NEON_TEAL); int tries = 0; while (wifiMulti.run() != WL_CONNECTED && tries++ < 40) delay(500); if (WiFi.isConnected()) { 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, 240, 2, COL_WHITE); delay(1500); } else { Serial.println("[WIFI] FAILED"); gfx->fillScreen(COL_BLACK); drawCentered("WiFi FAILED", 220, 3, COL_RED); delay(2000); } } // ===================================================================== // Hardware Init // ===================================================================== void initHardware() { Wire.begin(I2C_SDA, I2C_SCL); ch422g_write(CH422G_SYS, CH422G_OE); delay(10); ioState = 0; ch422g_write(CH422G_OUT, ioState); delay(100); ioState = IO_TP_RST | IO_LCD_RST; ch422g_write(CH422G_OUT, ioState); delay(200); if (!gfx->begin()) { Serial.println("[HW] Display FAILED"); while (true) delay(1000); } gfx->fillScreen(COL_BLACK); gt911_init(); } // ===================================================================== // Setup // ===================================================================== void setup() { Serial.begin(115200); unsigned long t = millis(); while (!Serial && millis() - t < 5000) delay(10); delay(500); bootTime = millis(); Serial.println("\n========================================"); Serial.println(" KLUBHAUS ALERT v4.2 — 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("Heap: %d KB PSRAM: %d KB\n", ESP.getFreeHeap() / 1024, ESP.getPsramSize() / 1024); if (ESP.getPsramSize() == 0) { Serial.println("PSRAM required! Check FQBN has PSRAM=opi"); while (true) delay(1000); } initHardware(); setBacklight(true); gfx->fillScreen(COL_BLACK); drawCentered("KLUBHAUS", 120, 6, COL_NEON_TEAL); drawCentered("ALERT", 200, 6, COL_HOT_FUCHSIA); drawCentered("v4.2 Touch", 300, 2, COL_DARK_GRAY); #if DEBUG_MODE drawCentered("DEBUG MODE", 340, 2, COL_YELLOW); #endif delay(1500); connectWiFi(); if (WiFi.isConnected()) { checkNetwork(); } timeClient.begin(); if (timeClient.update()) { ntpSynced = true; lastKnownEpoch = timeClient.getEpochTime(); Serial.printf("[NTP] Synced: %ld\n", lastKnownEpoch); } queueStatus("BOOTED", "v4.2 Touch Edition"); currentState = STATE_SILENT; setBacklight(false); Serial.println("[BOOT] Ready — monitoring ntfy.sh\n"); } // ===================================================================== // Loop // ===================================================================== void loop() { unsigned long now = millis(); if (Serial.available()) { String cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd == "CLEAR_DEDUP") { lastAlertId = ""; lastSilenceId = ""; lastAdminId = ""; Serial.println("[CMD] Dedup cleared (all topics)"); } else if (cmd == "NET") { checkNetwork(); } } 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(); if (!WiFi.isConnected()) { if (wifiMulti.run() == WL_CONNECTED) { Serial.println("[WIFI] Reconnected"); queueStatus("RECONNECTED", WiFi.SSID().c_str()); } } if (now - lastPoll >= POLL_INTERVAL_MS) { lastPoll = now; if (WiFi.isConnected() && ntpSynced) { pollTopic(ALERT_URL, handleAlertMessage, "ALERT", lastAlertId); pollTopic(SILENCE_URL, handleSilenceMessage, "SILENCE", lastSilenceId); pollTopic(ADMIN_URL, handleAdminMessage, "ADMIN", lastAdminId); } } flushStatus(); switch (currentState) { case STATE_ALERTING: if (now - alertStart > ALERT_TIMEOUT_MS) { handleSilenceMessage("timeout"); break; } if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { lastBlinkToggle = now; blinkState = !blinkState; drawAlertScreen(); } break; case STATE_WAKE: if (now - wakeStart > WAKE_DISPLAY_MS) { currentState = STATE_SILENT; setBacklight(false); } break; case STATE_SILENT: break; } static unsigned long lastHB = 0; if (now - lastHB >= 30000) { lastHB = now; Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB | net:%s\n", now / 1000, currentState == STATE_SILENT ? "SILENT" : currentState == STATE_ALERTING ? "ALERT" : "WAKE", WiFi.isConnected() ? "OK" : "DOWN", ESP.getFreeHeap() / 1024, networkOK ? "OK" : "FAIL"); } delay(20); }