537 lines
16 KiB
C++
537 lines
16 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; // ← MISSING
|
|
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setSleep(false);
|
|
WiFi.setAutoReconnect(true); // ← MISSING
|
|
WiFi.onEvent(onWiFiEvent); // ← MISSING
|
|
|
|
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;
|
|
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;
|
|
_alertStartEpoch = 0; // <-- ADD: clear on silence
|
|
Serial.println("-> SILENT");
|
|
break;
|
|
case DeviceState::ALERTING:
|
|
_alertStart = now;
|
|
_alertStartEpoch = _lastEpoch; // <-- ADD: record NTP time of alert
|
|
_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) {
|
|
// When called from ntfy poll, reject silence messages that predate the current alert.
|
|
// This prevents stale silence messages from immediately canceling new alerts.
|
|
// When called from touch/admin/timeout, _lastParsedMsgEpoch is 0 → bypass check.
|
|
if (_state == DeviceState::ALERTING &&
|
|
_lastParsedMsgEpoch > 0 &&
|
|
_alertStartEpoch > 0 &&
|
|
_lastParsedMsgEpoch <= _alertStartEpoch) {
|
|
Serial.printf("[SILENCE] Ignored — predates alert (msg:%ld alert:%ld)\n",
|
|
(long)_lastParsedMsgEpoch, (long)_alertStartEpoch);
|
|
return;
|
|
}
|
|
|
|
_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() {
|
|
Serial.println("[POLL] Starting poll cycle...");
|
|
pollTopic(ALERT_URL, &DoorbellLogic::handleAlert, "ALERT", _lastAlertId);
|
|
yield(); // ← MISSING
|
|
pollTopic(SILENCE_URL, &DoorbellLogic::handleSilence, "SILENCE", _lastSilenceId);
|
|
yield(); // ← MISSING
|
|
pollTopic(ADMIN_URL, &DoorbellLogic::handleAdmin, "ADMIN", _lastAdminId);
|
|
Serial.printf("[POLL] Done. Heap: %dKB\n", ESP.getFreeHeap() / 1024); // ← MISSING
|
|
}
|
|
|
|
void DoorbellLogic::pollTopic(const char* url,
|
|
void (DoorbellLogic::*handler)(const String&),
|
|
const char* name, String& lastId) {
|
|
if (!WiFi.isConnected()) { // ← MISSING
|
|
Serial.printf("[%s] Skipped — WiFi down\n", name);
|
|
return;
|
|
}
|
|
|
|
WiFiClientSecure client;
|
|
client.setInsecure();
|
|
client.setTimeout(10); // ← MISSING
|
|
|
|
HTTPClient http;
|
|
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
|
http.setTimeout(10000);
|
|
http.setReuse(false); // ← MISSING
|
|
|
|
// ... rest of method ...
|
|
|
|
http.end();
|
|
client.stop(); // ← MISSING
|
|
|
|
yield(); // ← MISSING
|
|
}
|
|
|
|
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 (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());
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
|