diff --git a/sketches/doorbell-touch-esp32-32e/DisplayDriverGFX.cpp b/sketches/doorbell-touch-esp32-32e/DisplayDriverGFX.cpp index 30659d5..7e0cf58 100644 --- a/sketches/doorbell-touch-esp32-32e/DisplayDriverGFX.cpp +++ b/sketches/doorbell-touch-esp32-32e/DisplayDriverGFX.cpp @@ -23,10 +23,12 @@ void Gfx::init() { LCD_R0, LCD_R1, LCD_R2, LCD_R3, LCD_R4, LCD_G0, LCD_G1, LCD_G2, LCD_G3, LCD_G4, LCD_G5, LCD_B0, LCD_B1, LCD_B2, LCD_B3, LCD_B4, - 1 /* hsync_polarity */, 46 /* hsync_front_porch */, - 2 /* hsync_pulse_width */, 44 /* hsync_back_porch */, - 1 /* vsync_polarity */, 50 /* vsync_front_porch */, - 16 /* vsync_pulse_width */, 16 /* vsync_back_porch */ + 1 /* hsync_polarity */, 10 /* hsync_front_porch */, + 8 /* hsync_pulse_width */, 50 /* hsync_back_porch */, + 1 /* vsync_polarity */, 10 /* vsync_front_porch */, + 8 /* vsync_pulse_width */, 20 /* vsync_back_porch */, + 1 /* pclk_active_neg */, + 16000000 /* prefer_speed = 16MHz PCLK */ ); _gfx = new Arduino_RGB_Display( 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 c3d9d81..6b107f9 100644 --- a/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino +++ b/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino @@ -60,6 +60,8 @@ void setup() { logic.finishBoot(); display.setBacklight(false); + Serial.printf("[MEM] Free heap: %d | Free PSRAM: %d\n", + ESP.getFreeHeap(), ESP.getFreePsram()); Serial.println("[BOOT] Ready — monitoring ntfy.sh\n"); } @@ -72,5 +74,64 @@ void silenceAlerts() { } void loop() { + logic.update(); + display.render(logic.getScreenState()); + + // Touch → hold-to-silence gesture + TouchEvent evt = display.readTouch(); + if (evt.pressed) { + // Dashboard tile tap + if (logic.getScreenState().screen == ScreenID::DASHBOARD) { + int tile = display.dashboardTouch(evt.x, evt.y); + if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile); + } + } + + // Hold-to-silence during ALERT + if (logic.getScreenState().deviceState == DeviceState::ALERTING) { + HoldState h = display.updateHold(HOLD_TO_SILENCE_MS); + if (h.completed) silenceAlerts(); + + // Hint animation when not touching + if (!h.active) display.updateHint(); + } + + // Serial commands + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd.length() > 0) logic.onSerialCommand(cmd); + } +} + +void loop() { + logic.update(); + display.render(logic.getScreenState()); + + // Touch → hold-to-silence gesture + TouchEvent evt = display.readTouch(); + if (evt.pressed) { + // Dashboard tile tap + if (logic.getScreenState().screen == ScreenID::DASHBOARD) { + int tile = display.dashboardTouch(evt.x, evt.y); + if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile); + } + } + + // Hold-to-silence during ALERT + if (logic.getScreenState().deviceState == DeviceState::ALERTING) { + HoldState h = display.updateHold(HOLD_TO_SILENCE_MS); + if (h.completed) silenceAlerts(); + + // Hint animation when not touching + if (!h.active) display.updateHint(); + } + + // Serial commands + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd.length() > 0) logic.onSerialCommand(cmd); + } } diff --git a/sketches/doorbell-touch-esp32-32e/scaffold.sh b/sketches/doorbell-touch-esp32-32e/scaffold.sh new file mode 100644 index 0000000..fb987f1 --- /dev/null +++ b/sketches/doorbell-touch-esp32-32e/scaffold.sh @@ -0,0 +1,1772 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ═══════════════════════════════════════════════════════════════ +# Klubhaus Doorbell — Project Scaffold v5.1 +# Creates the full multi-target build with: +# - Shared Arduino library (KlubhausCore) +# - Per-board sketch directories +# - Per-board vendored display libraries +# - mise.toml multi-target build harness +# ═══════════════════════════════════════════════════════════════ + +GREEN='\033[0;32m' +BLUE='\033[0;34m' +DIM='\033[2m' +NC='\033[0m' + +info() { echo -e "${GREEN}[+]${NC} $1"; } +section() { echo -e "\n${BLUE}━━━ $1 ━━━${NC}"; } + +section "Creating directory structure" + +mkdir -p libraries/KlubhausCore/src +mkdir -p vendor/esp32-32e +mkdir -p vendor/esp32-s3-lcd-43 +mkdir -p boards/esp32-32e +mkdir -p boards/esp32-s3-lcd-43 + +info "Directories created" + +# ───────────────────────────────────────────────────────────── +section "Shared Library: KlubhausCore" +# ───────────────────────────────────────────────────────────── + +# ── library.properties ────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/library.properties +name=KlubhausCore +version=5.1.0 +author=David +maintainer=David +sentence=Core logic for Klubhaus doorbell alert system +paragraph=Shared state machine, ntfy.sh polling, networking, and abstract display interface. +category=Other +url=https://git.notsosm.art/david/klubhaus-doorbell +architectures=esp32 +includes=KlubhausCore.h +depends=ArduinoJson,NTPClient +EOF +info "library.properties" + +# ── KlubhausCore.h (umbrella include) ────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/KlubhausCore.h +#pragma once + +// Umbrella header — board sketches just #include +#include "Config.h" +#include "ScreenState.h" +#include "IDisplayDriver.h" +#include "DisplayManager.h" +#include "NetManager.h" +#include "DoorbellLogic.h" +EOF +info "KlubhausCore.h" + +# ── Config.h (shared constants only — no pins, no secrets) ── + +cat << 'EOF' > libraries/KlubhausCore/src/Config.h +#pragma once + +#define FW_VERSION "5.1" + +// ── ntfy.sh ── +#define NTFY_SERVER "ntfy.sh" +#define ALERT_TOPIC "ALERT_klubhaus_topic" +#define SILENCE_TOPIC "SILENCE_klubhaus_topic" +#define ADMIN_TOPIC "ADMIN_klubhaus_topic" +#define STATUS_TOPIC "STATUS_klubhaus_topic" + +// ── Timing ── +#define POLL_INTERVAL_MS 15000 +#define HEARTBEAT_INTERVAL_MS 300000 +#define BOOT_GRACE_MS 5000 +#define HOLD_TO_SILENCE_MS 3000 +#define ALERT_TIMEOUT_MS 120000 +#define SILENCE_DISPLAY_MS 10000 +#define WIFI_CONNECT_TIMEOUT_MS 15000 +#define HTTP_TIMEOUT_MS 10000 + +// ── WiFi credential struct (populated in each board's secrets.h) ── +struct WiFiCred { + const char* ssid; + const char* pass; +}; +EOF +info "Config.h" + +# ── ScreenState.h ────────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/ScreenState.h +#pragma once +#include + +enum class DeviceState { + BOOTED, + SILENT, + ALERTING, + SILENCED +}; + +enum class ScreenID { + BOOT, + OFF, + ALERT, + DASHBOARD +}; + +struct ScreenState { + DeviceState deviceState = DeviceState::BOOTED; + ScreenID screen = ScreenID::BOOT; + + String alertTitle; + String alertBody; + uint32_t alertStartMs = 0; + uint32_t silenceStartMs = 0; + + bool backlightOn = false; + int wifiRssi = 0; + String wifiSsid; + String ipAddr; + uint32_t uptimeMs = 0; + uint32_t lastPollMs = 0; + uint32_t lastHeartbeatMs= 0; +}; + +inline const char* deviceStateStr(DeviceState s) { + switch (s) { + case DeviceState::BOOTED: return "BOOTED"; + case DeviceState::SILENT: return "SILENT"; + case DeviceState::ALERTING: return "ALERTING"; + case DeviceState::SILENCED: return "SILENCED"; + } + return "?"; +} + +inline const char* screenIdStr(ScreenID s) { + switch (s) { + case ScreenID::BOOT: return "BOOT"; + case ScreenID::OFF: return "OFF"; + case ScreenID::ALERT: return "ALERT"; + case ScreenID::DASHBOARD: return "DASHBOARD"; + } + return "?"; +} +EOF +info "ScreenState.h" + +# ── IDisplayDriver.h (pure virtual interface) ────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/IDisplayDriver.h +#pragma once +#include "ScreenState.h" + +struct TouchEvent { + bool pressed = false; + int x = 0; + int y = 0; +}; + +struct HoldState { + bool active = false; + bool completed = false; + float progress = 0.0f; // 0.0 – 1.0 +}; + +/// Abstract display driver — implemented per-board. +class IDisplayDriver { +public: + virtual ~IDisplayDriver() = default; + + virtual void begin() = 0; + virtual void setBacklight(bool on) = 0; + + /// Called every loop() iteration; draw the screen described by `state`. + virtual void render(const ScreenState& state) = 0; + + // ── Touch ── + virtual TouchEvent readTouch() = 0; + /// Returns tile index at (x,y), or -1 if none. + virtual int dashboardTouch(int x, int y) = 0; + /// Track a long-press gesture; returns progress/completion. + virtual HoldState updateHold(unsigned long holdMs) = 0; + /// Idle hint animation (e.g. pulsing ring) while alert is showing. + virtual void updateHint() = 0; + + virtual int width() = 0; + virtual int height() = 0; +}; +EOF +info "IDisplayDriver.h" + +# ── DisplayManager.h (thin delegation wrapper) ───────────── + +cat << 'EOF' > libraries/KlubhausCore/src/DisplayManager.h +#pragma once +#include "IDisplayDriver.h" + +/// Owns a pointer to the concrete driver; all calls delegate. +/// Board sketch creates the concrete driver and passes it in. +class DisplayManager { +public: + DisplayManager() : _drv(nullptr) {} + explicit DisplayManager(IDisplayDriver* drv) : _drv(drv) {} + void setDriver(IDisplayDriver* drv) { _drv = drv; } + + void begin() { if (_drv) _drv->begin(); } + void setBacklight(bool on) { if (_drv) _drv->setBacklight(on); } + void render(const ScreenState& st) { if (_drv) _drv->render(st); } + TouchEvent readTouch() { return _drv ? _drv->readTouch() : TouchEvent{}; } + int dashboardTouch(int x, int y) { return _drv ? _drv->dashboardTouch(x, y) : -1; } + HoldState updateHold(unsigned long ms) { return _drv ? _drv->updateHold(ms) : HoldState{}; } + void updateHint() { if (_drv) _drv->updateHint(); } + int width() { return _drv ? _drv->width() : 0; } + int height() { return _drv ? _drv->height() : 0; } + +private: + IDisplayDriver* _drv; +}; +EOF +info "DisplayManager.h" + +# ── NetManager.h ─────────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/NetManager.h +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "Config.h" + +class NetManager { +public: + void begin(const WiFiCred* creds, int count); + bool checkConnection(); + bool isConnected(); + + String getSSID(); + String getIP(); + int getRSSI(); + + bool syncNTP(); + String getTimeStr(); + + bool dnsCheck(const char* host); + bool tlsCheck(const char* host); + + /// HTTPS GET; writes body into `response`. Returns HTTP status code. + int httpGet(const char* url, String& response); + /// HTTPS POST with text/plain body. Returns HTTP status code. + int httpPost(const char* url, const String& body); + +private: + WiFiMulti _multi; + WiFiUDP _udp; + NTPClient* _ntp = nullptr; + bool _ntpReady = false; +}; +EOF +info "NetManager.h" + +# ── NetManager.cpp ───────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/NetManager.cpp +#include "NetManager.h" + +// ── WiFi ──────────────────────────────────────────────────── + +void NetManager::begin(const WiFiCred* creds, int count) { + WiFi.mode(WIFI_STA); + for (int i = 0; i < count; i++) + _multi.addAP(creds[i].ssid, creds[i].pass); + + Serial.println("[WIFI] Connecting..."); + unsigned long t0 = millis(); + while (_multi.run() != WL_CONNECTED) { + if (millis() - t0 > WIFI_CONNECT_TIMEOUT_MS) { + Serial.println("[WIFI] Timeout!"); + return; + } + delay(250); + } + Serial.printf("[WIFI] Connected: %s %s\n", + getSSID().c_str(), getIP().c_str()); +} + +bool NetManager::checkConnection() { + if (WiFi.status() == WL_CONNECTED) return true; + Serial.println("[WIFI] Reconnecting..."); + return _multi.run(WIFI_CONNECT_TIMEOUT_MS) == WL_CONNECTED; +} + +bool NetManager::isConnected() { return WiFi.status() == WL_CONNECTED; } +String NetManager::getSSID() { return WiFi.SSID(); } +String NetManager::getIP() { return WiFi.localIP().toString(); } +int NetManager::getRSSI() { return WiFi.RSSI(); } + +// ── NTP ───────────────────────────────────────────────────── + +bool NetManager::syncNTP() { + Serial.println("[NTP] Starting sync..."); + if (!_ntp) _ntp = new NTPClient(_udp, "pool.ntp.org", 0, 60000); + _ntp->begin(); + _ntp->forceUpdate(); + _ntpReady = _ntp->isTimeSet(); + Serial.printf("[NTP] %s: %s UTC\n", + _ntpReady ? "Synced" : "FAILED", + _ntpReady ? _ntp->getFormattedTime().c_str() : "--"); + return _ntpReady; +} + +String NetManager::getTimeStr() { + return (_ntp && _ntpReady) ? _ntp->getFormattedTime() : "??:??:??"; +} + +// ── Diagnostics ───────────────────────────────────────────── + +bool NetManager::dnsCheck(const char* host) { + IPAddress ip; + bool ok = WiFi.hostByName(host, ip); + Serial.printf("[NET] DNS %s: %s\n", ok ? "OK" : "FAIL", + ok ? ip.toString().c_str() : ""); + return ok; +} + +bool NetManager::tlsCheck(const char* host) { + WiFiClientSecure c; + c.setInsecure(); + c.setTimeout(HTTP_TIMEOUT_MS); + bool ok = c.connect(host, 443); + if (ok) c.stop(); + Serial.printf("[NET] TLS %s\n", ok ? "OK" : "FAIL"); + return ok; +} + +// ── HTTP helpers ──────────────────────────────────────────── + +int NetManager::httpGet(const char* url, String& response) { + WiFiClientSecure client; + client.setInsecure(); + client.setTimeout(HTTP_TIMEOUT_MS); + + HTTPClient http; + http.setTimeout(HTTP_TIMEOUT_MS); + http.begin(client, url); + + int code = http.GET(); + if (code > 0) response = http.getString(); + http.end(); + return code; +} + +int NetManager::httpPost(const char* url, const String& body) { + WiFiClientSecure client; + client.setInsecure(); + client.setTimeout(HTTP_TIMEOUT_MS); + + HTTPClient http; + http.setTimeout(HTTP_TIMEOUT_MS); + http.begin(client, url); + http.addHeader("Content-Type", "text/plain"); + + int code = http.POST(body); + http.end(); + return code; +} +EOF +info "NetManager.cpp" + +# ── DoorbellLogic.h ──────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/DoorbellLogic.h +#pragma once +#include +#include +#include "Config.h" +#include "ScreenState.h" +#include "DisplayManager.h" +#include "NetManager.h" + +class DoorbellLogic { +public: + explicit DoorbellLogic(DisplayManager* display); + + /// Call from setup(). Pass board-specific WiFi creds. + void begin(const char* version, const char* boardName, + const WiFiCred* creds, int credCount); + /// Call from loop() — polls topics, runs timers, transitions state. + void update(); + /// Transition out of BOOTED → SILENT. Call at end of setup(). + void finishBoot(); + /// Serial debug console. + void onSerialCommand(const String& cmd); + + const ScreenState& getScreenState() const { return _state; } + + /// Externally trigger silence (e.g. hold-to-silence gesture). + void silenceAlert(); + +private: + void pollTopics(); + void pollTopic(const String& url, const char* label); + void onAlert(const String& title, const String& body); + void onSilence(); + void onAdmin(const String& command); + void flushStatus(const String& message); + void heartbeat(); + void transition(DeviceState s); + String topicUrl(const char* base); + + DisplayManager* _display; + NetManager _net; + ScreenState _state; + + const char* _version = ""; + const char* _board = ""; + bool _debug = false; + + uint32_t _lastPollMs = 0; + uint32_t _lastHeartbeatMs = 0; + uint32_t _bootGraceEnd = 0; + + String _alertUrl; + String _silenceUrl; + String _adminUrl; + String _statusUrl; +}; +EOF +info "DoorbellLogic.h" + +# ── DoorbellLogic.cpp ────────────────────────────────────── + +cat << 'EOF' > libraries/KlubhausCore/src/DoorbellLogic.cpp +#include "DoorbellLogic.h" + +DoorbellLogic::DoorbellLogic(DisplayManager* display) + : _display(display) {} + +// ── URL builder ───────────────────────────────────────────── + +String DoorbellLogic::topicUrl(const char* base) { + String suffix = _debug ? "_test" : ""; + return String("https://") + NTFY_SERVER + "/" + base + suffix + + "/json?since=20s&poll=1"; +} + +// ── Lifecycle ─────────────────────────────────────────────── + +void DoorbellLogic::begin(const char* version, const char* boardName, + const WiFiCred* creds, int credCount) { + _version = version; + _board = boardName; +#ifdef DEBUG_MODE + _debug = true; +#endif + + Serial.println(F("========================================")); + Serial.printf( " KLUBHAUS ALERT v%s — %s\n", _version, _board); + if (_debug) Serial.println(F(" *** DEBUG MODE — _test topics ***")); + Serial.println(F("========================================\n")); + + // Display + _display->begin(); + + // Network + _net.begin(creds, credCount); + + if (_net.isConnected()) { + _net.syncNTP(); + Serial.printf("[NET] WiFi:%s RSSI:%d IP:%s\n", + _net.getSSID().c_str(), _net.getRSSI(), + _net.getIP().c_str()); + _net.dnsCheck(NTFY_SERVER); + _net.tlsCheck(NTFY_SERVER); + } + + // Topic URLs + _alertUrl = topicUrl(ALERT_TOPIC); + _silenceUrl = topicUrl(SILENCE_TOPIC); + _adminUrl = topicUrl(ADMIN_TOPIC); + String sfx = _debug ? "_test" : ""; + _statusUrl = String("https://") + NTFY_SERVER + "/" + STATUS_TOPIC + sfx; + + Serial.printf("[CONFIG] ALERT_URL: %s\n", _alertUrl.c_str()); + Serial.printf("[CONFIG] SILENCE_URL: %s\n", _silenceUrl.c_str()); + Serial.printf("[CONFIG] ADMIN_URL: %s\n", _adminUrl.c_str()); + + // Boot status + flushStatus(String("BOOTED — ") + _net.getSSID() + " " + + _net.getIP() + " RSSI:" + String(_net.getRSSI())); +} + +void DoorbellLogic::finishBoot() { + transition(DeviceState::SILENT); + _state.screen = ScreenID::OFF; + _display->setBacklight(false); + _state.backlightOn = false; + _bootGraceEnd = millis() + BOOT_GRACE_MS; + Serial.printf("[BOOT] Grace period: %d ms\n", BOOT_GRACE_MS); + Serial.printf("[BOOT] Ready — monitoring %s\n", NTFY_SERVER); +} + +// ── Main loop tick ────────────────────────────────────────── + +void DoorbellLogic::update() { + uint32_t now = millis(); + _state.uptimeMs = now; + + if (_net.isConnected()) { + _state.wifiRssi = _net.getRSSI(); + _state.wifiSsid = _net.getSSID(); + _state.ipAddr = _net.getIP(); + } + if (!_net.checkConnection()) return; + + // Poll + if (now - _lastPollMs >= POLL_INTERVAL_MS) { + _lastPollMs = now; + pollTopics(); + } + + // Heartbeat + if (now - _lastHeartbeatMs >= HEARTBEAT_INTERVAL_MS) { + _lastHeartbeatMs = now; + heartbeat(); + } + + // Auto-transitions + switch (_state.deviceState) { + case DeviceState::ALERTING: + if (now - _state.alertStartMs > ALERT_TIMEOUT_MS) { + Serial.println("[STATE] Alert timed out → SILENT"); + transition(DeviceState::SILENT); + _state.screen = ScreenID::OFF; + _display->setBacklight(false); + _state.backlightOn = false; + } + break; + case DeviceState::SILENCED: + if (now - _state.silenceStartMs > SILENCE_DISPLAY_MS) { + Serial.println("[STATE] Silence display done → SILENT"); + transition(DeviceState::SILENT); + _state.screen = ScreenID::OFF; + _display->setBacklight(false); + _state.backlightOn = false; + } + break; + default: break; + } +} + +// ── Polling ───────────────────────────────────────────────── + +void DoorbellLogic::pollTopics() { + pollTopic(_alertUrl, "ALERT"); + pollTopic(_silenceUrl, "SILENCE"); + pollTopic(_adminUrl, "ADMIN"); + _state.lastPollMs = millis(); +} + +void DoorbellLogic::pollTopic(const String& url, const char* label) { + String body; + int code = _net.httpGet(url.c_str(), body); + if (code != 200 || body.length() == 0) return; + + // ntfy returns newline-delimited JSON + int pos = 0; + while (pos < (int)body.length()) { + int nl = body.indexOf('\n', pos); + if (nl < 0) nl = body.length(); + String line = body.substring(pos, nl); + line.trim(); + pos = nl + 1; + if (line.length() == 0) continue; + + JsonDocument doc; + if (deserializeJson(doc, line)) continue; + + const char* evt = doc["event"] | ""; + if (strcmp(evt, "message") != 0) continue; + + const char* title = doc["title"] | ""; + const char* message = doc["message"] | ""; + Serial.printf("[%s] title=\"%s\" message=\"%s\"\n", + label, title, message); + + if (strcmp(label, "ALERT") == 0) onAlert(String(title), String(message)); + else if (strcmp(label, "SILENCE") == 0) onSilence(); + else if (strcmp(label, "ADMIN") == 0) onAdmin(String(message)); + } +} + +// ── Event handlers ────────────────────────────────────────── + +void DoorbellLogic::onAlert(const String& title, const String& body) { + if (millis() < _bootGraceEnd) { + Serial.println("[ALERT] Ignored (boot grace)"); + return; + } + Serial.printf("[ALERT] %s: %s\n", title.c_str(), body.c_str()); + _state.alertTitle = title; + _state.alertBody = body; + _state.alertStartMs = millis(); + transition(DeviceState::ALERTING); + _state.screen = ScreenID::ALERT; + _display->setBacklight(true); + _state.backlightOn = true; + flushStatus("ALERT: " + title); +} + +void DoorbellLogic::onSilence() { + if (_state.deviceState != DeviceState::ALERTING) return; + Serial.println("[SILENCE] Alert silenced"); + _state.silenceStartMs = millis(); + transition(DeviceState::SILENCED); + _state.screen = ScreenID::OFF; + _display->setBacklight(false); + _state.backlightOn = false; + flushStatus("SILENCED"); +} + +void DoorbellLogic::silenceAlert() { onSilence(); } + +void DoorbellLogic::onAdmin(const String& cmd) { + Serial.printf("[ADMIN] %s\n", cmd.c_str()); + if (cmd == "reboot") { + flushStatus("REBOOTING (admin)"); + delay(500); + ESP.restart(); + } else if (cmd == "dashboard") { + _state.screen = ScreenID::DASHBOARD; + _display->setBacklight(true); + _state.backlightOn = true; + } else if (cmd == "off") { + _state.screen = ScreenID::OFF; + _display->setBacklight(false); + _state.backlightOn = false; + } else if (cmd == "status") { + heartbeat(); // re-uses heartbeat message format + } +} + +// ── Status / heartbeat ───────────────────────────────────── + +void DoorbellLogic::flushStatus(const String& message) { + Serial.printf("[STATUS] Queued: %s\n", message.c_str()); + String full = String(_board) + " " + message; + int code = _net.httpPost(_statusUrl.c_str(), full); + Serial.printf("[STATUS] Sent (%d): %s\n", code, message.c_str()); + _state.lastHeartbeatMs = millis(); +} + +void DoorbellLogic::heartbeat() { + String m = String("HEARTBEAT ") + deviceStateStr(_state.deviceState) + + " up:" + String(millis() / 1000) + "s" + + " RSSI:" + String(_net.getRSSI()) + + " heap:" + String(ESP.getFreeHeap()); +#ifdef BOARD_HAS_PSRAM + m += " psram:" + String(ESP.getFreePsram()); +#endif + flushStatus(m); +} + +void DoorbellLogic::transition(DeviceState s) { + Serial.printf("-> %s\n", deviceStateStr(s)); + _state.deviceState = s; +} + +// ── Serial console ────────────────────────────────────────── + +void DoorbellLogic::onSerialCommand(const String& cmd) { + Serial.printf("[CMD] %s\n", cmd.c_str()); + if (cmd == "alert") onAlert("Test Alert", "Serial test"); + else if (cmd == "silence") onSilence(); + else if (cmd == "reboot") ESP.restart(); + else if (cmd == "dashboard") onAdmin("dashboard"); + else if (cmd == "off") onAdmin("off"); + else if (cmd == "status") { + Serial.printf("[STATE] %s screen:%s bl:%s\n", + deviceStateStr(_state.deviceState), + screenIdStr(_state.screen), + _state.backlightOn ? "ON" : "OFF"); + Serial.printf("[MEM] heap:%d", ESP.getFreeHeap()); +#ifdef BOARD_HAS_PSRAM + Serial.printf(" psram:%d", ESP.getFreePsram()); +#endif + Serial.println(); + Serial.printf("[NET] %s RSSI:%d IP:%s\n", + _state.wifiSsid.c_str(), _state.wifiRssi, + _state.ipAddr.c_str()); + } + else Serial.println(F("[CMD] alert|silence|reboot|dashboard|off|status")); +} +EOF +info "DoorbellLogic.h + .cpp" + +# ───────────────────────────────────────────────────────────── +section "Board: ESP32-S3-LCD-4.3" +# ───────────────────────────────────────────────────────────── + +# ── board_config.h ───────────────────────────────────────── + +cat << 'EOF' > boards/esp32-s3-lcd-43/board_config.h +#pragma once + +#define BOARD_NAME "WS_S3_43" +#define DISPLAY_WIDTH 800 +#define DISPLAY_HEIGHT 480 +#define DISPLAY_ROTATION 0 // landscape, USB-C on left + +// ── RGB parallel bus pins (directly to ST7262 panel) ── +#define LCD_DE 40 +#define LCD_VSYNC 41 +#define LCD_HSYNC 39 +#define LCD_PCLK 42 + +#define LCD_R0 45 +#define LCD_R1 48 +#define LCD_R2 47 +#define LCD_R3 21 +#define LCD_R4 14 + +#define LCD_G0 5 +#define LCD_G1 6 +#define LCD_G2 7 +#define LCD_G3 15 +#define LCD_G4 16 +#define LCD_G5 4 + +#define LCD_B0 8 +#define LCD_B1 3 +#define LCD_B2 46 +#define LCD_B3 9 +#define LCD_B4 1 + +// ── CH422G I2C IO expander ── +// Controls LCD_RST, TP_RST, LCD_BL, SD_CS via I2C +#define I2C_SDA 17 +#define I2C_SCL 18 +#define I2C_FREQ 100000 + +// CH422G I2C command addresses +#define CH422G_WRITE_OC 0x46 +#define CH422G_SET_MODE 0x48 +#define CH422G_READ_IN 0x4C + +// EXIO bit positions +#define EXIO_TP_RST (1 << 0) // EXIO1 +#define EXIO_LCD_BL (1 << 1) // EXIO2 — also drives DISP signal! +#define EXIO_LCD_RST (1 << 2) // EXIO3 +#define EXIO_SD_CS (1 << 3) // EXIO4 + +// ── GT911 Touch ── +#define GT911_ADDR 0x5D +#define TOUCH_INT -1 // not wired to a readable GPIO on this board +EOF +info "board_config.h" + +# ── secrets.h.example ───────────────────────────────────── + +cat << 'EOF' > boards/esp32-s3-lcd-43/secrets.h.example +#pragma once +#include + +// Copy this file to secrets.h and fill in your credentials. +// secrets.h is gitignored. + +static const WiFiCred wifiNetworks[] = { + { "Your_SSID_1", "password1" }, + { "Your_SSID_2", "password2" }, +}; +static const int wifiNetworkCount = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); +EOF + +cp boards/esp32-s3-lcd-43/secrets.h.example boards/esp32-s3-lcd-43/secrets.h +info "secrets.h.example (copied to secrets.h — edit before building)" + +# ── DisplayDriverGFX.h ──────────────────────────────────── + +cat << 'EOF' > boards/esp32-s3-lcd-43/DisplayDriverGFX.h +#pragma once + +#include +#include +#include +#include "board_config.h" + +class DisplayDriverGFX : public IDisplayDriver { +public: + void begin() override; + void setBacklight(bool on) override; + void render(const ScreenState& state) override; + TouchEvent readTouch() override; + int dashboardTouch(int x, int y) override; + HoldState updateHold(unsigned long holdMs) override; + void updateHint() override; + int width() override { return DISPLAY_WIDTH; } + int height() override { return DISPLAY_HEIGHT; } + +private: + // CH422G helpers + void ch422gInit(); + void ch422gWrite(uint8_t val); + uint8_t _exioBits = 0; + void exioSet(uint8_t bit, bool on); + + // GT911 helpers + void gt911Init(); + bool gt911Read(int& x, int& y); + + // Rendering + void drawBoot(); + void drawAlert(const ScreenState& st); + void drawDashboard(const ScreenState& st); + + Arduino_GFX* _gfx = nullptr; + + // Hold-to-silence tracking + bool _holdActive = false; + uint32_t _holdStartMs = 0; + bool _lastTouched = false; + + // Render-change tracking + ScreenID _lastScreen = ScreenID::BOOT; + bool _needsRedraw = true; +}; +EOF +info "DisplayDriverGFX.h" + +# ── DisplayDriverGFX.cpp ────────────────────────────────── + +cat << 'EOF' > boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp +#include "DisplayDriverGFX.h" + +// ═══════════════════════════════════════════════════════════ +// CH422G IO Expander +// ═══════════════════════════════════════════════════════════ + +void DisplayDriverGFX::ch422gInit() { + Wire.begin(I2C_SDA, I2C_SCL, I2C_FREQ); + + // Enable OC output mode + Wire.beginTransmission(CH422G_SET_MODE >> 1); + Wire.write(0x01); // OC output enable + Wire.endTransmission(); + + // Deassert resets, backlight OFF initially + _exioBits = EXIO_TP_RST | EXIO_LCD_RST | EXIO_SD_CS; + ch422gWrite(_exioBits); + + delay(100); + Serial.println("[IO] CH422G initialized"); +} + +void DisplayDriverGFX::ch422gWrite(uint8_t val) { + Wire.beginTransmission(CH422G_WRITE_OC >> 1); + Wire.write(val); + Wire.endTransmission(); +} + +void DisplayDriverGFX::exioSet(uint8_t bit, bool on) { + if (on) _exioBits |= bit; + else _exioBits &= ~bit; + ch422gWrite(_exioBits); +} + +// ═══════════════════════════════════════════════════════════ +// GT911 Touch (minimal implementation) +// ═══════════════════════════════════════════════════════════ + +void DisplayDriverGFX::gt911Init() { + // GT911 is on the same I2C bus (Wire), already started by ch422gInit. + // Reset sequence: pull TP_RST low then high (via CH422G EXIO1). + exioSet(EXIO_TP_RST, false); + delay(10); + exioSet(EXIO_TP_RST, true); + delay(50); + Serial.println("[TOUCH] GT911 initialized"); +} + +bool DisplayDriverGFX::gt911Read(int& x, int& y) { + // Read status register 0x814E + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); Wire.write(0x4E); + if (Wire.endTransmission() != 0) return false; + + Wire.requestFrom((uint8_t)GT911_ADDR, (uint8_t)1); + if (!Wire.available()) return false; + uint8_t status = Wire.read(); + + uint8_t touches = status & 0x0F; + bool ready = status & 0x80; + + if (!ready || touches == 0 || touches > 5) { + // Clear status + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); Wire.write(0x4E); Wire.write(0x00); + Wire.endTransmission(); + return false; + } + + // Read first touch point (0x8150..0x8157) + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); Wire.write(0x50); + Wire.endTransmission(); + Wire.requestFrom((uint8_t)GT911_ADDR, (uint8_t)4); + if (Wire.available() >= 4) { + uint8_t xl = Wire.read(); + uint8_t xh = Wire.read(); + uint8_t yl = Wire.read(); + uint8_t yh = Wire.read(); + x = (xh << 8) | xl; + y = (yh << 8) | yl; + } + + // Clear status + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); Wire.write(0x4E); Wire.write(0x00); + Wire.endTransmission(); + + return true; +} + +// ═══════════════════════════════════════════════════════════ +// Display Init +// ═══════════════════════════════════════════════════════════ + +void DisplayDriverGFX::begin() { + // 1. IO expander first (controls resets + backlight) + ch422gInit(); + + // 2. Create RGB bus with corrected Waveshare timing + Arduino_ESP32RGBPanel* rgbPanel = new Arduino_ESP32RGBPanel( + LCD_DE, LCD_VSYNC, LCD_HSYNC, LCD_PCLK, + LCD_R0, LCD_R1, LCD_R2, LCD_R3, LCD_R4, + LCD_G0, LCD_G1, LCD_G2, LCD_G3, LCD_G4, LCD_G5, + LCD_B0, LCD_B1, LCD_B2, LCD_B3, LCD_B4, + // ─── Corrected timing for ST7262 / Waveshare 4.3" ─── + 1, // hsync_polarity + 10, // hsync_front_porch + 8, // hsync_pulse_width + 50, // hsync_back_porch + 1, // vsync_polarity + 10, // vsync_front_porch + 8, // vsync_pulse_width + 20, // vsync_back_porch + 1, // pclk_active_neg *** CRITICAL — must be 1 *** + 16000000 // prefer_speed = 16 MHz PCLK + ); + + // 3. Create display + _gfx = new Arduino_RGB_Display( + DISPLAY_WIDTH, DISPLAY_HEIGHT, rgbPanel, + DISPLAY_ROTATION, + true // auto_flush + ); + + if (!_gfx->begin()) { + Serial.println("[GFX] *** Display init FAILED ***"); + return; + } + + Serial.printf("[GFX] Display OK: %dx%d\n", DISPLAY_WIDTH, DISPLAY_HEIGHT); + + // PSRAM diagnostic + Serial.printf("[MEM] Free heap: %d | Free PSRAM: %d\n", + ESP.getFreeHeap(), ESP.getFreePsram()); + if (ESP.getFreePsram() == 0) { + Serial.println("[MEM] *** WARNING: PSRAM not detected! " + "Display will likely be blank. " + "Ensure PSRAM=opi in board config. ***"); + } + + // 4. Init touch + gt911Init(); + + // 5. Show boot screen (backlight still off) + _gfx->fillScreen(BLACK); + drawBoot(); + + // 6. Backlight ON + exioSet(EXIO_LCD_BL, true); + Serial.println("[GFX] Backlight ON"); +} + +void DisplayDriverGFX::setBacklight(bool on) { + exioSet(EXIO_LCD_BL, on); +} + +// ═══════════════════════════════════════════════════════════ +// Rendering +// ═══════════════════════════════════════════════════════════ + +void DisplayDriverGFX::render(const ScreenState& st) { + if (st.screen != _lastScreen) { + _needsRedraw = true; + _lastScreen = st.screen; + } + + switch (st.screen) { + case ScreenID::BOOT: + if (_needsRedraw) { drawBoot(); _needsRedraw = false; } + break; + case ScreenID::ALERT: + drawAlert(st); // redraws every frame (pulse animation) + break; + case ScreenID::DASHBOARD: + if (_needsRedraw) { drawDashboard(st); _needsRedraw = false; } + break; + case ScreenID::OFF: + if (_needsRedraw) { _gfx->fillScreen(BLACK); _needsRedraw = false; } + break; + } +} + +void DisplayDriverGFX::drawBoot() { + _gfx->fillScreen(BLACK); + _gfx->setTextColor(WHITE); + _gfx->setTextSize(3); + _gfx->setCursor(40, 40); + _gfx->printf("KLUBHAUS ALERT v%s", FW_VERSION); + _gfx->setTextSize(2); + _gfx->setCursor(40, 100); + _gfx->print(BOARD_NAME); + _gfx->setCursor(40, 140); + _gfx->print("Booting..."); +} + +void DisplayDriverGFX::drawAlert(const ScreenState& st) { + // Pulsing red background + uint32_t elapsed = millis() - st.alertStartMs; + uint8_t pulse = 180 + (uint8_t)(75.0f * sinf(elapsed / 300.0f)); + uint16_t bg = _gfx->color565(pulse, 0, 0); + + _gfx->fillScreen(bg); + _gfx->setTextColor(WHITE); + + // Title + _gfx->setTextSize(5); + _gfx->setCursor(40, 80); + _gfx->print(st.alertTitle.length() > 0 ? st.alertTitle : "ALERT"); + + // Body + _gfx->setTextSize(3); + _gfx->setCursor(40, 200); + _gfx->print(st.alertBody); + + // Hold hint + _gfx->setTextSize(2); + _gfx->setCursor(40, DISPLAY_HEIGHT - 60); + _gfx->print("Hold to silence..."); +} + +void DisplayDriverGFX::drawDashboard(const ScreenState& st) { + _gfx->fillScreen(BLACK); + _gfx->setTextColor(WHITE); + + // Title bar + _gfx->setTextSize(2); + _gfx->setCursor(20, 10); + _gfx->printf("KLUBHAUS — %s", deviceStateStr(st.deviceState)); + + // Info tiles (simple text grid) + int y = 60; + int dy = 50; + _gfx->setTextSize(2); + + _gfx->setCursor(20, y); + _gfx->printf("WiFi: %s RSSI: %d", st.wifiSsid.c_str(), st.wifiRssi); + y += dy; + + _gfx->setCursor(20, y); + _gfx->printf("IP: %s", st.ipAddr.c_str()); + y += dy; + + _gfx->setCursor(20, y); + _gfx->printf("Uptime: %lus", st.uptimeMs / 1000); + y += dy; + + _gfx->setCursor(20, y); + _gfx->printf("Heap: %d PSRAM: %d", ESP.getFreeHeap(), ESP.getFreePsram()); + y += dy; + + _gfx->setCursor(20, y); + _gfx->printf("Last poll: %lus ago", + st.lastPollMs > 0 ? (millis() - st.lastPollMs) / 1000 : 0); +} + +// ═══════════════════════════════════════════════════════════ +// Touch +// ═══════════════════════════════════════════════════════════ + +TouchEvent DisplayDriverGFX::readTouch() { + TouchEvent evt; + int x, y; + if (gt911Read(x, y)) { + evt.pressed = true; + evt.x = x; + evt.y = y; + } + return evt; +} + +int DisplayDriverGFX::dashboardTouch(int x, int y) { + // Simple 2-column, 4-row tile grid + int col = x / (DISPLAY_WIDTH / 2); + int row = (y - 60) / 80; + if (row < 0 || row > 3) return -1; + return row * 2 + col; +} + +HoldState DisplayDriverGFX::updateHold(unsigned long holdMs) { + HoldState h; + TouchEvent t = readTouch(); + + if (t.pressed) { + if (!_holdActive) { + _holdActive = true; + _holdStartMs = millis(); + } + uint32_t held = millis() - _holdStartMs; + h.active = true; + h.progress = constrain((float)held / (float)holdMs, 0.0f, 1.0f); + h.completed = (held >= holdMs); + + // Draw progress arc + if (h.active && !h.completed) { + int cx = DISPLAY_WIDTH / 2; + int cy = DISPLAY_HEIGHT / 2; + int r = 100; + float angle = h.progress * 360.0f; + // Simple progress: draw filled arc sector + for (float a = 0; a < angle; a += 2.0f) { + float rad = a * PI / 180.0f; + int px = cx + (int)(r * cosf(rad - PI / 2)); + int py = cy + (int)(r * sinf(rad - PI / 2)); + _gfx->fillCircle(px, py, 4, WHITE); + } + } + } else { + _holdActive = false; + } + + _lastTouched = t.pressed; + return h; +} + +void DisplayDriverGFX::updateHint() { + // Subtle pulsing ring to hint "hold here" + float t = (millis() % 2000) / 2000.0f; + uint8_t alpha = (uint8_t)(60.0f + 40.0f * sinf(t * 2 * PI)); + uint16_t col = _gfx->color565(alpha, alpha, alpha); + int cx = DISPLAY_WIDTH / 2; + int cy = DISPLAY_HEIGHT / 2 + 60; + _gfx->drawCircle(cx, cy, 80, col); + _gfx->drawCircle(cx, cy, 81, col); +} +EOF +info "DisplayDriverGFX.h + .cpp" + +# ── Main sketch (S3) ────────────────────────────────────── + +cat << 'EOF' > boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino +// +// Klubhaus Doorbell — ESP32-S3-Touch-LCD-4.3 target +// + +#include +#include "board_config.h" +#include "secrets.h" +#include "DisplayDriverGFX.h" + +DisplayDriverGFX gfxDriver; +DisplayManager display(&gfxDriver); +DoorbellLogic logic(&display); + +void setup() { + Serial.begin(115200); + delay(500); + + logic.begin(FW_VERSION, BOARD_NAME, wifiNetworks, wifiNetworkCount); + logic.finishBoot(); +} + +void loop() { + // ── State machine tick ── + logic.update(); + + // ── Render current screen ── + display.render(logic.getScreenState()); + + // ── Touch handling ── + const ScreenState& st = logic.getScreenState(); + + if (st.deviceState == DeviceState::ALERTING) { + HoldState h = display.updateHold(HOLD_TO_SILENCE_MS); + if (h.completed) { + logic.silenceAlert(); + } + if (!h.active) { + display.updateHint(); + } + } else { + TouchEvent evt = display.readTouch(); + if (evt.pressed && st.screen == ScreenID::DASHBOARD) { + int tile = display.dashboardTouch(evt.x, evt.y); + if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile); + } + } + + // ── Serial console ── + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd.length() > 0) logic.onSerialCommand(cmd); + } +} +EOF +info "esp32-s3-lcd-43.ino" + +# ───────────────────────────────────────────────────────────── +section "Board: ESP32-32E" +# ───────────────────────────────────────────────────────────── + +# ── board_config.h ───────────────────────────────────────── + +cat << 'EOF' > boards/esp32-32e/board_config.h +#pragma once + +#define BOARD_NAME "WS_32E" + +// ══════════════════════════════════════════════════════════ +// TODO: Set these to match YOUR display + wiring. +// Defaults below are for a common ILI9341 320×240 SPI TFT. +// The actual pin mapping must also be set in tft_user_setup.h +// (which gets copied into the vendored TFT_eSPI library). +// ══════════════════════════════════════════════════════════ + +#define DISPLAY_WIDTH 320 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_ROTATION 1 // landscape + +// Backlight GPIO (directly wired) +#define PIN_LCD_BL 22 + +// Touch — if using XPT2046 via TFT_eSPI, set TOUCH_CS in tft_user_setup.h +// If using capacitive touch (e.g. FT6236), configure I2C pins here: +// #define TOUCH_SDA 21 +// #define TOUCH_SCL 22 +EOF +info "board_config.h" + +# ── tft_user_setup.h (copied into vendored TFT_eSPI) ────── + +cat << 'EOF' > boards/esp32-32e/tft_user_setup.h +// ═══════════════════════════════════════════════════════════ +// TFT_eSPI User_Setup for ESP32-32E target +// This file is copied over vendor/esp32-32e/TFT_eSPI/User_Setup.h +// by the install-libs-32e task. +// +// TODO: Change the driver, pins, and dimensions to match your display. +// ═══════════════════════════════════════════════════════════ + +#define USER_SETUP_ID 200 + +// ── Driver ── +#define ILI9341_DRIVER +// #define ST7789_DRIVER +// #define ILI9488_DRIVER + +// ── Resolution ── +#define TFT_WIDTH 240 +#define TFT_HEIGHT 320 + +// ── SPI Pins ── +#define TFT_MOSI 23 +#define TFT_SCLK 18 +#define TFT_CS 5 +#define TFT_DC 27 +#define TFT_RST 33 + +// ── Backlight (optional, can also use GPIO directly) ── +// #define TFT_BL 22 +// #define TFT_BACKLIGHT_ON HIGH + +// ── Touch (XPT2046 resistive) ── +#define TOUCH_CS 14 + +// ── SPI speed ── +#define SPI_FREQUENCY 40000000 +#define SPI_READ_FREQUENCY 20000000 +#define SPI_TOUCH_FREQUENCY 2500000 + +// ── Misc ── +#define LOAD_GLCD +#define LOAD_FONT2 +#define LOAD_FONT4 +#define LOAD_FONT6 +#define LOAD_FONT7 +#define LOAD_FONT8 +#define LOAD_GFXFF +#define SMOOTH_FONT +EOF +info "tft_user_setup.h" + +# ── secrets.h.example ───────────────────────────────────── + +cat << 'EOF' > boards/esp32-32e/secrets.h.example +#pragma once +#include + +// Copy this file to secrets.h and fill in your credentials. +// secrets.h is gitignored. + +static const WiFiCred wifiNetworks[] = { + { "Your_SSID_1", "password1" }, + { "Your_SSID_2", "password2" }, +}; +static const int wifiNetworkCount = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); +EOF + +cp boards/esp32-32e/secrets.h.example boards/esp32-32e/secrets.h +info "secrets.h.example (copied to secrets.h — edit before building)" + +# ── DisplayDriverTFT.h ──────────────────────────────────── + +cat << 'EOF' > boards/esp32-32e/DisplayDriverTFT.h +#pragma once + +#include +#include +#include "board_config.h" + +class DisplayDriverTFT : public IDisplayDriver { +public: + void begin() override; + void setBacklight(bool on) override; + void render(const ScreenState& state) override; + TouchEvent readTouch() override; + int dashboardTouch(int x, int y) override; + HoldState updateHold(unsigned long holdMs) override; + void updateHint() override; + int width() override { return DISPLAY_WIDTH; } + int height() override { return DISPLAY_HEIGHT; } + +private: + void drawBoot(); + void drawAlert(const ScreenState& st); + void drawDashboard(const ScreenState& st); + + TFT_eSPI _tft; + + bool _holdActive = false; + uint32_t _holdStartMs = 0; + ScreenID _lastScreen = ScreenID::BOOT; + bool _needsRedraw = true; +}; +EOF +info "DisplayDriverTFT.h" + +# ── DisplayDriverTFT.cpp ────────────────────────────────── + +cat << 'EOF' > boards/esp32-32e/DisplayDriverTFT.cpp +#include "DisplayDriverTFT.h" + +void DisplayDriverTFT::begin() { + // Backlight + pinMode(PIN_LCD_BL, OUTPUT); + digitalWrite(PIN_LCD_BL, LOW); + + _tft.init(); + _tft.setRotation(DISPLAY_ROTATION); + _tft.fillScreen(TFT_BLACK); + + Serial.printf("[GFX] Display OK: %dx%d\n", DISPLAY_WIDTH, DISPLAY_HEIGHT); + + drawBoot(); + + digitalWrite(PIN_LCD_BL, HIGH); + Serial.println("[GFX] Backlight ON"); +} + +void DisplayDriverTFT::setBacklight(bool on) { + digitalWrite(PIN_LCD_BL, on ? HIGH : LOW); +} + +// ── Rendering ─────────────────────────────────────────────── + +void DisplayDriverTFT::render(const ScreenState& st) { + if (st.screen != _lastScreen) { + _needsRedraw = true; + _lastScreen = st.screen; + } + + switch (st.screen) { + case ScreenID::BOOT: + if (_needsRedraw) { drawBoot(); _needsRedraw = false; } + break; + case ScreenID::ALERT: + drawAlert(st); + break; + case ScreenID::DASHBOARD: + if (_needsRedraw) { drawDashboard(st); _needsRedraw = false; } + break; + case ScreenID::OFF: + if (_needsRedraw) { _tft.fillScreen(TFT_BLACK); _needsRedraw = false; } + break; + } +} + +void DisplayDriverTFT::drawBoot() { + _tft.fillScreen(TFT_BLACK); + _tft.setTextColor(TFT_WHITE, TFT_BLACK); + _tft.setTextSize(2); + _tft.setCursor(10, 10); + _tft.printf("KLUBHAUS v%s", FW_VERSION); + _tft.setTextSize(1); + _tft.setCursor(10, 40); + _tft.print(BOARD_NAME); + _tft.setCursor(10, 60); + _tft.print("Booting..."); +} + +void DisplayDriverTFT::drawAlert(const ScreenState& st) { + uint32_t elapsed = millis() - st.alertStartMs; + uint8_t pulse = 180 + (uint8_t)(75.0f * sinf(elapsed / 300.0f)); + uint16_t bg = _tft.color565(pulse, 0, 0); + + _tft.fillScreen(bg); + _tft.setTextColor(TFT_WHITE, bg); + + _tft.setTextSize(3); + _tft.setCursor(10, 20); + _tft.print(st.alertTitle.length() > 0 ? st.alertTitle : "ALERT"); + + _tft.setTextSize(2); + _tft.setCursor(10, 80); + _tft.print(st.alertBody); + + _tft.setTextSize(1); + _tft.setCursor(10, DISPLAY_HEIGHT - 20); + _tft.print("Hold to silence..."); +} + +void DisplayDriverTFT::drawDashboard(const ScreenState& st) { + _tft.fillScreen(TFT_BLACK); + _tft.setTextColor(TFT_WHITE, TFT_BLACK); + + _tft.setTextSize(1); + _tft.setCursor(5, 5); + _tft.printf("KLUBHAUS — %s", deviceStateStr(st.deviceState)); + + int y = 30; + _tft.setCursor(5, y); y += 18; + _tft.printf("WiFi: %s %ddBm", st.wifiSsid.c_str(), st.wifiRssi); + + _tft.setCursor(5, y); y += 18; + _tft.printf("IP: %s", st.ipAddr.c_str()); + + _tft.setCursor(5, y); y += 18; + _tft.printf("Up: %lus Heap: %d", st.uptimeMs / 1000, ESP.getFreeHeap()); + + _tft.setCursor(5, y); y += 18; + _tft.printf("Last poll: %lus ago", + st.lastPollMs > 0 ? (millis() - st.lastPollMs) / 1000 : 0); +} + +// ── Touch ─────────────────────────────────────────────────── + +TouchEvent DisplayDriverTFT::readTouch() { + TouchEvent evt; + uint16_t tx, ty; + if (_tft.getTouch(&tx, &ty)) { + evt.pressed = true; + evt.x = tx; + evt.y = ty; + } + return evt; +} + +int DisplayDriverTFT::dashboardTouch(int x, int y) { + // 2-column, 4-row + int col = x / (DISPLAY_WIDTH / 2); + int row = (y - 30) / 40; + if (row < 0 || row > 3) return -1; + return row * 2 + col; +} + +HoldState DisplayDriverTFT::updateHold(unsigned long holdMs) { + HoldState h; + TouchEvent t = readTouch(); + + if (t.pressed) { + if (!_holdActive) { + _holdActive = true; + _holdStartMs = millis(); + } + uint32_t held = millis() - _holdStartMs; + h.active = true; + h.progress = constrain((float)held / (float)holdMs, 0.0f, 1.0f); + h.completed = (held >= holdMs); + + // Simple progress bar at bottom of screen + int barW = (int)(DISPLAY_WIDTH * h.progress); + _tft.fillRect(0, DISPLAY_HEIGHT - 8, barW, 8, TFT_WHITE); + _tft.fillRect(barW, DISPLAY_HEIGHT - 8, DISPLAY_WIDTH - barW, 8, TFT_DARKGREY); + } else { + _holdActive = false; + } + return h; +} + +void DisplayDriverTFT::updateHint() { + float t = (millis() % 2000) / 2000.0f; + uint8_t v = (uint8_t)(30.0f + 30.0f * sinf(t * 2 * PI)); + uint16_t col = _tft.color565(v, v, v); + _tft.drawRect(DISPLAY_WIDTH / 2 - 40, DISPLAY_HEIGHT / 2 + 20, 80, 40, col); +} +EOF +info "DisplayDriverTFT.h + .cpp" + +# ── Main sketch (32E) ───────────────────────────────────── + +cat << 'EOF' > boards/esp32-32e/esp32-32e.ino +// +// Klubhaus Doorbell — ESP32-32E target +// + +#include +#include "board_config.h" +#include "secrets.h" +#include "DisplayDriverTFT.h" + +DisplayDriverTFT tftDriver; +DisplayManager display(&tftDriver); +DoorbellLogic logic(&display); + +void setup() { + Serial.begin(115200); + delay(500); + + logic.begin(FW_VERSION, BOARD_NAME, wifiNetworks, wifiNetworkCount); + logic.finishBoot(); +} + +void loop() { + // ── State machine tick ── + logic.update(); + + // ── Render current screen ── + display.render(logic.getScreenState()); + + // ── Touch handling ── + const ScreenState& st = logic.getScreenState(); + + if (st.deviceState == DeviceState::ALERTING) { + HoldState h = display.updateHold(HOLD_TO_SILENCE_MS); + if (h.completed) { + logic.silenceAlert(); + } + if (!h.active) { + display.updateHint(); + } + } else { + TouchEvent evt = display.readTouch(); + if (evt.pressed && st.screen == ScreenID::DASHBOARD) { + int tile = display.dashboardTouch(evt.x, evt.y); + if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile); + } + } + + // ── Serial console ── + if (Serial.available()) { + String cmd = Serial.readStringUntil('\n'); + cmd.trim(); + if (cmd.length() > 0) logic.onSerialCommand(cmd); + } +} +EOF +info "esp32-32e.ino" + +# ───────────────────────────────────────────────────────────── +section "Build Harness: mise.toml" +# ───────────────────────────────────────────────────────────── + +cat << 'MISE_EOF' > mise.toml +# ═══════════════════════════════════════════════════════════ +# Klubhaus Doorbell — Multi-Target Build Harness +# ═══════════════════════════════════════════════════════════ + +[tasks.install-libs-shared] +description = "Install shared (platform-independent) libraries" +run = """ +arduino-cli lib install "ArduinoJson@7.4.1" +arduino-cli lib install "NTPClient@3.2.1" +echo "[OK] Shared libraries installed" +""" + +[tasks.install-libs-32e] +description = "Vendor TFT_eSPI into vendor/esp32-32e" +run = """ +#!/usr/bin/env bash +set -euo pipefail +if [ ! -d "vendor/esp32-32e/TFT_eSPI" ]; then + echo "Cloning TFT_eSPI..." + git clone --depth 1 --branch 2.5.43 \ + https://github.com/Bodmer/TFT_eSPI.git \ + vendor/esp32-32e/TFT_eSPI +fi +echo "Copying board-specific User_Setup.h..." +cp boards/esp32-32e/tft_user_setup.h vendor/esp32-32e/TFT_eSPI/User_Setup.h +echo "[OK] TFT_eSPI 2.5.43 vendored + configured" +""" + +[tasks.install-libs-s3-43] +description = "Vendor Arduino_GFX into vendor/esp32-s3-lcd-43" +run = """ +#!/usr/bin/env bash +set -euo pipefail +if [ ! -d "vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino" ]; then + echo "Cloning Arduino_GFX..." + git clone --depth 1 --branch v1.6.5 \ + https://github.com/moononournation/Arduino_GFX.git \ + vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino +fi +echo "[OK] Arduino_GFX 1.6.5 vendored" +""" + +[tasks.install-libs] +description = "Install all libraries (shared + vendored)" +depends = ["install-libs-shared", "install-libs-32e", "install-libs-s3-43"] + +# ── ESP32-32E ──────────────────────────────────────────── + +[tasks.compile-32e] +description = "Compile ESP32-32E sketch" +depends = ["install-libs"] +run = """ +arduino-cli compile \ + --fqbn "esp32:esp32:esp32:FlashSize=4M,PartitionScheme=default" \ + --libraries ./libraries \ + --libraries ./vendor/esp32-32e \ + --build-property "compiler.cpp.extra_flags=-DDEBUG_MODE" \ + --warnings default \ + ./boards/esp32-32e +""" + +[tasks.upload-32e] +description = "Upload to ESP32-32E" +run = """ +arduino-cli upload \ + --fqbn "esp32:esp32:esp32:FlashSize=4M,PartitionScheme=default" \ + --port "${PORT:-/dev/ttyUSB0}" \ + ./boards/esp32-32e +""" + +[tasks.monitor-32e] +description = "Serial monitor for ESP32-32E" +run = """ +arduino-cli monitor --port "${PORT:-/dev/ttyUSB0}" --config baudrate=115200 +""" + +# ── ESP32-S3-LCD-4.3 ──────────────────────────────────── + +[tasks.compile-s3-43] +description = "Compile ESP32-S3-LCD-4.3 sketch" +depends = ["install-libs"] +run = """ +arduino-cli compile \ + --fqbn "esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=opi,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB" \ + --libraries ./libraries \ + --libraries ./vendor/esp32-s3-lcd-43 \ + --build-property "compiler.cpp.extra_flags=-DDEBUG_MODE -DBOARD_HAS_PSRAM" \ + --warnings default \ + ./boards/esp32-s3-lcd-43 +""" + +[tasks.upload-s3-43] +description = "Upload to ESP32-S3-LCD-4.3" +run = """ +arduino-cli upload \ + --fqbn "esp32:esp32:waveshare_esp32_s3_touch_lcd_43:PSRAM=opi,FlashSize=16M,USBMode=hwcdc,PartitionScheme=app3M_fat9M_16MB" \ + --port "${PORT:-/dev/ttyACM0}" \ + ./boards/esp32-s3-lcd-43 +""" + +[tasks.monitor-s3-43] +description = "Serial monitor for ESP32-S3-LCD-4.3" +run = """ +arduino-cli monitor --port "${PORT:-/dev/ttyACM0}" --config baudrate=115200 +""" + +# ── Convenience ────────────────────────────────────────── + +[tasks.clean] +description = "Remove build artifacts" +run = """ +rm -rf boards/esp32-32e/build +rm -rf boards/esp32-s3-lcd-43/build +echo "[OK] Build artifacts cleaned" +""" +MISE_EOF +info "mise.toml" + +# ───────────────────────────────────────────────────────────── +section "Project Files" +# ───────────────────────────────────────────────────────────── + +cat << 'EOF' > .gitignore +# Secrets (WiFi passwords etc.) +**/secrets.h + +# Build artifacts +**/build/ + +# Vendored libraries (re-created by install-libs) +vendor/esp32-32e/TFT_eSPI/ +vendor/esp32-s3-lcd-43/GFX_Library_for_Arduino/ + +# IDE +.vscode/ +*.swp +*.swo +*~ +.DS_Store +EOF +info ".gitignore" + +cat << 'EOF' > README.md +# Klubhaus Doorbell + +Multi-target doorbell alert system powered by [ntfy.sh](https://ntfy.sh). + +## Targets + +| Board | Display | Library | Build Task | +|---|---|---|---| +| ESP32-32E | SPI TFT (ILI9341 etc.) | TFT_eSPI | `mise run compile-32e` | +| ESP32-S3-Touch-LCD-4.3 | 800×480 RGB parallel | Arduino_GFX | `mise run compile-s3-43` | + +## Quick Start + +```bash +# 1. Install prerequisites +# - arduino-cli (with esp32:esp32 platform installed) +# - mise (https://mise.jdx.dev) + +# 2. Edit WiFi credentials +cp boards/esp32-32e/secrets.h.example boards/esp32-32e/secrets.h +cp boards/esp32-s3-lcd-43/secrets.h.example boards/esp32-s3-lcd-43/secrets.h +# Edit both secrets.h files with your WiFi SSIDs/passwords. + +# 3. Build & upload +mise run compile-s3-43 +mise run upload-s3-43 + +mise run compile-32e +mise run upload-32e