From 329690abcf44996cc0dbd2d180bd913a03e6933f Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 13 Feb 2026 19:41:14 -0800 Subject: [PATCH] snapshot --- sketches/doorbell-touch/doorbell-touch.ino | 630 +++++++++++++++++++++ sketches/doorbell-touch/mise.toml | 30 + 2 files changed, 660 insertions(+) create mode 100644 sketches/doorbell-touch/doorbell-touch.ino create mode 100644 sketches/doorbell-touch/mise.toml diff --git a/sketches/doorbell-touch/doorbell-touch.ino b/sketches/doorbell-touch/doorbell-touch.ino new file mode 100644 index 0000000..30a4b3f --- /dev/null +++ b/sketches/doorbell-touch/doorbell-touch.ino @@ -0,0 +1,630 @@ +/* + * 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 +} diff --git a/sketches/doorbell-touch/mise.toml b/sketches/doorbell-touch/mise.toml new file mode 100644 index 0000000..f78fedd --- /dev/null +++ b/sketches/doorbell-touch/mise.toml @@ -0,0 +1,30 @@ +[tools] +arduino-cli = "latest" + +[env] +FQBN = "esp32:esp32:waveshare_esp32_s3_touch_lcd_43:UploadSpeed=921600,USBMode=hwcdc,CDCOnBoot=cdc,CPUFreq=240,FlashMode=qio,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,DebugLevel=info,PSRAM=enabled,LoopCore=1,EventsCore=1,EraseFlash=none" + +[tasks.install-core] +run = "arduino-cli core update-index && arduino-cli core install esp32:esp32" + +[tasks.install-libs] +run = """ +arduino-cli lib install "GFX Library for Arduino" +arduino-cli lib install "ArduinoJson" +arduino-cli lib install "NTPClient" +""" + +[tasks.compile] +run = "arduino-cli compile --fqbn $FQBN ." + +[tasks.upload] +depends = ["compile"] +run = "arduino-cli upload --fqbn $FQBN -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') ." + +[tasks.monitor] +depends = ["upload"] +run = "arduino-cli monitor -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') -c baudrate=115200" + +[tasks.all] +depends = ["monitor"] +