This commit is contained in:
2026-02-13 19:41:14 -08:00
commit 6b2921aa30
2 changed files with 660 additions and 0 deletions

630
doorbell-touch.ino Normal file
View File

@@ -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 <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
}

30
mise.toml Normal file
View File

@@ -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"]