#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; } }