/* * Doorbell Touch — Waveshare ESP32-S3-Touch-LCD-4.3 (non-B) * * Ported from ESP32-C6 + 1.44" TFT_eSPI version. * * 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 */ #include #include #include #include #include #include #include #include #include // ===================================================================== // WiFi — add as many networks as you like // ===================================================================== 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 // ===================================================================== #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" // ===================================================================== // 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 // ===================================================================== // RGB565 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 // ===================================================================== // I2C // ===================================================================== #define I2C_SDA 8 #define I2C_SCL 9 // ===================================================================== // CH422G IO Expander (direct Wire — no library needed) // ===================================================================== #define CH422G_SYS 0x24 // system / mode register #define CH422G_OUT 0x38 // output register #define CH422G_OE 0x01 // enable push-pull outputs #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 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); Serial.printf("Backlight %s\n", on ? "ON" : "OFF"); } // ===================================================================== // GT911 Touch (direct I2C — avoids driver_ng conflict) // ===================================================================== #define GT911_ADDR1 0x14 #define GT911_ADDR2 0x5D #define GT911_IRQ 4 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() { 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); return true; } } Serial.println("GT911 not found — touch disabled"); 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); 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; // 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(); *x = xl | (xh << 8); *y = yl | (yh << 8); return true; } // ===================================================================== // RGB Display — correct pin map 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 ); Arduino_RGB_Display *gfx = new Arduino_RGB_Display(800, 480, rgbpanel, 0, true); // ===================================================================== // NTP // ===================================================================== WiFiUDP ntpUDP; NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, 60000); // ===================================================================== // Application State // ===================================================================== enum State { ST_IDLE, ST_ALERTING, ST_SILENCED, ST_WAKE }; 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; // Touch double-tap tracking unsigned long lastTapMs = 0; int tapCount = 0; // ===================================================================== // 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; // approximate pixel width gfx->setCursor(max(0, (800 - 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; gfx->fillScreen(bg); drawCentered("! DOORBELL !", 60, 6, fg); 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()); } drawCentered("TAP ANYWHERE TO SILENCE", 430, 2, fg); } void drawStatusScreen() { gfx->fillScreen(COL_BLACK); drawCentered("DOORBELL MONITOR", 20, 3, COL_NEON_TEAL); gfx->drawFastHLine(50, 65, 700, COL_DARK_GRAY); 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; }; 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); 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); if (alertMessage.length() > 0) { y += 10; line(COL_DARK_GRAY, "Last: %s", alertMessage.c_str()); } drawCentered("tap to dismiss | auto-sleeps in 5s", 445, 1, COL_DARK_GRAY); } // ===================================================================== // ntfy.sh Communication // ===================================================================== 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); http.end(); } // Poll a topic; returns the message body of the newest unseen message, or "" String ntfyPoll(const char *topic) { if (!WiFi.isConnected()) return ""; HTTPClient http; String url = String(NTFY_BASE) + "/" + topic + "/json?poll=1&since=5s"; http.begin(url); http.setTimeout(5000); int code = http.GET(); String result = ""; if (code == 200) { String body = http.getString(); // 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(); if (last.length() > 2) { JsonDocument doc; if (!deserializeJson(doc, last) && doc["event"] == "message") { String id = doc["id"].as(); long ts = doc["time"].as(); // 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(); } } } } } http.end(); return result; } // ===================================================================== // 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 // ===================================================================== void connectWiFi() { Serial.println("Connecting WiFi..."); 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); 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("Connected: %s IP: %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); } else { Serial.println("WiFi FAILED"); gfx->fillScreen(COL_BLACK); drawCentered("WiFi FAILED — retrying in background", 220, 2, COL_RED); delay(2000); } } // ===================================================================== // Hardware Init // ===================================================================== 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"); while (true) delay(1000); } gfx->fillScreen(COL_BLACK); // Touch gt911_init(); } // ===================================================================== // Setup // ===================================================================== void setup() { Serial.begin(115200); unsigned long t = millis(); while (!Serial && millis() - t < 5000) delay(10); delay(500); Serial.println("\n========================================"); Serial.println(" Doorbell Touch v2.0"); Serial.println(" Waveshare ESP32-S3-Touch-LCD-4.3"); Serial.println("========================================"); Serial.printf("PSRAM: %d MB\n", ESP.getPsramSize() / (1024 * 1024)); if (ESP.getPsramSize() == 0) { Serial.println("PSRAM required!"); while (true) delay(1000); } 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); delay(1500); connectWiFi(); timeClient.begin(); timeClient.update(); bootTime = millis(); lastPoll = 0; ntfyPublish(STATUS_TOPIC, "Doorbell Touch v2.0 booted"); // Enter idle — screen off state = ST_IDLE; setBacklight(false); Serial.println("Ready — monitoring ntfy.sh\n"); } // ===================================================================== // Main Loop // ===================================================================== void loop() { unsigned long now = millis(); timeClient.update(); handleTouch(); // WiFi auto-reconnect if (!WiFi.isConnected()) { if (wifiMulti.run() == WL_CONNECTED) { Serial.println("WiFi reconnected"); ntfyPublish(STATUS_TOPIC, "WiFi reconnected"); } } // ---- 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(); } } } // ---- 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)"); break; } if (now - blinkTimer >= BLINK_INTERVAL_MS) { blinkTimer = now; blinkPhase = !blinkPhase; drawAlertScreen(blinkPhase); } break; case ST_WAKE: if (now - wakeStart > WAKE_DISPLAY_MS) { state = ST_IDLE; setBacklight(false); Serial.println("-> IDLE (wake timeout)"); } break; case ST_IDLE: case ST_SILENCED: break; // backlight off, nothing to draw } // Heartbeat static unsigned long lastHB = 0; if (now - lastHB >= 30000) { lastHB = now; Serial.printf("[%lu s] %s | WiFi:%s | heap:%dKB\n", now / 1000, state == ST_IDLE ? "IDLE" : state == ST_ALERTING ? "ALERT" : "SILENCED", WiFi.isConnected() ? "OK" : "DOWN", ESP.getFreeHeap() / 1024); } delay(20); // ~50 Hz touch polling }