#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() { 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(); } 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 - _alertStart > ALERT_TIMEOUT_MS) { Serial.println("[ALERT] Timeout — auto-silencing"); handleSilence("timeout"); break; } 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; Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB\n", now / 1000, _state == DeviceState::SILENT ? "SILENT" : _state == DeviceState::ALERTING ? "ALERT" : "WAKE", WiFi.isConnected() ? "OK" : "DOWN", ESP.getFreeHeap() / 1024); } 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; 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::STATUS; Serial.println("-> WAKE"); break; } } // ===================================================================== // Message Handlers // ===================================================================== void DoorbellLogic::handleAlert(const String& msg) { if (_state == DeviceState::ALERTING && _currentMessage == msg) return; _currentMessage = msg; transitionTo(DeviceState::ALERTING); queueStatus("ALERTING", msg); } void DoorbellLogic::handleSilence(const String& msg) { _currentMessage = ""; 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() { pollTopic(ALERT_URL, &DoorbellLogic::handleAlert, "ALERT", _lastAlertId); pollTopic(SILENCE_URL, &DoorbellLogic::handleSilence, "SILENCE", _lastSilenceId); pollTopic(ADMIN_URL, &DoorbellLogic::handleAdmin, "ADMIN", _lastAdminId); } void DoorbellLogic::pollTopic(const char* url, void (DoorbellLogic::*handler)(const String&), const char* name, 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", name); return; } int code = http.GET(); if (code == HTTP_CODE_OK) { String response = http.getString(); if (response.length() > 0) { parseMessages(response, name, handler, lastId); } } else if (code < 0) { Serial.printf("[%s] HTTP error: %s\n", name, http.errorToString(code).c_str()); } http.end(); } void DoorbellLogic::parseMessages(String& response, const char* name, void (DoorbellLogic::*handler)(const String&), String& lastId) { if (_inBootGrace || !_ntpSynced || _lastEpoch == 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) { lineStart = lineEnd + 1; continue; } String idStr = msgId ? String(msgId) : ""; if (idStr.length() > 0 && idStr == lastId) { lineStart = lineEnd + 1; continue; } if (msgTime > 0 && (_lastEpoch - msgTime) > (time_t)STALE_MSG_THRESHOLD_S) { Serial.printf("[%s] Stale: %.30s (age %llds)\n", name, message, (long long)(_lastEpoch - msgTime)); lineStart = lineEnd + 1; continue; } Serial.printf("[%s] %.50s\n", name, message); if (idStr.length() > 0) lastId = idStr; (this->*handler)(String(message)); } } lineStart = lineEnd + 1; } } // ===================================================================== // Status Publishing (deferred) // ===================================================================== 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()); }