Files
klubhaus-doorbell/doorbell-touch.ino
2026-02-16 19:05:13 -08:00

825 lines
24 KiB
C++

/*
* KLUBHAUS ALERT v4.1 — Touch Edition
*
* Target: Waveshare ESP32-S3-Touch-LCD-4.3 (non-B)
*
* v4.1 fixes:
* - Fixed variadic lambda crash in drawStatusScreen
* - Single tap for all interactions
* - DEBUG_MODE with _test topic suffix
*/
#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>
#include <WiFiClientSecure.h>
// =====================================================================
// DEBUG MODE — set to true to use _test topic suffix
// =====================================================================
#define DEBUG_MODE 0
// =====================================================================
// WiFi
// =====================================================================
struct WiFiCred { const char *ssid; const char *pass; };
WiFiCred wifiNetworks[] = {
{ "Dobro Veče", "goodnight" },
{ "berylpunk", "dhgwilliam" },
// { "iot-2GHz", "lesson-greater" },
};
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 5000
#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 — correct pins for 4.3 non-B
// =====================================================================
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
);
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;
// Deferred status publishing — avoids nested HTTPS connections
bool pendingStatus = false;
String pendingStatusState = "";
String pendingStatusMsg = "";
// =====================================================================
// 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);
}
// Safe line drawing — replaces the variadic lambda that caused crashes
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",
(millis() - bootTime) / 60000, ESP.getFreeHeap() / 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",
currentState == STATE_SILENT ? "SILENT" :
currentState == STATE_ALERTING ? "ALERTING" : "WAKE");
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) : "";
// Per-topic dedup
if (idStr.length() > 0 && idStr == lastId) {
lineStart = lineEnd + 1;
continue;
}
// Age check
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;
}
}
// =====================================================================
// ntfy Polling — with diagnostics and proper HTTPS
// =====================================================================
#include <WiFiClientSecure.h>
bool networkOK = false;
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.flush();
// Test DNS
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());
// Test TCP connectivity on port 80
WiFiClient testClient;
Serial.println("[NET] Testing TCP to ntfy.sh:80...");
Serial.flush();
if (testClient.connect("ntfy.sh", 80, 5000)) {
Serial.println("[NET] TCP port 80 OK!");
testClient.stop();
networkOK = true;
} else {
Serial.println("[NET] *** TCP port 80 BLOCKED ***");
}
// Test TCP connectivity on port 443
WiFiClient testClient2;
Serial.println("[NET] Testing TCP to ntfy.sh:443...");
Serial.flush();
if (testClient2.connect("ntfy.sh", 443, 5000)) {
Serial.println("[NET] TCP port 443 OK!");
testClient2.stop();
networkOK = true;
} else {
Serial.println("[NET] *** TCP port 443 BLOCKED ***");
}
Serial.flush();
}
#include <WiFiClientSecure.h>
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",
currentState == STATE_SILENT ? "SILENT" :
currentState == STATE_ALERTING ? "ALERT" : "WAKE",
WiFi.SSID().c_str(), WiFi.RSSI(),
ESP.getFreeHeap() / 1024, (millis() - bootTime) / 1000);
queueStatus("STATUS", buf);
}
else if (message == "wake") {
currentState = STATE_WAKE;
wakeStart = millis();
setBacklight(true);
drawStatusScreen();
}
else if (message == "REBOOT") {
queueStatus("REBOOTING", "admin");
delay(200);
ESP.restart();
}
}
// =====================================================================
// Touch — trigger on finger DOWN only, not while held
// =====================================================================
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();
// Only act on the transition: NOT touching → touching (new finger down)
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.1 — 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("PSRAM: %d MB\n", ESP.getPsramSize() / (1024 * 1024));
if (ESP.getPsramSize() == 0) {
Serial.println("PSRAM required!");
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.1 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.1 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)");
}
}
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());
}
}
// Poll ntfy.sh
if (now - lastPoll >= POLL_INTERVAL_MS) {
lastPoll = now;
if (WiFi.isConnected() && ntpSynced) {
if (!networkOK) checkNetwork(); // diagnose on first poll
pollTopic(ALERT_URL, handleAlertMessage, "ALERT", lastAlertId);
pollTopic(SILENCE_URL, handleSilenceMessage, "SILENCE", lastSilenceId);
pollTopic(ADMIN_URL, handleAdminMessage, "ADMIN", lastAdminId);
}
}
// Send any queued status AFTER polling connections are closed
flushStatus();
switch (currentState) {
case STATE_ALERTING:
if (now - alertStart > ALERT_TIMEOUT_MS) {
handleSilenceMessage("timeout");
queueStatus("SILENT", "auto-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\n",
now / 1000,
currentState == STATE_SILENT ? "SILENT" :
currentState == STATE_ALERTING ? "ALERT" : "WAKE",
WiFi.isConnected() ? "OK" : "DOWN",
ESP.getFreeHeap() / 1024);
}
delay(20);
}