diff --git a/sketches/doorbell-touch-esp32-32e/Config.h b/sketches/doorbell-touch-esp32-32e/Config.h new file mode 100644 index 0000000..a9847aa --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/Config.h @@ -0,0 +1,90 @@ +#pragma once + +// ===================================================================== +// Debug +// ===================================================================== +#define DEBUG_MODE 1 + +// ===================================================================== +// WiFi Credentials +// ===================================================================== +struct WiFiCred { const char *ssid; const char *pass; }; +static WiFiCred wifiNetworks[] = { + { "Dobro Veče", "goodnight" }, + { "berylpunk", "dhgwilliam" }, +}; +static const int NUM_WIFI = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); + +// ===================================================================== +// ntfy.sh Topics +// ===================================================================== +#define NTFY_BASE "https://ntfy.sh" + +#if DEBUG_MODE + #define TOPIC_SUFFIX "_test" +#else + #define TOPIC_SUFFIX "" +#endif + +#define ALERT_URL NTFY_BASE "/ALERT_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define SILENCE_URL NTFY_BASE "/SILENCE_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define ADMIN_URL NTFY_BASE "/ADMIN_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1" +#define STATUS_URL NTFY_BASE "/STATUS_klubhaus_topic" TOPIC_SUFFIX + +// ===================================================================== +// Timing +// ===================================================================== +#define POLL_INTERVAL_MS 15000 +#define BLINK_INTERVAL_MS 500 +#define STALE_MSG_THRESHOLD_S 600 +#define NTP_SYNC_INTERVAL_MS 3600000 +#define ALERT_TIMEOUT_MS 300000 +#define WAKE_DISPLAY_MS 5000 +#define TOUCH_DEBOUNCE_MS 300 +#define HEARTBEAT_INTERVAL_MS 30000 + +#if DEBUG_MODE + #define BOOT_GRACE_MS 5000 +#else + #define BOOT_GRACE_MS 30000 +#endif + +// ===================================================================== +// E32R35T Hardware Pins (hardwired on PCB — do not change) +// ===================================================================== +// LCD (HSPI) +#define PIN_LCD_CS 15 +#define PIN_LCD_DC 2 +#define PIN_LCD_MOSI 13 +#define PIN_LCD_SCLK 14 +#define PIN_LCD_MISO 12 +#define PIN_LCD_BL 27 + +// Touch (XPT2046, shares HSPI) +#define PIN_TOUCH_CS 33 +#define PIN_TOUCH_IRQ 36 + +// SD Card (VSPI — for future use) +#define PIN_SD_CS 5 +#define PIN_SD_MOSI 23 +#define PIN_SD_SCLK 18 +#define PIN_SD_MISO 19 + +// RGB LED (active low) +#define PIN_LED_RED 22 +#define PIN_LED_GREEN 16 +#define PIN_LED_BLUE 17 + +// Audio +#define PIN_AUDIO_EN 4 +#define PIN_AUDIO_DAC 26 + +// Battery ADC +#define PIN_BAT_ADC 34 + +// ===================================================================== +// Display +// ===================================================================== +#define SCREEN_WIDTH 480 // landscape +#define SCREEN_HEIGHT 320 + diff --git a/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp b/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp new file mode 100644 index 0000000..5d58a40 --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp @@ -0,0 +1,202 @@ +#include "DisplayManager.h" +#include "Config.h" + +void DisplayManager::begin() { + pinMode(PIN_LCD_BL, OUTPUT); + setBacklight(true); + + _tft.init(); + _tft.setRotation(1); // landscape: 480x320 + _tft.fillScreen(COL_BLACK); +} + +void DisplayManager::setBacklight(bool on) { + digitalWrite(PIN_LCD_BL, on ? HIGH : LOW); +} + +TouchEvent DisplayManager::readTouch() { + TouchEvent evt; + uint16_t x, y; + if (_tft.getTouch(&x, &y)) { + evt.pressed = true; + evt.x = x; + evt.y = y; + } + return evt; +} + +void DisplayManager::render(const ScreenState& state) { + // Detect screen change → force full redraw + if (state.screen != _lastScreen) { + _needsFullRedraw = true; + _lastScreen = state.screen; + } + + switch (state.screen) { + case ScreenID::BOOT_SPLASH: + if (_needsFullRedraw) drawBootSplash(state); + break; + case ScreenID::WIFI_CONNECTING: + if (_needsFullRedraw) drawWifiConnecting(); + break; + case ScreenID::WIFI_CONNECTED: + if (_needsFullRedraw) drawWifiConnected(state); + break; + case ScreenID::WIFI_FAILED: + if (_needsFullRedraw) drawWifiFailed(); + break; + case ScreenID::ALERT: + // Alert redraws every blink cycle + if (_needsFullRedraw || state.blinkPhase != _lastBlink) { + drawAlertScreen(state); + _lastBlink = state.blinkPhase; + } + break; + case ScreenID::STATUS: + if (_needsFullRedraw) drawStatusScreen(state); + break; + case ScreenID::OFF: + break; + } + + _needsFullRedraw = false; +} + +// ----- Helpers ----- + +void DisplayManager::drawCentered(const char* txt, int y, int sz, uint16_t col) { + _tft.setTextSize(sz); + _tft.setTextColor(col); + int w = strlen(txt) * 6 * sz; + _tft.setCursor(max(0, (SCREEN_WIDTH - w) / 2), y); + _tft.print(txt); +} + +void DisplayManager::drawInfoLine(int x, int y, uint16_t col, const char* text) { + _tft.setTextSize(1); + _tft.setTextColor(col); + _tft.setCursor(x, y); + _tft.print(text); +} + +void DisplayManager::drawHeaderBar(uint16_t col, const char* label, const char* timeStr) { + _tft.setTextSize(2); + _tft.setTextColor(col); + _tft.setCursor(8, 8); + _tft.print(label); + + int tw = strlen(timeStr) * 12; + _tft.setCursor(SCREEN_WIDTH - tw - 8, 8); + _tft.print(timeStr); +} + +// ----- Screens ----- + +void DisplayManager::drawBootSplash(const ScreenState& s) { + _tft.fillScreen(COL_BLACK); + drawCentered("KLUBHAUS", 60, 4, COL_NEON_TEAL); + drawCentered("ALERT", 110, 4, COL_HOT_FUCHSIA); + drawCentered("v5.0 E32R35T", 180, 2, COL_DARK_GRAY); + if (s.debugMode) { + drawCentered("DEBUG MODE", 210, 2, COL_YELLOW); + } +} + +void DisplayManager::drawWifiConnecting() { + _tft.fillScreen(COL_BLACK); + drawCentered("Connecting", 130, 3, COL_NEON_TEAL); + drawCentered("to WiFi...", 170, 3, COL_NEON_TEAL); +} + +void DisplayManager::drawWifiConnected(const ScreenState& s) { + _tft.fillScreen(COL_BLACK); + drawCentered("Connected!", 100, 3, COL_GREEN); + drawCentered(s.wifiSSID, 150, 2, COL_WHITE); + drawCentered(s.wifiIP, 180, 2, COL_WHITE); +} + +void DisplayManager::drawWifiFailed() { + _tft.fillScreen(COL_BLACK); + drawCentered("WiFi FAILED", 140, 3, COL_RED); +} + +void DisplayManager::drawAlertScreen(const ScreenState& s) { + uint16_t bg = s.blinkPhase ? COL_NEON_TEAL : COL_HOT_FUCHSIA; + uint16_t fg = s.blinkPhase ? COL_BLACK : COL_WHITE; + + _tft.fillScreen(bg); + drawHeaderBar(fg, "ALERT", s.timeString); + + // Scale text to fit 480px wide screen + int sz = 5; + int len = strlen(s.alertMessage); + if (len > 10) sz = 4; + if (len > 18) sz = 3; + if (len > 30) sz = 2; + + if (len > 12) { + // Two-line split + String msg(s.alertMessage); + int mid = len / 2; + int sp = msg.lastIndexOf(' ', mid); + if (sp < 0) sp = mid; + String l1 = msg.substring(0, sp); + String l2 = msg.substring(sp + 1); + int lh = 8 * sz + 8; + int y1 = (SCREEN_HEIGHT - lh * 2) / 2; + drawCentered(l1.c_str(), y1, sz, fg); + drawCentered(l2.c_str(), y1 + lh, sz, fg); + } else { + drawCentered(s.alertMessage, (SCREEN_HEIGHT - 8 * sz) / 2, sz, fg); + } + + drawCentered("TAP TO SILENCE", SCREEN_HEIGHT - 25, 2, fg); +} + +void DisplayManager::drawStatusScreen(const ScreenState& s) { + _tft.fillScreen(COL_BLACK); + drawHeaderBar(COL_MINT, "KLUBHAUS", s.timeString); + + drawCentered("MONITORING", 60, 3, COL_WHITE); + + char buf[80]; + int y = 110; + int sp = 22; + int x = 20; + + snprintf(buf, sizeof(buf), "WiFi: %s (%ddBm)", + s.wifiConnected ? s.wifiSSID : "DOWN", s.wifiRSSI); + drawInfoLine(x, y, COL_WHITE, buf); y += sp; + + snprintf(buf, sizeof(buf), "IP: %s", + s.wifiConnected ? s.wifiIP : "---"); + drawInfoLine(x, y, COL_WHITE, buf); y += sp; + + snprintf(buf, sizeof(buf), "Up: %lu min Heap: %lu KB", + s.uptimeMinutes, s.freeHeapKB); + drawInfoLine(x, y, COL_WHITE, buf); y += sp; + + snprintf(buf, sizeof(buf), "NTP: %s UTC", + s.ntpSynced ? s.timeString : "not synced"); + drawInfoLine(x, y, COL_WHITE, buf); y += sp; + + const char* stName = s.deviceState == DeviceState::SILENT ? "SILENT" : + s.deviceState == DeviceState::ALERTING ? "ALERTING" : "WAKE"; + snprintf(buf, sizeof(buf), "State: %s Net: %s", + stName, s.networkOK ? "OK" : "FAIL"); + uint16_t stCol = s.deviceState == DeviceState::ALERTING ? COL_RED : + s.deviceState == DeviceState::SILENT ? COL_GREEN : COL_NEON_TEAL; + drawInfoLine(x, y, stCol, buf); y += sp; + + if (strlen(s.alertMessage) > 0) { + snprintf(buf, sizeof(buf), "Last: %.40s", s.alertMessage); + drawInfoLine(x, y, COL_DARK_GRAY, buf); y += sp; + } + + if (s.debugMode) { + drawInfoLine(x, y, COL_YELLOW, "DEBUG MODE - _test topics"); + } + + drawCentered("tap to dismiss", SCREEN_HEIGHT - 18, 1, COL_DARK_GRAY); +} + diff --git a/sketches/doorbell-touch-esp32-32e/DisplayManager.h b/sketches/doorbell-touch-esp32-32e/DisplayManager.h new file mode 100644 index 0000000..c45d3bd --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/DisplayManager.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include "ScreenData.h" + +class DisplayManager { +public: + void begin(); + void render(const ScreenState& state); + void setBacklight(bool on); + TouchEvent readTouch(); + +private: + TFT_eSPI _tft; + ScreenID _lastScreen = ScreenID::BOOT_SPLASH; + bool _needsFullRedraw = true; + bool _lastBlink = false; + + // Colors + static constexpr uint16_t COL_NEON_TEAL = 0x07D7; + static constexpr uint16_t COL_HOT_FUCHSIA = 0xF81F; + static constexpr uint16_t COL_WHITE = 0xFFDF; + static constexpr uint16_t COL_BLACK = 0x0000; + static constexpr uint16_t COL_MINT = 0x67F5; + static constexpr uint16_t COL_DARK_GRAY = 0x2104; + static constexpr uint16_t COL_GREEN = 0x07E0; + static constexpr uint16_t COL_RED = 0xF800; + static constexpr uint16_t COL_YELLOW = 0xFFE0; + + // Screen renderers + void drawBootSplash(const ScreenState& s); + void drawWifiConnecting(); + void drawWifiConnected(const ScreenState& s); + void drawWifiFailed(); + void drawAlertScreen(const ScreenState& s); + void drawStatusScreen(const ScreenState& s); + + // Helpers + void drawCentered(const char* txt, int y, int sz, uint16_t col); + void drawInfoLine(int x, int y, uint16_t col, const char* text); + void drawHeaderBar(uint16_t col, const char* label, const char* timeStr); +}; + diff --git a/sketches/doorbell-touch-esp32-32e/DoorbellLogic.cpp b/sketches/doorbell-touch-esp32-32e/DoorbellLogic.cpp new file mode 100644 index 0000000..6c0b125 --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/DoorbellLogic.cpp @@ -0,0 +1,453 @@ +#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()); +} + diff --git a/sketches/doorbell-touch-esp32-32e/DoorbellLogic.h b/sketches/doorbell-touch-esp32-32e/DoorbellLogic.h new file mode 100644 index 0000000..4f5c0b4 --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/DoorbellLogic.h @@ -0,0 +1,87 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "Config.h" +#include "ScreenData.h" + +class DoorbellLogic { +public: + void begin(); + + // Boot sequence (called individually so .ino can render between steps) + void beginWiFi(); + void connectWiFiBlocking(); + void finishBoot(); + + void update(); + + const ScreenState& getScreenState() const { return _screen; } + + // Input events from the outside + void onTouch(const TouchEvent& evt); + void onSerialCommand(const String& cmd); + +private: + ScreenState _screen; + + // State + DeviceState _state = DeviceState::SILENT; + String _currentMessage = ""; + unsigned long _bootTime = 0; + bool _inBootGrace = true; + bool _networkOK = false; + + // Dedup + String _lastAlertId; + String _lastSilenceId; + String _lastAdminId; + + // Timing + unsigned long _lastPoll = 0; + unsigned long _lastBlink = 0; + unsigned long _alertStart = 0; + unsigned long _wakeStart = 0; + unsigned long _lastHeartbeat = 0; + bool _blinkState = false; + + // Deferred status publish + bool _pendingStatus = false; + String _pendStatusState; + String _pendStatusMsg; + + // NTP + WiFiUDP _ntpUDP; + NTPClient _timeClient = nullptr; + bool _ntpSynced = false; + time_t _lastEpoch = 0; + + // WiFi + WiFiMulti _wifiMulti; + + // Methods + void checkNetwork(); + void syncNTP(); + + void pollTopics(); + void pollTopic(const char* url, void (DoorbellLogic::*handler)(const String&), + const char* name, String& lastId); + void parseMessages(String& response, const char* name, + void (DoorbellLogic::*handler)(const String&), + String& lastId); + + void handleAlert(const String& msg); + void handleSilence(const String& msg); + void handleAdmin(const String& msg); + + void transitionTo(DeviceState newState); + void queueStatus(const char* state, const String& msg); + void flushStatus(); + + void updateScreenState(); +}; + diff --git a/sketches/doorbell-touch-esp32-32e/ScreenData.h b/sketches/doorbell-touch-esp32-32e/ScreenData.h new file mode 100644 index 0000000..6f2ce34 --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/ScreenData.h @@ -0,0 +1,58 @@ +#pragma once +#include + +// ===================================================================== +// Shared enums and structs — NO library dependencies +// ===================================================================== + +enum class DeviceState : uint8_t { + SILENT, + ALERTING, + WAKE +}; + +enum class ScreenID : uint8_t { + BOOT_SPLASH, + WIFI_CONNECTING, + WIFI_CONNECTED, + WIFI_FAILED, + ALERT, + STATUS, + OFF // backlight off, nothing to draw +}; + +// Everything the display needs to render any screen +struct ScreenState { + ScreenID screen = ScreenID::BOOT_SPLASH; + DeviceState deviceState = DeviceState::SILENT; + bool blinkPhase = false; + + // Alert + char alertMessage[64] = ""; + + // WiFi + bool wifiConnected = false; + char wifiSSID[33] = ""; + int wifiRSSI = 0; + char wifiIP[16] = ""; + + // NTP + bool ntpSynced = false; + char timeString[12] = ""; + + // System + uint32_t uptimeMinutes = 0; + uint32_t freeHeapKB = 0; + bool networkOK = false; + + // Debug + bool debugMode = false; +}; + +// Touch event passed from display to logic +struct TouchEvent { + bool pressed = false; + uint16_t x = 0; + uint16_t y = 0; +}; + diff --git a/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino b/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino index 72c166d..316f3a3 100644 --- a/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino +++ b/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino @@ -1,58 +1,78 @@ -#include -#include +/* + * KLUBHAUS ALERT v5.0 — E32R35T Edition + * + * Target: LCDWiki E32R35T (ESP32-WROOM-32E + 3.5" ST7796S + XPT2046) + * + * Refactored: business logic separated from display code. + * Business logic knows nothing about TFT_eSPI. + * Display knows nothing about ntfy/WiFi/state machine. + * They communicate through ScreenState (plain struct). + */ -TFT_eSPI tft = TFT_eSPI(); +#include "Config.h" +#include "DisplayManager.h" +#include "DoorbellLogic.h" + +DisplayManager display; +DoorbellLogic logic; void setup() { Serial.begin(115200); - delay(2000); + unsigned long t = millis(); + while (!Serial && millis() - t < 3000) delay(10); + delay(500); - // Backlight ON - pinMode(27, OUTPUT); - digitalWrite(27, HIGH); - - // Config verification - Serial.println("\n=== E32R35T Config ==="); - #if defined(USER_SETUP_LOADED) - Serial.println("User_Setup: LOADED"); - #else - Serial.println("User_Setup: DEFAULT (BAD!)"); + Serial.println("\n========================================"); + Serial.println(" KLUBHAUS ALERT v5.0 — E32R35T"); + #if DEBUG_MODE + Serial.println(" *** DEBUG MODE — _test topics ***"); #endif - #if defined(USE_HSPI_PORT) - Serial.println("SPI Bus: HSPI"); - #else - Serial.println("SPI Bus: VSPI"); - #endif - Serial.printf("MOSI:%d SCLK:%d MISO:%d\n", TFT_MOSI, TFT_SCLK, TFT_MISO); - Serial.printf("CS:%d DC:%d RST:%d BL:%d\n", TFT_CS, TFT_DC, TFT_RST, TFT_BL); - Serial.printf("TOUCH_CS:%d\n", TOUCH_CS); - Serial.printf("SPI_FREQ:%d\n", SPI_FREQUENCY); - Serial.println("======================\n"); + Serial.println("========================================"); - tft.init(); - tft.setRotation(1); + // 1. Init display hardware + display.begin(); - uint16_t colors[] = {TFT_RED, TFT_GREEN, TFT_BLUE, TFT_WHITE, TFT_BLACK}; - const char* names[] = {"RED", "GREEN", "BLUE", "WHITE", "BLACK"}; + // 2. Init logic (sets boot splash screen state) + logic.begin(); + display.render(logic.getScreenState()); + delay(1500); - for (int i = 0; i < 5; i++) { - tft.fillScreen(colors[i]); - Serial.printf("Screen: %s\n", names[i]); - delay(1500); - } + // 3. WiFi (logic updates screen state, we render each phase) + // We need a small coupling here for the blocking WiFi connect + // This could be made async later + logic.beginWiFi(); // sets screen to WIFI_CONNECTING + display.render(logic.getScreenState()); - tft.fillScreen(TFT_BLACK); - tft.setTextColor(TFT_GREEN, TFT_BLACK); - tft.setTextSize(3); - tft.drawString("E32R35T Works!", 30, 100); - Serial.println("Done!"); + logic.connectWiFiBlocking(); // blocks, sets CONNECTED or FAILED + display.render(logic.getScreenState()); + delay(1500); + + // 4. Finish boot + logic.finishBoot(); + display.setBacklight(false); + + Serial.println("[BOOT] Ready — monitoring ntfy.sh\n"); } void loop() { - uint16_t x, y; - if (tft.getTouch(&x, &y)) { - Serial.printf("Touch: %d, %d\n", x, y); - tft.fillCircle(x, y, 4, TFT_YELLOW); - } -} + // 1. Read touch + TouchEvent touch = display.readTouch(); + logic.onTouch(touch); + // 2. Read serial commands + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + logic.onSerialCommand(cmd); + } + + // 3. Update business logic + logic.update(); + + // 4. Render + const ScreenState& state = logic.getScreenState(); + display.setBacklight(state.screen != ScreenID::OFF); + display.render(state); + + delay(20); +} diff --git a/sketches/doorbell-touch-esp32-32e/mise.toml b/sketches/doorbell-touch-esp32-32e/mise.toml index 00dbedf..fb8a73e 100644 --- a/sketches/doorbell-touch-esp32-32e/mise.toml +++ b/sketches/doorbell-touch-esp32-32e/mise.toml @@ -4,7 +4,6 @@ lazygit = "latest" [env] FQBN = "esp32:esp32:esp32:UploadSpeed=921600,CPUFreq=240,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=default,DebugLevel=info,EraseFlash=none" -SKETCH_DIR = "{{cwd}}" [tasks.install-core] run = "arduino-cli core update-index && arduino-cli core install esp32:esp32" @@ -25,8 +24,8 @@ depends = ["compile"] run = "arduino-cli upload --fqbn $FQBN -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyUSB\\|CP210\\|CH340' | head -1 | awk '{print $1}') ." [tasks.monitor] -depends = ["upload"] -run = "arduino-cli monitor -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyUSB\\|CP210\\|CH340' | head -1 | awk '{print $1}') -c baudrate=115200" +run = "tio /dev/ttyUSB0" [tasks.all] -depends = ["monitor"] +depends = ["upload"] +run = "mise run monitor"