Files
klubhaus-doorbell/sketches/doorbell-touch/DoorbellLogic.cpp
2026-02-16 19:08:47 -08:00

577 lines
17 KiB
C++

#include "DoorbellLogic.h"
// =====================================================================
// Lifecycle
// =====================================================================
void DoorbellLogic::begin() {
_bootTime = millis();
_timeClient = new NTPClient(_ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS);
_screen.debugMode = DEBUG_MODE;
_screen.screen = ScreenID::BOOT_SPLASH;
updateScreenState();
}
void DoorbellLogic::beginWiFi() {
_instance = this;
WiFi.mode(WIFI_STA);
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.onEvent(onWiFiEvent);
for (int i = 0; i < NUM_WIFI; i++) {
_wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass);
}
_screen.screen = ScreenID::WIFI_CONNECTING;
updateScreenState();
}
void DoorbellLogic::connectWiFiBlocking() {
Serial.println("[WIFI] Connecting...");
int tries = 0;
while (_wifiMulti.run() != WL_CONNECTED && tries++ < 40) {
Serial.print(".");
delay(500);
}
Serial.println();
if (WiFi.isConnected()) {
Serial.printf("[WIFI] Connected: %s %s\n",
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
updateScreenState();
_screen.screen = ScreenID::WIFI_CONNECTED;
} else {
Serial.println("[WIFI] FAILED — no networks reachable");
_screen.screen = ScreenID::WIFI_FAILED;
}
updateScreenState();
}
void DoorbellLogic::finishBoot() {
if (WiFi.isConnected()) {
_timeClient->begin();
Serial.println("[NTP] Starting sync...");
for (int i = 0; i < 5 && !_ntpSynced; i++) {
syncNTP();
if (!_ntpSynced) delay(500);
}
if (_ntpSynced) {
Serial.printf("[NTP] Synced: %s UTC\n",
_timeClient->getFormattedTime().c_str());
} else {
Serial.println("[NTP] Initial sync failed — will retry in update()");
}
checkNetwork();
char bootMsg[80];
snprintf(bootMsg, sizeof(bootMsg), "%s %s RSSI:%d",
WiFi.SSID().c_str(),
WiFi.localIP().toString().c_str(),
WiFi.RSSI());
queueStatus("BOOTED", bootMsg);
flushStatus();
}
Serial.printf("[CONFIG] ALERT_URL: %s\n", ALERT_URL);
Serial.printf("[CONFIG] SILENCE_URL: %s\n", SILENCE_URL);
Serial.printf("[CONFIG] ADMIN_URL: %s\n", ADMIN_URL);
transitionTo(DeviceState::SILENT);
Serial.printf("[BOOT] Grace period: %d ms\n", BOOT_GRACE_MS);
}
// =====================================================================
// Main Update Loop
// =====================================================================
void DoorbellLogic::update() {
unsigned long now = millis();
if (_inBootGrace && (now - _bootTime >= BOOT_GRACE_MS)) {
_inBootGrace = false;
Serial.println("[BOOT] Grace period ended");
}
syncNTP();
if (!WiFi.isConnected()) {
if (_wifiMulti.run() == WL_CONNECTED) {
Serial.println("[WIFI] Reconnected");
queueStatus("RECONNECTED", WiFi.SSID().c_str());
}
}
if (now - _lastPoll >= POLL_INTERVAL_MS) {
_lastPoll = now;
if (WiFi.isConnected() && _ntpSynced) {
pollTopics();
}
}
flushStatus();
switch (_state) {
case DeviceState::ALERTING:
if (now - _lastBlink >= BLINK_INTERVAL_MS) {
_lastBlink = now;
_blinkState = !_blinkState;
}
break;
case DeviceState::WAKE:
if (now - _wakeStart > WAKE_DISPLAY_MS) {
transitionTo(DeviceState::SILENT);
}
break;
case DeviceState::SILENT:
break;
}
if (now - _lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
_lastHeartbeat = now;
uint32_t heap = ESP.getFreeHeap();
Serial.printf("[%lus] %s | WiFi:%s RSSI:%d | heap:%dKB | minHeap:%dKB\n",
now / 1000,
_state == DeviceState::SILENT ? "SILENT" :
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
WiFi.isConnected() ? "OK" : "DOWN",
WiFi.RSSI(),
heap / 1024,
ESP.getMinFreeHeap() / 1024);
if (heap < 20000) {
Serial.println("[HEAP] CRITICAL — rebooting!");
queueStatus("REBOOTING", "low heap");
flushStatus();
delay(200);
ESP.restart();
}
}
updateScreenState();
}
// =====================================================================
// Input Events
// =====================================================================
void DoorbellLogic::onTouch(const TouchEvent& evt) {
if (!evt.pressed) return;
static unsigned long lastAction = 0;
unsigned long now = millis();
if (now - lastAction < TOUCH_DEBOUNCE_MS) return;
lastAction = now;
Serial.printf("[TOUCH] x=%d y=%d state=%d\n", evt.x, evt.y, (int)_state);
switch (_state) {
case DeviceState::ALERTING:
handleSilence("touch");
break;
case DeviceState::SILENT:
transitionTo(DeviceState::WAKE);
break;
case DeviceState::WAKE:
transitionTo(DeviceState::SILENT);
break;
}
}
void DoorbellLogic::onSerialCommand(const String& cmd) {
if (cmd == "CLEAR_DEDUP") {
_lastAlertId = _lastSilenceId = _lastAdminId = "";
Serial.println("[CMD] Dedup cleared");
} else if (cmd == "NET") {
checkNetwork();
} else if (cmd == "STATUS") {
Serial.printf("[CMD] State:%s WiFi:%s RSSI:%d Heap:%dKB NTP:%s\n",
_state == DeviceState::SILENT ? "SILENT" :
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
WiFi.isConnected() ? WiFi.SSID().c_str() : "DOWN",
WiFi.RSSI(),
ESP.getFreeHeap() / 1024,
_ntpSynced ? _timeClient->getFormattedTime().c_str() : "no");
} else if (cmd == "WAKE") {
transitionTo(DeviceState::WAKE);
} else if (cmd == "TEST") {
handleAlert("TEST ALERT");
} else if (cmd == "REBOOT") {
Serial.println("[CMD] Rebooting...");
queueStatus("REBOOTING", "serial");
flushStatus();
delay(200);
ESP.restart();
} else {
Serial.printf("[CMD] Unknown: %s\n", cmd.c_str());
}
}
// =====================================================================
// State Transitions
// =====================================================================
void DoorbellLogic::transitionTo(DeviceState newState) {
_state = newState;
unsigned long now = millis();
switch (newState) {
case DeviceState::SILENT:
_screen.screen = ScreenID::OFF;
_alertMsgEpoch = 0;
Serial.println("-> SILENT");
break;
case DeviceState::ALERTING:
_alertStart = now;
_lastBlink = now;
_blinkState = false;
_screen.screen = ScreenID::ALERT;
Serial.printf("-> ALERTING: %s\n", _currentMessage.c_str());
break;
case DeviceState::WAKE:
_wakeStart = now;
_screen.screen = ScreenID::DASHBOARD; // ← CHANGED from STATUS
Serial.println("-> WAKE (dashboard)"); // ← CHANGED
break;
}
}
// =====================================================================
// Message Handlers
// =====================================================================
void DoorbellLogic::handleAlert(const String& msg) {
if (_state == DeviceState::ALERTING && _currentMessage == msg) return;
_currentMessage = msg;
_alertMsgEpoch = _lastParsedMsgEpoch;
for (int i = ALERT_HISTORY_SIZE - 1; i > 0; i--) {
_screen.alertHistory[i] = _screen.alertHistory[i - 1];
}
strncpy(_screen.alertHistory[0].message, msg.c_str(), 63);
_screen.alertHistory[0].message[63] = '\0';
strncpy(_screen.alertHistory[0].timestamp,
_ntpSynced ? _timeClient->getFormattedTime().c_str() : "??:??:??", 11);
_screen.alertHistory[0].timestamp[11] = '\0';
if (_screen.alertHistoryCount < ALERT_HISTORY_SIZE)
_screen.alertHistoryCount++;
Serial.printf("[ALERT] Accepted. ntfy time=%ld history=%d\n",
(long)_alertMsgEpoch, _screen.alertHistoryCount);
transitionTo(DeviceState::ALERTING);
queueStatus("ALERTING", msg);
}
void DoorbellLogic::handleSilence(const String& msg) {
if (_state != DeviceState::ALERTING) {
Serial.println("[SILENCE] Ignored — not alerting");
return;
}
if (_lastParsedMsgEpoch > 0 && _alertMsgEpoch > 0 &&
_lastParsedMsgEpoch <= _alertMsgEpoch) {
Serial.printf("[SILENCE] Ignored — predates alert (silence:%ld <= alert:%ld)\n",
(long)_lastParsedMsgEpoch, (long)_alertMsgEpoch);
return;
}
Serial.printf("[SILENCE] Accepted (silence:%ld > alert:%ld)\n",
(long)_lastParsedMsgEpoch, (long)_alertMsgEpoch);
_currentMessage = "";
_alertMsgEpoch = 0;
transitionTo(DeviceState::SILENT);
queueStatus("SILENT", "silenced");
}
void DoorbellLogic::handleAdmin(const String& msg) {
Serial.printf("[ADMIN] %s\n", msg.c_str());
if (msg == "SILENCE") handleSilence("admin");
else if (msg == "PING") queueStatus("PONG", "ping");
else if (msg == "test") handleAlert("TEST ALERT");
else if (msg == "status") {
char buf[128];
snprintf(buf, sizeof(buf), "State:%s WiFi:%s RSSI:%d Heap:%dKB",
_state == DeviceState::SILENT ? "SILENT" :
_state == DeviceState::ALERTING ? "ALERT" : "WAKE",
WiFi.SSID().c_str(), WiFi.RSSI(), ESP.getFreeHeap() / 1024);
queueStatus("STATUS", buf);
}
else if (msg == "wake") {
transitionTo(DeviceState::WAKE);
}
else if (msg == "REBOOT") {
queueStatus("REBOOTING", "admin");
flushStatus();
delay(200);
ESP.restart();
}
}
// =====================================================================
// Screen State Sync
// =====================================================================
void DoorbellLogic::updateScreenState() {
_screen.deviceState = _state;
_screen.blinkPhase = _blinkState;
strncpy(_screen.alertMessage, _currentMessage.c_str(), sizeof(_screen.alertMessage) - 1);
_screen.alertMessage[sizeof(_screen.alertMessage) - 1] = '\0';
_screen.wifiConnected = WiFi.isConnected();
if (_screen.wifiConnected) {
strncpy(_screen.wifiSSID, WiFi.SSID().c_str(), sizeof(_screen.wifiSSID) - 1);
_screen.wifiSSID[sizeof(_screen.wifiSSID) - 1] = '\0';
strncpy(_screen.wifiIP, WiFi.localIP().toString().c_str(), sizeof(_screen.wifiIP) - 1);
_screen.wifiIP[sizeof(_screen.wifiIP) - 1] = '\0';
_screen.wifiRSSI = WiFi.RSSI();
}
_screen.ntpSynced = _ntpSynced;
if (_ntpSynced) {
strncpy(_screen.timeString, _timeClient->getFormattedTime().c_str(),
sizeof(_screen.timeString) - 1);
_screen.timeString[sizeof(_screen.timeString) - 1] = '\0';
}
_screen.uptimeMinutes = (millis() - _bootTime) / 60000;
_screen.freeHeapKB = ESP.getFreeHeap() / 1024;
_screen.networkOK = _networkOK;
}
// =====================================================================
// WiFi & Network
// =====================================================================
void DoorbellLogic::syncNTP() {
if (_timeClient->update()) {
_ntpSynced = true;
_lastEpoch = _timeClient->getEpochTime();
}
}
void DoorbellLogic::checkNetwork() {
Serial.printf("[NET] WiFi:%s RSSI:%d IP:%s\n",
WiFi.SSID().c_str(), WiFi.RSSI(), WiFi.localIP().toString().c_str());
IPAddress ip;
if (!WiFi.hostByName("ntfy.sh", ip)) {
Serial.println("[NET] DNS FAILED");
_networkOK = false;
return;
}
Serial.printf("[NET] DNS OK: %s\n", ip.toString().c_str());
WiFiClientSecure tls;
tls.setInsecure();
if (tls.connect("ntfy.sh", 443, 15000)) {
Serial.println("[NET] TLS OK");
tls.stop();
_networkOK = true;
} else {
Serial.println("[NET] TLS FAILED");
_networkOK = false;
}
}
// =====================================================================
// ntfy Polling
// =====================================================================
void DoorbellLogic::pollTopics() {
Serial.printf("[POLL] Starting poll cycle... WiFi:%s NTP:%d Grace:%d\n",
WiFi.isConnected() ? "OK" : "DOWN", _ntpSynced, _inBootGrace);
pollTopic(ALERT_URL, &DoorbellLogic::handleAlert, "ALERT", _lastAlertId);
yield();
pollTopic(SILENCE_URL, &DoorbellLogic::handleSilence, "SILENCE", _lastSilenceId);
yield();
pollTopic(ADMIN_URL, &DoorbellLogic::handleAdmin, "ADMIN", _lastAdminId);
Serial.printf("[POLL] Done. Heap: %dKB\n", ESP.getFreeHeap() / 1024);
}
void DoorbellLogic::pollTopic(const char* url,
void (DoorbellLogic::*handler)(const String&),
const char* name, String& lastId) {
Serial.printf("[%s] Polling: %s\n", name, url);
if (!WiFi.isConnected()) {
Serial.printf("[%s] SKIPPED — WiFi down\n", name);
return;
}
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(10);
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setTimeout(10000);
http.setReuse(false);
if (!http.begin(client, url)) {
Serial.printf("[%s] begin() FAILED\n", name);
return;
}
int code = http.GET();
Serial.printf("[%s] HTTP %d\n", name, code);
if (code == HTTP_CODE_OK) {
String response = http.getString();
Serial.printf("[%s] %d bytes\n", name, response.length());
if (response.length() > 0) {
parseMessages(response, name, handler, lastId);
} else {
Serial.printf("[%s] Empty response\n", name);
}
} else if (code < 0) {
Serial.printf("[%s] ERROR: %s\n", name, http.errorToString(code).c_str());
_networkOK = false;
} else {
Serial.printf("[%s] Unexpected code: %d\n", name, code);
}
http.end();
client.stop();
yield();
}
void DoorbellLogic::parseMessages(String& response, const char* name,
void (DoorbellLogic::*handler)(const String&),
String& lastId) {
Serial.printf("[%s] parseMessages: grace=%d ntp=%d epoch=%ld\n",
name, _inBootGrace, _ntpSynced, (long)_lastEpoch);
if (_inBootGrace || !_ntpSynced || _lastEpoch == 0) {
Serial.printf("[%s] SKIPPED — guard failed\n", name);
return;
}
int lineStart = 0;
int msgCount = 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) {
msgCount++;
JsonDocument doc;
if (deserializeJson(doc, line)) {
Serial.printf("[%s] JSON parse FAILED on line %d\n", name, msgCount);
lineStart = lineEnd + 1;
continue;
}
const char* event = doc["event"];
const char* msgId = doc["id"];
const char* message = doc["message"];
time_t msgTime = doc["time"] | 0;
Serial.printf("[%s] msg#%d: event=%s id=%s time=%ld msg=%.30s\n",
name, msgCount,
event ? event : "null",
msgId ? msgId : "null",
(long)msgTime,
message ? message : "null");
if (event && strcmp(event, "message") != 0) {
Serial.printf("[%s] SKIP — not a message event (event=%s)\n", name, event);
lineStart = lineEnd + 1;
continue;
}
if (!message || strlen(message) == 0) {
Serial.printf("[%s] SKIP — empty message\n", name);
lineStart = lineEnd + 1;
continue;
}
String idStr = msgId ? String(msgId) : "";
if (idStr.length() > 0 && idStr == lastId) {
Serial.printf("[%s] SKIP — dedup (id=%s)\n", name, msgId);
lineStart = lineEnd + 1;
continue;
}
if (msgTime > 0 && (_lastEpoch - msgTime) > (time_t)STALE_MSG_THRESHOLD_S) {
Serial.printf("[%s] SKIP — stale (age=%llds, threshold=%ds)\n",
name, (long long)(_lastEpoch - msgTime), STALE_MSG_THRESHOLD_S);
lineStart = lineEnd + 1;
continue;
}
Serial.printf("[%s] ACCEPTED: %.50s\n", name, message);
if (idStr.length() > 0) lastId = idStr;
_lastParsedMsgEpoch = msgTime;
(this->*handler)(String(message));
_lastParsedMsgEpoch = 0;
}
lineStart = lineEnd + 1;
}
Serial.printf("[%s] Parsed %d JSON objects\n", name, msgCount);
}
// =====================================================================
// Status Publishing
// =====================================================================
void DoorbellLogic::queueStatus(const char* st, const String& msg) {
_pendingStatus = true;
_pendStatusState = st;
_pendStatusMsg = msg;
Serial.printf("[STATUS] Queued: %s — %s\n", st, msg.c_str());
}
void DoorbellLogic::flushStatus() {
if (!_pendingStatus || !WiFi.isConnected()) return;
_pendingStatus = false;
JsonDocument doc;
doc["state"] = _pendStatusState;
doc["message"] = _pendStatusMsg;
doc["timestamp"] = _ntpSynced ? (long long)_timeClient->getEpochTime() * 1000LL : 0LL;
String payload;
serializeJson(doc, payload);
WiFiClientSecure client;
client.setInsecure();
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.begin(client, STATUS_URL);
http.addHeader("Content-Type", "application/json");
int code = http.POST(payload);
http.end();
Serial.printf("[STATUS] Sent (%d): %s\n", code, _pendStatusState.c_str());
}
DoorbellLogic* DoorbellLogic::_instance = nullptr;
void DoorbellLogic::onWiFiEvent(WiFiEvent_t event) {
if (!_instance) return;
switch (event) {
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
Serial.println("[WIFI] Disconnected — will reconnect");
WiFi.reconnect();
break;
case ARDUINO_EVENT_WIFI_STA_CONNECTED:
Serial.println("[WIFI] Reconnected to AP");
break;
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
Serial.printf("[WIFI] Got IP: %s\n", WiFi.localIP().toString().c_str());
break;
default:
break;
}
}