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

421 lines
12 KiB
C++

/*
* KLUBHAUS ALERT DEVICE v3.9.1-minimal
*
* Minimal fix for boot loop: message age filtering + ID deduplication
* Preserves original code structure from v3.8.x
*/
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
// ============== CONFIGURATION ==============
String wifiSSID = "IoT-2GHz";
String wifiPassword = "lesson-greater";
const char* ALERT_TOPIC = "https://ntfy.sh/ALERT_klubhaus_topic/json?since=all";
const char* SILENCE_TOPIC = "https://ntfy.sh/SILENCE_klubhaus_topic/json?since=all";
const char* ADMIN_TOPIC = "https://ntfy.sh/ADMIN_klubhaus_topic/json?since=all";
const char* STATUS_TOPIC = "https://ntfy.sh/STATUS_klubhaus_topic";
const unsigned long POLL_INTERVAL_MS = 5000;
const unsigned long BLINK_INTERVAL_MS = 500;
const unsigned long BUTTON_DEBOUNCE_MS = 50;
const unsigned long BUTTON_LONG_PRESS_MS = 1000;
const unsigned long BUTTON_DOUBLE_PRESS_MS = 300;
const unsigned long STALE_MESSAGE_THRESHOLD_S = 600; // 10 minutes (in seconds!)
const unsigned long BOOT_GRACE_PERIOD_MS = 30000;
const unsigned long NTP_SYNC_INTERVAL_MS = 3600000;
const int SCREEN_WIDTH = 320;
const int SCREEN_HEIGHT = 172;
const uint16_t COLOR_NEON_TEAL = 0x07D7;
const uint16_t COLOR_HOT_FUCHSIA = 0xF81F;
const uint16_t COLOR_COCAINE_WHITE = 0xFFDF;
const uint16_t COLOR_MIDNIGHT_BLACK = 0x0000;
const uint16_t COLOR_MINT_FLASH = 0x67F5;
// ============== GLOBALS ==============
TFT_eSPI tft = TFT_eSPI();
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS);
enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_SENDING };
DeviceState currentState = STATE_SILENT;
String currentMessage = "";
bool displayOn = true;
bool ledModeOnly = false;
const int BUTTON_PIN = 0;
unsigned long buttonLastPress = 0;
unsigned long buttonPressStart = 0;
int buttonPressCount = 0;
bool buttonPressed = false;
unsigned long lastBlinkToggle = 0;
bool blinkState = false;
uint16_t currentAlertColor = COLOR_NEON_TEAL;
unsigned long bootTime = 0;
bool inBootGracePeriod = true;
bool ntpSynced = false;
HTTPClient http;
unsigned long lastPoll = 0;
// ============== NEW: DEDUPLICATION ==============
String lastProcessedId = "";
time_t lastKnownEpoch = 0;
// ============== SETUP ==============
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 3000) { ; }
bootTime = millis();
Serial.println("[BOOT] KLUBHAUS ALERT v3.9.1-minimal");
Serial.println("[BOOT] Fix: message age filter + ID dedup");
tft.init();
tft.setRotation(1);
tft.fillScreen(COLOR_MIDNIGHT_BLACK);
tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(2);
tft.drawString("KLUBHAUS", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 20);
tft.setTextSize(1);
tft.drawString("v3.9.1-minimal", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 10);
tft.drawString("Booting...", SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 25);
pinMode(BUTTON_PIN, INPUT_PULLUP);
WiFi.begin(wifiSSID.c_str(), wifiPassword.c_str());
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
attempts++;
}
timeClient.begin();
if (timeClient.update()) {
ntpSynced = true;
lastKnownEpoch = timeClient.getEpochTime();
Serial.println("[BOOT] NTP synced: " + String(lastKnownEpoch));
}
Serial.println("[BOOT] Setup complete, grace period: 30s");
}
// ============== LOOP ==============
void loop() {
unsigned long now = millis();
if (Serial.available()) {
String cmd = Serial.readStringUntil('\n');
cmd.trim();
if (cmd == "CLEAR_DEDUP") {
lastProcessedId = "";
Serial.println("[CMD] Deduplication cleared");
}
}
if (timeClient.update()) {
ntpSynced = true;
lastKnownEpoch = timeClient.getEpochTime();
}
if (inBootGracePeriod && (now - bootTime >= BOOT_GRACE_PERIOD_MS)) {
inBootGracePeriod = false;
Serial.println("[BOOT] Grace period ended");
}
if (now - lastPoll >= POLL_INTERVAL_MS) {
lastPoll = now;
if (WiFi.status() == WL_CONNECTED && ntpSynced) {
pollTopic(ALERT_TOPIC, handleAlertMessage, "ALERT");
pollTopic(SILENCE_TOPIC, handleSilenceMessage, "SILENCE");
pollTopic(ADMIN_TOPIC, handleAdminMessage, "ADMIN");
}
}
handleButton();
updateDisplay();
delay(10);
}
// ============== POLLING ==============
void pollTopic(const char* url, void (*handler)(const String&), const char* topicName) {
http.begin(url);
http.setTimeout(10000);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
String response = http.getString();
if (response.length() > 0) {
parseMessages(response, topicName, handler);
}
}
http.end();
}
// ============== PARSING WITH AGE FILTER & DEDUP ==============
void parseMessages(String& response, const char* topicName, void (*handler)(const String&)) {
// Grace period: ignore everything
if (inBootGracePeriod) {
return;
}
// No time sync: can't validate, skip to be safe
if (!ntpSynced || lastKnownEpoch == 0) {
return;
}
int lineStart = 0;
while (lineStart < 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) {
StaticJsonDocument<1024> doc;
DeserializationError err = deserializeJson(doc, line);
if (!err) {
const char* msgId = doc["id"];
const char* message = doc["message"];
time_t msgTime = doc["time"] | 0; // ntfy sends epoch SECONDS
if (message && strlen(message) > 0) {
String msgStr = String(message);
String idStr = msgId ? String(msgId) : "";
// DEDUP: skip if same ID already processed
if (idStr.length() > 0 && idStr == lastProcessedId) {
Serial.println("[DEDUP] Skipping duplicate: " + idStr.substring(0, 8));
lineStart = lineEnd + 1;
continue;
}
// AGE CHECK: compare message time to current wall clock
if (msgTime > 0) {
time_t ageSeconds = lastKnownEpoch - msgTime;
if (ageSeconds > (time_t)STALE_MESSAGE_THRESHOLD_S) {
Serial.println("[STALE] Rejecting " + String(ageSeconds) + "s old message: " + msgStr.substring(0, 20));
lineStart = lineEnd + 1;
continue;
}
}
// VALID: process and record ID
Serial.println("[" + String(topicName) + "] Processing: " + msgStr.substring(0, 40));
if (idStr.length() > 0) {
lastProcessedId = idStr;
}
handler(msgStr);
}
}
}
lineStart = lineEnd + 1;
}
}
// ============== HANDLERS ==============
void handleAlertMessage(const String& message) {
if (currentState == STATE_ALERTING && currentMessage == message) return;
currentState = STATE_ALERTING;
currentMessage = message;
displayOn = true;
publishStatus("ALERTING", message);
if (!ledModeOnly) {
drawAlertScreen();
}
}
void handleSilenceMessage(const String& message) {
currentState = STATE_SILENT;
currentMessage = "";
displayOn = true;
publishStatus("SILENT", "silenced");
if (!ledModeOnly) {
drawSilentScreen();
}
}
void handleAdminMessage(const String& message) {
Serial.println("[ADMIN] Command: " + message);
if (message == "MODE_SCREEN") {
ledModeOnly = false;
if (currentState == STATE_SILENT) drawSilentScreen();
else drawAlertScreen();
}
else if (message == "MODE_LED") {
ledModeOnly = true;
tft.fillScreen(COLOR_MIDNIGHT_BLACK);
}
else if (message == "SILENCE") {
handleSilenceMessage("admin");
}
else if (message == "PING") {
publishStatus("PONG", "ping");
}
else if (message == "REBOOT") {
Serial.println("[ADMIN] Rebooting...");
delay(100);
ESP.restart();
}
}
// ============== STATUS ==============
void publishStatus(const char* state, const String& message) {
if (WiFi.status() != WL_CONNECTED) return;
StaticJsonDocument<256> doc;
doc["state"] = state;
doc["message"] = message;
doc["timestamp"] = (long long)timeClient.getEpochTime() * 1000LL;
doc["led_mode"] = ledModeOnly;
String payload;
serializeJson(doc, payload);
HTTPClient statusHttp;
statusHttp.begin(STATUS_TOPIC);
statusHttp.addHeader("Content-Type", "application/json");
statusHttp.POST(payload);
statusHttp.end();
}
// ============== DISPLAY ==============
void updateDisplay() {
if (ledModeOnly) {
if (currentState == STATE_ALERTING) {
unsigned long now = millis();
if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) {
lastBlinkToggle = now;
blinkState = !blinkState;
digitalWrite(2, blinkState ? HIGH : LOW);
}
}
return;
}
if (currentState == STATE_ALERTING) {
unsigned long now = millis();
if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) {
lastBlinkToggle = now;
blinkState = !blinkState;
currentAlertColor = blinkState ? COLOR_NEON_TEAL : COLOR_HOT_FUCHSIA;
drawAlertScreen();
digitalWrite(2, blinkState ? HIGH : LOW);
}
}
}
void drawAlertScreen() {
if (ledModeOnly) return;
tft.fillScreen(COLOR_MIDNIGHT_BLACK);
tft.setTextColor(currentAlertColor, COLOR_MIDNIGHT_BLACK);
int textSize = 4;
if (currentMessage.length() > 10) textSize = 3;
if (currentMessage.length() > 20) textSize = 2;
tft.setTextSize(textSize);
tft.setTextDatum(MC_DATUM);
if (currentMessage.length() > 15) {
int mid = currentMessage.length() / 2;
int spacePos = currentMessage.lastIndexOf(' ', mid);
if (spacePos < 0) spacePos = mid;
tft.drawString(currentMessage.substring(0, spacePos), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 - 15);
tft.drawString(currentMessage.substring(spacePos + 1), SCREEN_WIDTH/2, SCREEN_HEIGHT/2 + 15);
} else {
tft.drawString(currentMessage, SCREEN_WIDTH/2, SCREEN_HEIGHT/2);
}
tft.setTextSize(1);
tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK);
tft.setTextDatum(TL_DATUM);
tft.drawString("ALERT", 5, 5);
tft.setTextDatum(TR_DATUM);
tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5);
}
void drawSilentScreen() {
if (ledModeOnly) return;
tft.fillScreen(COLOR_MIDNIGHT_BLACK);
tft.setTextColor(COLOR_COCAINE_WHITE, COLOR_MIDNIGHT_BLACK);
tft.setTextSize(2);
tft.setTextDatum(MC_DATUM);
tft.drawString("SILENT", SCREEN_WIDTH/2, SCREEN_HEIGHT/2);
tft.setTextSize(1);
tft.setTextColor(COLOR_MINT_FLASH, COLOR_MIDNIGHT_BLACK);
tft.setTextDatum(TL_DATUM);
tft.drawString("KLUBHAUS", 5, 5);
tft.setTextDatum(TR_DATUM);
tft.drawString(timeClient.getFormattedTime(), SCREEN_WIDTH - 5, 5);
}
// ============== BUTTON ==============
void handleButton() {
bool reading = (digitalRead(BUTTON_PIN) == LOW);
if (reading != buttonPressed) {
if (reading) {
buttonPressStart = millis();
if (millis() - buttonLastPress < BUTTON_DOUBLE_PRESS_MS) {
buttonPressCount++;
} else {
buttonPressCount = 1;
}
buttonLastPress = millis();
if (buttonPressCount == 2) {
ledModeOnly = !ledModeOnly;
buttonPressCount = 0;
if (ledModeOnly) {
tft.fillScreen(COLOR_MIDNIGHT_BLACK);
} else {
if (currentState == STATE_SILENT) drawSilentScreen();
else drawAlertScreen();
}
return;
}
} else {
unsigned long duration = millis() - buttonPressStart;
if (duration >= BUTTON_DEBOUNCE_MS && duration < BUTTON_LONG_PRESS_MS) {
if (currentState == STATE_ALERTING) {
handleSilenceMessage("button");
} else {
handleAlertMessage("TEST ALERT");
}
}
}
buttonPressed = reading;
}
}