Files
klubhaus-doorbell/sketches/doorbell-touch/doorbell-touch.ino
2026-02-13 19:41:14 -08:00

631 lines
19 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Arduino_GFX_Library.h>
// =====================================================================
// 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<String>();
long ts = doc["time"].as<long>();
// 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<String>();
}
}
}
}
}
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
}