From 838afaa36f73ba33f51e025c9fc8a8f57989ef7d Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Feb 2026 17:53:06 -0800 Subject: [PATCH] consolidate sketches --- .arduino_config.lua | 5 + .gitignore | 16 + BoardConfig.h | 18 + Config.h | 59 + Dashboard.cpp | 196 ++ Dashboard.h | 56 + DisplayDriver.h | 117 ++ DisplayDriverGFX.cpp | 194 ++ DisplayManager.cpp | 481 +++++ DisplayManager.h | 99 + DoorbellLogic.cpp | 576 ++++++ DoorbellLogic.h | 95 + README.md | 91 + ScreenData.h | 58 + TouchDriver.h | 21 + TouchDriverGT911.cpp | 43 + boards/board_e32r35t.h | 50 + boards/board_waveshare_s3.h | 73 + boards/esp32-32e/DisplayDriverTFT.cpp | 155 ++ boards/esp32-32e/DisplayDriverTFT.h | 30 + boards/esp32-32e/board_config.h | 22 + boards/esp32-32e/esp32-32e.ino | 54 + boards/esp32-32e/secrets.h.example | 11 + boards/esp32-32e/tft_user_setup.h | 47 + boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp | 325 ++++ boards/esp32-s3-lcd-43/DisplayDriverGFX.h | 46 + boards/esp32-s3-lcd-43/board_config.h | 52 + boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino | 54 + boards/esp32-s3-lcd-43/secrets.h.example | 11 + doorbell-touch.ino | 899 ++------- libraries/KlubhausCore/library.properties | 11 + libraries/KlubhausCore/src/Config.h | 26 + libraries/KlubhausCore/src/DisplayManager.h | 24 + libraries/KlubhausCore/src/DoorbellLogic.cpp | 260 +++ libraries/KlubhausCore/src/DoorbellLogic.h | 55 + libraries/KlubhausCore/src/IDisplayDriver.h | 38 + libraries/KlubhausCore/src/KlubhausCore.h | 9 + libraries/KlubhausCore/src/NetManager.cpp | 102 + libraries/KlubhausCore/src/NetManager.h | 37 + libraries/KlubhausCore/src/ScreenState.h | 54 + mise.toml | 130 +- scaffold.sh | 1772 ++++++++++++++++++ 42 files changed, 5655 insertions(+), 817 deletions(-) create mode 100644 .arduino_config.lua create mode 100644 .gitignore create mode 100644 BoardConfig.h create mode 100644 Config.h create mode 100644 Dashboard.cpp create mode 100644 Dashboard.h create mode 100644 DisplayDriver.h create mode 100644 DisplayDriverGFX.cpp create mode 100644 DisplayManager.cpp create mode 100644 DisplayManager.h create mode 100644 DoorbellLogic.cpp create mode 100644 DoorbellLogic.h create mode 100644 README.md create mode 100644 ScreenData.h create mode 100644 TouchDriver.h create mode 100644 TouchDriverGT911.cpp create mode 100644 boards/board_e32r35t.h create mode 100644 boards/board_waveshare_s3.h create mode 100644 boards/esp32-32e/DisplayDriverTFT.cpp create mode 100644 boards/esp32-32e/DisplayDriverTFT.h create mode 100644 boards/esp32-32e/board_config.h create mode 100644 boards/esp32-32e/esp32-32e.ino create mode 100644 boards/esp32-32e/secrets.h.example create mode 100644 boards/esp32-32e/tft_user_setup.h create mode 100644 boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp create mode 100644 boards/esp32-s3-lcd-43/DisplayDriverGFX.h create mode 100644 boards/esp32-s3-lcd-43/board_config.h create mode 100644 boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino create mode 100644 boards/esp32-s3-lcd-43/secrets.h.example create mode 100644 libraries/KlubhausCore/library.properties create mode 100644 libraries/KlubhausCore/src/Config.h create mode 100644 libraries/KlubhausCore/src/DisplayManager.h create mode 100644 libraries/KlubhausCore/src/DoorbellLogic.cpp create mode 100644 libraries/KlubhausCore/src/DoorbellLogic.h create mode 100644 libraries/KlubhausCore/src/IDisplayDriver.h create mode 100644 libraries/KlubhausCore/src/KlubhausCore.h create mode 100644 libraries/KlubhausCore/src/NetManager.cpp create mode 100644 libraries/KlubhausCore/src/NetManager.h create mode 100644 libraries/KlubhausCore/src/ScreenState.h create mode 100644 scaffold.sh diff --git a/.arduino_config.lua b/.arduino_config.lua new file mode 100644 index 0000000..d49453c --- /dev/null +++ b/.arduino_config.lua @@ -0,0 +1,5 @@ +local M = {} +M.board = 'arduino:avr:uno' +M.port = '/dev/ttyUSB0' +M.baudrate =115200 +return M diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5562009 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 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 diff --git a/BoardConfig.h b/BoardConfig.h new file mode 100644 index 0000000..fe0db93 --- /dev/null +++ b/BoardConfig.h @@ -0,0 +1,18 @@ +#pragma once +// ═══════════════════════════════════════════════════════════════════ +// Board selector — driven by build flags +// Pass -DTARGET_E32R35T or -DTARGET_WAVESHARE_S3_43 +// ═══════════════════════════════════════════════════════════════════ + +#if defined(TARGET_E32R35T) + #include "boards/board_e32r35t.h" + +#elif defined(TARGET_WAVESHARE_S3_43) + #include "boards/board_waveshare_s3.h" + +#else + // Default to E32R35T for backward compatibility with existing builds + #pragma message("No TARGET_* defined — defaulting to E32R35T") + #define TARGET_E32R35T + #include "boards/board_e32r35t.h" +#endif diff --git a/Config.h b/Config.h new file mode 100644 index 0000000..d2019b1 --- /dev/null +++ b/Config.h @@ -0,0 +1,59 @@ +#pragma once + +#include "BoardConfig.h" + +// ===================================================================== +// Debug +// ===================================================================== +#define DEBUG_MODE 1 + +// ===================================================================== +// WiFi Credentials +// ===================================================================== +struct WiFiCred { const char *ssid; const char *pass; }; +static WiFiCred wifiNetworks[] = { + { "Dobro Veče", "goodnight" }, + { "iot-2GHz", "lesson-greater" }, +}; +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 + +// Change since=10s to since=20s (must be > poll interval of 15s, but not too large) +#define ALERT_URL NTFY_BASE "/ALERT_klubhaus_topic" TOPIC_SUFFIX "/json?since=20s&poll=1" +#define SILENCE_URL NTFY_BASE "/SILENCE_klubhaus_topic" TOPIC_SUFFIX "/json?since=20s&poll=1" +#define ADMIN_URL NTFY_BASE "/ADMIN_klubhaus_topic" TOPIC_SUFFIX "/json?since=20s&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 WAKE_DISPLAY_MS 5000 +#define TOUCH_DEBOUNCE_MS 300 +#define HOLD_DURATION_MS 2000 +#define HEARTBEAT_INTERVAL_MS 30000 + +#if DEBUG_MODE + #define BOOT_GRACE_MS 5000 +#else + #define BOOT_GRACE_MS 30000 +#endif + +// ===================================================================== +// Hardware pins are now in boards/board_*.h via BoardConfig.h +// Screen dimensions (SCREEN_WIDTH, SCREEN_HEIGHT) also come from there. +// ===================================================================== diff --git a/Dashboard.cpp b/Dashboard.cpp new file mode 100644 index 0000000..bae23a9 --- /dev/null +++ b/Dashboard.cpp @@ -0,0 +1,196 @@ +#include "Dashboard.h" + +#define COL_BG 0x1082 +#define COL_BAR 0x2104 +#define COL_RED 0xF800 +#define COL_ORANGE 0xFBE0 +#define COL_GREEN 0x07E0 +#define COL_CYAN 0x07FF +#define COL_PURPLE 0x780F +#define COL_WHITE 0xFFFF +#define COL_GRAY 0x8410 +#define COL_DARK_TILE 0x18E3 + +static const uint16_t tileBG[] = { + COL_RED, COL_ORANGE, COL_CYAN, COL_PURPLE, COL_DARK_TILE, COL_DARK_TILE +}; +static const uint16_t tileFG[] = { + COL_WHITE, COL_WHITE, 0x0000, COL_WHITE, COL_WHITE, COL_WHITE +}; + +Dashboard::Dashboard(Gfx& tft) + : _tft(tft), _sprite(&tft) +{ + _tiles[TILE_LAST_ALERT] = { "!", "LAST ALERT", "none", "", 0, 0, true }; + _tiles[TILE_STATS] = { "#", "TODAY", "0 alerts", "", 0, 0, true }; + _tiles[TILE_NETWORK] = { "~", "NETWORK", "---", "", 0, 0, true }; + _tiles[TILE_MUTE] = { "M", "MUTE", "OFF", "", 0, 0, true }; + _tiles[TILE_HISTORY] = { ">", "HISTORY", "tap to view", "", 0, 0, true }; + _tiles[TILE_SYSTEM] = { "*", "SYSTEM", "---", "", 0, 0, true }; + + for (int i = 0; i < TILE_COUNT; i++) { + _tiles[i].bgColor = tileBG[i]; + _tiles[i].fgColor = tileFG[i]; + } +} + +void Dashboard::begin() { + _sprite.createSprite(TILE_W, TILE_H); + _sprite.setTextDatum(MC_DATUM); +} + +void Dashboard::drawAll() { + _tft.fillScreen(COL_BG); + drawTopBar("--:--", 0, false); + for (int i = 0; i < TILE_COUNT; i++) { + drawTile((TileID)i); + } +} + +void Dashboard::tilePosition(TileID id, int& x, int& y) { + int col = id % DASH_COLS; + int row = id / DASH_COLS; + x = DASH_MARGIN + col * (TILE_W + DASH_MARGIN); + y = DASH_TOP_BAR + DASH_MARGIN + row * (TILE_H + DASH_MARGIN); +} + +void Dashboard::drawTile(TileID id) { + TileData& t = _tiles[id]; + int tx, ty; + tilePosition(id, tx, ty); + + _sprite.fillSprite(COL_BG); + _sprite.fillRoundRect(0, 0, TILE_W, TILE_H, 8, t.bgColor); + _sprite.drawRoundRect(0, 0, TILE_W, TILE_H, 8, COL_GRAY); + + _sprite.setTextColor(t.fgColor, t.bgColor); + _sprite.setTextFont(4); + _sprite.setTextSize(2); + _sprite.setTextDatum(TC_DATUM); + _sprite.drawString(t.icon, TILE_W / 2, 8); + + _sprite.setTextSize(1); + _sprite.setTextFont(2); + _sprite.setTextDatum(MC_DATUM); + _sprite.drawString(t.label, TILE_W / 2, TILE_H / 2 + 5); + + _sprite.setTextFont(2); + _sprite.setTextDatum(BC_DATUM); + _sprite.drawString(t.value, TILE_W / 2, TILE_H - 25); + + if (strlen(t.sub) > 0) { + _sprite.setTextFont(1); + _sprite.setTextDatum(BC_DATUM); + _sprite.drawString(t.sub, TILE_W / 2, TILE_H - 8); + } + + _sprite.pushSprite(tx, ty); + t.dirty = false; +} + +void Dashboard::drawTopBar(const char* time, int rssi, bool wifiOk) { + _tft.fillRect(0, 0, SCREEN_WIDTH, DASH_TOP_BAR, COL_BAR); + _tft.setTextColor(COL_WHITE, COL_BAR); + _tft.setTextFont(4); + _tft.setTextSize(1); + + _tft.setTextDatum(ML_DATUM); + _tft.drawString("KLUBHAUS ALERT", DASH_MARGIN, DASH_TOP_BAR / 2); + + _tft.setTextDatum(MR_DATUM); + _tft.drawString(time, SCREEN_WIDTH - 10, DASH_TOP_BAR / 2); + + int bars = 0; + if (wifiOk) { + if (rssi > -50) bars = 4; + else if (rssi > -60) bars = 3; + else if (rssi > -70) bars = 2; + else bars = 1; + } + int barX = SCREEN_WIDTH - 110, barW = 6, barGap = 3; + for (int i = 0; i < 4; i++) { + int barH = 6 + i * 5; + int barY = DASH_TOP_BAR - 8 - barH; + uint16_t col = (i < bars) ? COL_GREEN : COL_GRAY; + _tft.fillRect(barX + i * (barW + barGap), barY, barW, barH, col); + } + + strncpy(_barTime, time, sizeof(_barTime) - 1); + _barRSSI = rssi; + _barWifiOk = wifiOk; +} + +void Dashboard::updateTile(TileID id, const char* value, const char* sub) { + TileData& t = _tiles[id]; + bool changed = (strcmp(t.value, value) != 0); + if (sub && strcmp(t.sub, sub) != 0) changed = true; + if (!changed && !t.dirty) return; + + strncpy(t.value, value, 31); + t.value[31] = '\0'; + if (sub) { + strncpy(t.sub, sub, 31); + t.sub[31] = '\0'; + } + t.dirty = true; + drawTile(id); +} + +int Dashboard::handleTouch(int x, int y) { + for (int i = 0; i < TILE_COUNT; i++) { + int tx, ty; + tilePosition((TileID)i, tx, ty); + if (x >= tx && x < tx + TILE_W && y >= ty && y < ty + TILE_H) return i; + } + return -1; +} + +void Dashboard::refreshFromState(const ScreenState& state) { + bool barChanged = (strcmp(_barTime, state.timeString) != 0) + || (_barRSSI != state.wifiRSSI) + || (_barWifiOk != state.wifiConnected); + if (barChanged) { + drawTopBar(state.timeString, state.wifiRSSI, state.wifiConnected); + } + + if (state.alertHistoryCount > 0) { + updateTile(TILE_LAST_ALERT, state.alertHistory[0].message, + state.alertHistory[0].timestamp); + } else { + updateTile(TILE_LAST_ALERT, "none", ""); + } + + char statsBuf[32]; + snprintf(statsBuf, sizeof(statsBuf), "%d alert%s", + state.alertHistoryCount, + state.alertHistoryCount == 1 ? "" : "s"); + updateTile(TILE_STATS, statsBuf, "this session"); + + if (state.wifiConnected) { + char rssiBuf[16]; + snprintf(rssiBuf, sizeof(rssiBuf), "%d dBm", state.wifiRSSI); + updateTile(TILE_NETWORK, rssiBuf, state.wifiSSID); + } else { + updateTile(TILE_NETWORK, "DOWN", "reconnecting..."); + } + + updateTile(TILE_MUTE, "OFF", "tap to mute"); + + if (state.alertHistoryCount > 1) { + char histBuf[48]; + snprintf(histBuf, sizeof(histBuf), "%s %.20s", + state.alertHistory[1].timestamp, + state.alertHistory[1].message); + const char* sub = (state.alertHistoryCount > 2) + ? state.alertHistory[2].message : ""; + updateTile(TILE_HISTORY, histBuf, sub); + } else { + updateTile(TILE_HISTORY, "no history", ""); + } + + char heapBuf[16]; + snprintf(heapBuf, sizeof(heapBuf), "%lu KB", state.freeHeapKB); + char uptimeBuf[20]; + snprintf(uptimeBuf, sizeof(uptimeBuf), "up %lum", state.uptimeMinutes); + updateTile(TILE_SYSTEM, heapBuf, uptimeBuf); +} diff --git a/Dashboard.h b/Dashboard.h new file mode 100644 index 0000000..133ca26 --- /dev/null +++ b/Dashboard.h @@ -0,0 +1,56 @@ +#pragma once + +#include "DisplayDriver.h" +#include "ScreenData.h" + +#define DASH_COLS 3 +#define DASH_ROWS 2 +#define DASH_MARGIN 8 +#define DASH_TOP_BAR 40 + +#define TILE_W ((SCREEN_WIDTH - (DASH_COLS + 1) * DASH_MARGIN) / DASH_COLS) +#define TILE_H ((SCREEN_HEIGHT - DASH_TOP_BAR - (DASH_ROWS + 1) * DASH_MARGIN) / DASH_ROWS) + +enum TileID : uint8_t { + TILE_LAST_ALERT = 0, + TILE_STATS, + TILE_NETWORK, + TILE_MUTE, + TILE_HISTORY, + TILE_SYSTEM, + TILE_COUNT +}; + +struct TileData { + const char* icon; + const char* label; + char value[32]; + char sub[32]; + uint16_t bgColor; + uint16_t fgColor; + bool dirty; +}; + +class Dashboard { +public: + Dashboard(Gfx& tft); + + void begin(); + void drawAll(); + void drawTopBar(const char* time, int rssi, bool wifiOk); + void updateTile(TileID id, const char* value, const char* sub = nullptr); + int handleTouch(int x, int y); + void refreshFromState(const ScreenState& state); + +private: + Gfx& _tft; + GfxSprite _sprite; + TileData _tiles[TILE_COUNT]; + + char _barTime[12] = ""; + int _barRSSI = 0; + bool _barWifiOk = false; + + void drawTile(TileID id); + void tilePosition(TileID id, int& x, int& y); +}; diff --git a/DisplayDriver.h b/DisplayDriver.h new file mode 100644 index 0000000..21f3d26 --- /dev/null +++ b/DisplayDriver.h @@ -0,0 +1,117 @@ +#pragma once +#include "BoardConfig.h" + +// ═══════════════════════════════════════════════════════════════════ +// Display driver abstraction +// +// TFT_eSPI path: zero-cost typedefs — compiles identically to before +// Arduino_GFX path: adapter classes providing TFT_eSPI-compatible API +// ═══════════════════════════════════════════════════════════════════ + +#if USE_TFT_ESPI +// ───────────────────────────────────────────────────────────────── +// TFT_eSPI — straight typedefs, zero overhead +// ───────────────────────────────────────────────────────────────── +#include + +using Gfx = TFT_eSPI; +using GfxSprite = TFT_eSprite; + +#elif USE_ARDUINO_GFX +// ───────────────────────────────────────────────────────────────── +// Arduino_GFX — adapter wrapping Arduino_GFX with a +// TFT_eSPI-compatible interface for Dashboard / DisplayManager +// ───────────────────────────────────────────────────────────────── +#include + +// Text datum constants (matching TFT_eSPI definitions) +#ifndef MC_DATUM +#define TL_DATUM 0 +#define TC_DATUM 1 +#define TR_DATUM 2 +#define ML_DATUM 3 +#define CL_DATUM 3 +#define MC_DATUM 4 +#define CC_DATUM 4 +#define MR_DATUM 5 +#define CR_DATUM 5 +#define BL_DATUM 6 +#define BC_DATUM 7 +#define BR_DATUM 8 +#endif + +class Gfx; // forward declaration for GfxSprite + +// ── Sprite adapter ────────────────────────────────────────────── +class GfxSprite { +public: + explicit GfxSprite(Gfx* parent); + + void createSprite(int16_t w, int16_t h); + void deleteSprite(); + void fillSprite(uint16_t color); + void fillRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t color); + void drawRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t color); + void setTextColor(uint16_t fg, uint16_t bg); + void setTextFont(uint8_t font); + void setTextSize(uint8_t size); + void setTextDatum(uint8_t datum); + void drawString(const char* str, int32_t x, int32_t y); + void pushSprite(int32_t x, int32_t y); + +private: + Gfx* _parent; + int16_t _w = 0; + int16_t _h = 0; + int32_t _pushX = 0; + int32_t _pushY = 0; + uint8_t _textDatum = TL_DATUM; + uint8_t _textSize = 1; + uint16_t _textFg = 0xFFFF; + uint16_t _textBg = 0x0000; +}; + +// ── Display adapter ───────────────────────────────────────────── +class Gfx { +public: + Gfx(); + + void init(); + void setRotation(uint8_t r); + + // Drawing primitives + void fillScreen(uint16_t color); + void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, + uint16_t color); + void fillRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t color); + void drawRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t color); + void drawFastVLine(int32_t x, int32_t y, int32_t h, uint16_t color); + + // Text (datum-based API matching TFT_eSPI) + void setTextColor(uint16_t fg, uint16_t bg); + void setTextFont(uint8_t font); + void setTextSize(uint8_t size); + void setTextDatum(uint8_t datum); + void drawString(const char* str, int32_t x, int32_t y); + int16_t textWidth(const char* str); + void setCursor(int32_t x, int32_t y); + void print(const char* str); + + // Escape hatch for direct Arduino_GFX access + Arduino_GFX* raw() { return _gfx; } + +private: + Arduino_GFX* _gfx = nullptr; + uint8_t _textDatum = TL_DATUM; + uint8_t _textSize = 1; + uint16_t _textFg = 0xFFFF; + uint16_t _textBg = 0x0000; +}; + +#else +#error "No display driver selected — check BoardConfig.h" +#endif diff --git a/DisplayDriverGFX.cpp b/DisplayDriverGFX.cpp new file mode 100644 index 0000000..7e0cf58 --- /dev/null +++ b/DisplayDriverGFX.cpp @@ -0,0 +1,194 @@ +// ═══════════════════════════════════════════════════════════════════ +// Arduino_GFX adapter implementation +// Only compiled when USE_ARDUINO_GFX is set (Waveshare path). +// For the TFT_eSPI path this file compiles to nothing. +// ═══════════════════════════════════════════════════════════════════ +#include "BoardConfig.h" +#include + +#if USE_ARDUINO_GFX + +#include "DisplayDriver.h" + +// ───────────────────────────────────────────────────────────────── +// Gfx adapter +// ───────────────────────────────────────────────────────────────── + +Gfx::Gfx() {} + +void Gfx::init() { + // Waveshare ESP32-S3 Touch LCD 4.3" — RGB parallel, ST7262 panel + 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, + 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( + SCREEN_WIDTH, SCREEN_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", SCREEN_WIDTH, SCREEN_HEIGHT); + _gfx->fillScreen(0xF800); // RED TEST + delay(2000); + _gfx->fillScreen(0x07E0); // GREEN TEST + delay(2000); + _gfx->fillScreen(0x001F); // BLUE TEST + delay(2000); _gfx->fillScreen(0x07E0); // GREEN TEST delay(2000); _gfx->fillScreen(0x001F); // BLUE TEST delay(2000); +} + +void Gfx::setRotation(uint8_t r) { if (_gfx) _gfx->setRotation(r); } +void Gfx::fillScreen(uint16_t c) { if (_gfx) _gfx->fillScreen(c); } + +void Gfx::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint16_t c) { + if (_gfx) _gfx->fillRect(x, y, w, h, c); +} +void Gfx::fillRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t c) { + if (_gfx) _gfx->fillRoundRect(x, y, w, h, r, c); +} +void Gfx::drawRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t c) { + if (_gfx) _gfx->drawRoundRect(x, y, w, h, r, c); +} +void Gfx::drawFastVLine(int32_t x, int32_t y, int32_t h, uint16_t c) { + if (_gfx) _gfx->drawFastVLine(x, y, h, c); +} + +void Gfx::setTextColor(uint16_t fg, uint16_t bg) { + _textFg = fg; _textBg = bg; + if (_gfx) _gfx->setTextColor(fg, bg); +} + +void Gfx::setTextFont(uint8_t font) { + // TFT_eSPI font IDs don't map 1:1 to Arduino_GFX. + // Using default built-in font; setTextSize controls scale. + // TODO: Map to GFXfont pointers for better visual fidelity. + (void)font; +} + +void Gfx::setTextSize(uint8_t s) { + _textSize = s; + if (_gfx) _gfx->setTextSize(s); +} + +void Gfx::setTextDatum(uint8_t d) { _textDatum = d; } + +void Gfx::drawString(const char* str, int32_t x, int32_t y) { + if (!_gfx || !str) return; + + int16_t bx, by; + uint16_t tw, th; + _gfx->getTextBounds(str, 0, 0, &bx, &by, &tw, &th); + + // Horizontal alignment from datum + int hAlign = _textDatum % 3; + if (hAlign == 1) x -= (int32_t)tw / 2; // center + else if (hAlign == 2) x -= (int32_t)tw; // right + + // Vertical alignment from datum + int vAlign = _textDatum / 3; + if (vAlign == 1) y -= (int32_t)th / 2; // middle + else if (vAlign == 2) y -= (int32_t)th; // bottom + + _gfx->setCursor(x - bx, y - by); + _gfx->print(str); +} + +int16_t Gfx::textWidth(const char* str) { + if (!_gfx || !str) return 0; + int16_t bx, by; + uint16_t tw, th; + _gfx->getTextBounds(str, 0, 0, &bx, &by, &tw, &th); + return (int16_t)tw; +} + +void Gfx::setCursor(int32_t x, int32_t y) { + if (_gfx) _gfx->setCursor(x, y); +} + +void Gfx::print(const char* str) { + if (_gfx) _gfx->print(str); +} + +// ───────────────────────────────────────────────────────────────── +// GfxSprite adapter +// +// On the 800x480 RGB panel the LCD controller has its own GRAM, +// so direct drawing rarely tears. This implementation is +// intentionally minimal — it renders tiles as direct draws. +// +// TODO: For flicker-free tile updates, allocate an Arduino_Canvas +// backed by PSRAM and blit via draw16bitBeRGBBitmap(). +// ───────────────────────────────────────────────────────────────── + +GfxSprite::GfxSprite(Gfx* parent) : _parent(parent) {} + +void GfxSprite::createSprite(int16_t w, int16_t h) { + _w = w; _h = h; + Serial.printf("[GFX] Sprite %dx%d created (direct-draw mode)\n", w, h); +} + +void GfxSprite::deleteSprite() { _w = 0; _h = 0; } + +void GfxSprite::fillSprite(uint16_t color) { + if (_parent && _parent->raw()) + _parent->raw()->fillRect(_pushX, _pushY, _w, _h, color); +} + +void GfxSprite::fillRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t c) { + if (_parent && _parent->raw()) + _parent->raw()->fillRoundRect(_pushX + x, _pushY + y, w, h, r, c); +} + +void GfxSprite::drawRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, + int32_t r, uint16_t c) { + if (_parent && _parent->raw()) + _parent->raw()->drawRoundRect(_pushX + x, _pushY + y, w, h, r, c); +} + +void GfxSprite::setTextColor(uint16_t fg, uint16_t bg) { + _textFg = fg; _textBg = bg; + if (_parent && _parent->raw()) _parent->raw()->setTextColor(fg, bg); +} + +void GfxSprite::setTextFont(uint8_t font) { (void)font; } + +void GfxSprite::setTextSize(uint8_t size) { + _textSize = size; + if (_parent && _parent->raw()) _parent->raw()->setTextSize(size); +} + +void GfxSprite::setTextDatum(uint8_t datum) { _textDatum = datum; } + +void GfxSprite::drawString(const char* str, int32_t x, int32_t y) { + if (!_parent) return; + _parent->setTextDatum(_textDatum); + _parent->setTextSize(_textSize); + _parent->setTextColor(_textFg, _textBg); + _parent->drawString(str, _pushX + x, _pushY + y); +} + +void GfxSprite::pushSprite(int32_t x, int32_t y) { + // Record the offset for subsequent draw calls in the next cycle. + // NOTE: In the TFT_eSPI path, sprite drawing happens BEFORE pushSprite + // (draws into offscreen buffer, then blits). In this direct-draw stub, + // the offset from the PREVIOUS pushSprite call is used. After one full + // drawAll() cycle, all tiles render at the correct positions. + _pushX = x; + _pushY = y; +} + +#endif // USE_ARDUINO_GFX diff --git a/DisplayManager.cpp b/DisplayManager.cpp new file mode 100644 index 0000000..6bffdfb --- /dev/null +++ b/DisplayManager.cpp @@ -0,0 +1,481 @@ +#include "DisplayManager.h" +#include "Config.h" + +#if USE_TOUCH_GT911 +#include "TouchDriver.h" +#endif + +DisplayManager::DisplayManager() : _dash(_tft) { } + +void DisplayManager::begin() { + pinMode(PIN_LCD_BL, OUTPUT); + setBacklight(true); + _tft.init(); + _tft.setRotation(DISPLAY_ROTATION); + _tft.fillScreen(COL_BLACK); + +#if USE_TOUCH_XPT2046 + uint16_t calData[5] = { 300, 3600, 300, 3600, 1 }; + _tft.setTouch(calData); +#elif USE_TOUCH_GT911 + TouchDriver::begin(); +#endif +} + +void DisplayManager::setBacklight(bool on) { + digitalWrite(PIN_LCD_BL, on ? HIGH : LOW); +} + +TouchEvent DisplayManager::readTouch() { + TouchEvent evt; + uint16_t x, y; + bool touched = false; + +#if USE_TOUCH_XPT2046 + touched = _tft.getTouch(&x, &y); +#elif USE_TOUCH_GT911 + touched = TouchDriver::read(x, y); +#endif + + if (touched) { + evt.pressed = true; + evt.x = x; + evt.y = y; + } + return evt; +} + +int DisplayManager::dashboardTouch(uint16_t x, uint16_t y) { + if (_lastScreen != ScreenID::DASHBOARD) return -1; + return _dash.handleTouch(x, y); +} + +// ===================================================================== +// Hold detection — charge up, then release to confirm +// ===================================================================== +HoldState DisplayManager::updateHold(unsigned long requiredMs) { + HoldState h; + h.targetMs = requiredMs; + + uint16_t tx, ty; + bool touching = false; + +#if USE_TOUCH_XPT2046 + touching = _tft.getTouch(&tx, &ty); +#elif USE_TOUCH_GT911 + touching = TouchDriver::read(tx, ty); +#endif + + if (touching) { + if (!_holdActive) { + _holdActive = true; + _holdCharged = false; + _holdStartMs = millis(); + _holdChargeMs = 0; + _holdX = tx; + _holdY = ty; + } + + h.active = true; + h.x = _holdX; + h.y = _holdY; + h.holdMs = millis() - _holdStartMs; + h.progress = min((float)h.holdMs / (float)requiredMs, 1.0f); + _holdProgress = h.progress; + + if (h.holdMs >= requiredMs) { + _holdCharged = true; + if (_holdChargeMs == 0) _holdChargeMs = millis(); + h.charged = true; + } + + } else { + if (_holdActive) { + if (_holdCharged) { + h.completed = true; + h.x = _holdX; + h.y = _holdY; + Serial.println("[HOLD] Charged + released -> completed!"); + } else { + h.cancelled = true; + Serial.println("[HOLD] Released early -> cancelled"); + } + } + _holdActive = false; + _holdCharged = false; + _holdProgress = 0.0f; + _holdChargeMs = 0; + } + + return h; +} + +float DisplayManager::holdProgress() const { + if (!_holdActive) return 0.0f; + return constrain((float)(millis() - _holdStartMs) / (float)HOLD_DURATION_MS, + 0.0f, 1.0f); +} + +// ===================================================================== +// Render +// ===================================================================== +void DisplayManager::render(const ScreenState& state) { + if (state.screen != _lastScreen) { + _needsFullRedraw = true; + if (state.screen == ScreenID::OFF) { + setBacklight(false); + } else if (_lastScreen == ScreenID::OFF) { + setBacklight(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: + if (_needsFullRedraw || state.blinkPhase != _lastBlink) { + drawAlertScreen(state); + _lastBlink = state.blinkPhase; + } + if (_holdProgress > 0.0f || _holdCharged) { + drawSilenceProgress(_holdProgress, _holdCharged); + } + break; + case ScreenID::STATUS: + if (_needsFullRedraw) drawStatusScreen(state); + break; + case ScreenID::DASHBOARD: + drawDashboard(state); + break; + case ScreenID::OFF: + if (_needsFullRedraw) { + _tft.fillScreen(COL_BLACK); + _dashSpriteReady = false; + } + break; + } + + _needsFullRedraw = false; +} + +// ===================================================================== +// Dashboard +// ===================================================================== +void DisplayManager::drawDashboard(const ScreenState& s) { + if (_needsFullRedraw) { + if (!_dashSpriteReady) { + _dash.begin(); + _dashSpriteReady = true; + } + _dash.drawAll(); + _dash.refreshFromState(s); + _lastDashRefresh = millis(); + } else if (millis() - _lastDashRefresh > 2000) { + _lastDashRefresh = millis(); + _dash.refreshFromState(s); + } +} + +// ===================================================================== +// Silence progress bar +// ===================================================================== +void DisplayManager::drawSilenceProgress(float progress, bool charged) { + const int barX = 20; + const int barY = SCREEN_HEIGHT - 50; + const int barW = SCREEN_WIDTH - 40; + const int barH = 26; + const int radius = 6; + + _tft.fillRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY); + + if (charged) { + float breath = (sinf(millis() / 150.0f) + 1.0f) / 2.0f; + uint8_t gLo = 42, gHi = 63; + uint8_t g = gLo + (uint8_t)(breath * (float)(gHi - gLo)); + uint16_t pulseCol = (g << 5); + + _tft.fillRoundRect(barX, barY, barW, barH, radius, pulseCol); + _tft.drawRoundRect(barX, barY, barW, barH, radius, COL_WHITE); + + _tft.setTextDatum(MC_DATUM); + _tft.setTextFont(2); + _tft.setTextSize(1); + _tft.setTextColor(COL_WHITE, pulseCol); + _tft.drawString("RELEASE", barX + barW / 2, barY + barH / 2); + return; + } + + float eased = 1.0f - powf(1.0f - progress, 3.0f); + int fillW = max(1, (int)(eased * (float)barW)); + + uint8_t gMin = 16, gMax = 58; + for (int i = 0; i < fillW; i++) { + float frac = (float)i / (float)barW; + uint8_t g = gMin + (uint8_t)(frac * (float)(gMax - gMin)); + _tft.drawFastVLine(barX + i, barY + 1, barH - 2, (uint16_t)(g << 5)); + } + + _tft.drawRoundRect(barX, barY, barW, barH, radius, COL_WHITE); + + _tft.setTextDatum(MC_DATUM); + _tft.setTextFont(2); + _tft.setTextSize(1); + _tft.setTextColor(COL_WHITE, COL_DARK_GRAY); + _tft.drawString("HOLD", barX + barW / 2, barY + barH / 2); +} + +// ===================================================================== +// Helpers +// ===================================================================== +void DisplayManager::drawCentered(const char* txt, int y, int sz, uint16_t col) { + _tft.setTextFont(1); + _tft.setTextSize(sz); + _tft.setTextColor(col, COL_BLACK); + int w = _tft.textWidth(txt); + _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.setTextFont(1); + _tft.setTextSize(1); + _tft.setTextColor(col, COL_BLACK); + _tft.setCursor(x, y); + _tft.print(text); +} + +void DisplayManager::drawHeaderBar(uint16_t col, const char* label, + const char* timeStr) { + _tft.setTextFont(1); + _tft.setTextSize(2); + _tft.setTextColor(col, COL_BLACK); + _tft.setCursor(8, 8); + _tft.print(label); + int tw = _tft.textWidth(timeStr); + _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); + + char verBuf[48]; + snprintf(verBuf, sizeof(verBuf), "v5.1 [%s]", BOARD_NAME); + drawCentered(verBuf, 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); + + 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) { + 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); + } + + if (_holdProgress == 0.0f && !_holdCharged) { + drawCentered("HOLD 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, sp = 22, 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 (s.alertHistoryCount > 0) { + drawInfoLine(x, y, COL_MINT, "Recent Alerts:"); + y += sp; + for (int i = 0; i < s.alertHistoryCount; i++) { + uint16_t col = (i == 0) ? COL_YELLOW : COL_DARK_GRAY; + snprintf(buf, sizeof(buf), "%s %.35s", + s.alertHistory[i].timestamp, + s.alertHistory[i].message); + drawInfoLine(x, y, col, buf); + y += sp; + } + } else { + drawInfoLine(x, y, COL_DARK_GRAY, "No alerts yet"); + y += sp; + } + + drawCentered("tap to dismiss", SCREEN_HEIGHT - 18, 1, COL_DARK_GRAY); +} + +// ===================================================================== +// Hint animation +// ===================================================================== + +void DisplayManager::startHintCycle() { + _hint = HintAnim{}; + _hint.lastPlayMs = millis(); +} + +void DisplayManager::stopHint() { + _hint.running = false; +} + +bool DisplayManager::updateHint() { + unsigned long now = millis(); + + if (_holdActive) { + _hint.running = false; + return false; + } + + if (!_hint.running) { + unsigned long gap = _hint.lastPlayMs == 0 + ? HintAnim::INITIAL_DELAY + : HintAnim::REPEAT_DELAY; + + if (now - _hint.lastPlayMs >= gap) { + _hint.running = true; + _hint.startMs = now; + } else { + return false; + } + } + + unsigned long elapsed = now - _hint.startMs; + + if (elapsed > _hint.totalDur()) { + _hint.running = false; + _hint.lastPlayMs = now; + drawSilenceProgress(0.0f, false); + return true; + } + + float progress = 0.0f; + + if (elapsed < HintAnim::FILL_DUR) { + float t = (float)elapsed / (float)HintAnim::FILL_DUR; + progress = HintAnim::PEAK * (t * t); + + } else if (elapsed < HintAnim::FILL_DUR + HintAnim::HOLD_DUR) { + progress = HintAnim::PEAK; + + } else { + float t = (float)(elapsed - HintAnim::FILL_DUR - HintAnim::HOLD_DUR) + / (float)HintAnim::DRAIN_DUR; + progress = HintAnim::PEAK * (1.0f - t * t); + } + + drawHintBar(progress); + return true; +} + +void DisplayManager::drawHintBar(float progress) { + const int barX = 20; + const int barY = SCREEN_HEIGHT - 50; + const int barW = SCREEN_WIDTH - 40; + const int barH = 26; + const int radius = 6; + + _tft.fillRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY); + + if (progress > 0.001f) { + int fillW = max(1, (int)(progress * (float)barW)); + + for (int i = 0; i < fillW; i++) { + float frac = (float)i / (float)barW; + uint8_t g = 12 + (uint8_t)(frac * 18.0f); + uint8_t b = 8 + (uint8_t)(frac * 10.0f); + uint16_t col = (g << 5) | b; + _tft.drawFastVLine(barX + i, barY + 1, barH - 2, col); + } + } + + _tft.drawRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY); + + _tft.setTextDatum(MC_DATUM); + _tft.setTextFont(2); + _tft.setTextSize(1); + _tft.setTextColor(COL_DARK_GRAY, COL_DARK_GRAY); + _tft.drawString("HOLD TO SILENCE", barX + barW / 2, barY + barH / 2); +} diff --git a/DisplayManager.h b/DisplayManager.h new file mode 100644 index 0000000..e6bdaef --- /dev/null +++ b/DisplayManager.h @@ -0,0 +1,99 @@ +#pragma once + +#include "DisplayDriver.h" +#include "ScreenData.h" +#include "Dashboard.h" + +// Hold gesture result +struct HoldState { + bool active = false; + bool charged = false; + bool completed = false; + bool cancelled = false; + uint16_t x = 0; + uint16_t y = 0; + unsigned long holdMs = 0; + unsigned long targetMs = 0; + float progress = 0.0f; +}; + +// Hint animation state +struct HintAnim { + bool running = false; + unsigned long startMs = 0; + unsigned long lastPlayMs = 0; + + static const unsigned long INITIAL_DELAY = 1500; + static const unsigned long FILL_DUR = 400; + static const unsigned long HOLD_DUR = 250; + static const unsigned long DRAIN_DUR = 500; + static const unsigned long REPEAT_DELAY = 5000; + + static constexpr float PEAK = 0.35f; + + unsigned long totalDur() const { return FILL_DUR + HOLD_DUR + DRAIN_DUR; } +}; + +class DisplayManager { +public: + DisplayManager(); + void begin(); + void render(const ScreenState& state); + void setBacklight(bool on); + TouchEvent readTouch(); + + int dashboardTouch(uint16_t x, uint16_t y); + HoldState updateHold(unsigned long requiredMs); + void startHintCycle(); + void stopHint(); + bool updateHint(); + float holdProgress() const; + +private: + HintAnim _hint; + void drawHintBar(float progress); + + Gfx _tft; + Dashboard _dash; + + ScreenID _lastScreen = ScreenID::BOOT_SPLASH; + bool _needsFullRedraw = true; + bool _lastBlink = false; + bool _dashSpriteReady = false; + unsigned long _lastDashRefresh = 0; + + // Hold tracking + bool _holdActive = false; + bool _holdCharged = false; + unsigned long _holdStartMs = 0; + unsigned long _holdChargeMs = 0; + uint16_t _holdX = 0; + uint16_t _holdY = 0; + float _holdProgress = 0.0f; + + // 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); + void drawDashboard(const ScreenState& s); + void drawSilenceProgress(float progress, bool charged); + + // 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/DoorbellLogic.cpp b/DoorbellLogic.cpp new file mode 100644 index 0000000..6ca2400 --- /dev/null +++ b/DoorbellLogic.cpp @@ -0,0 +1,576 @@ +#include "DoorbellLogic.h" + +// ===================================================================== +// Lifecycle +// ===================================================================== +void DoorbellLogic::begin() { + _bootTime = millis(); + _timeClient = new NTPClient(_ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); + + _screen.debugMode = DEBUG_MODE; + _screen.screen = ScreenID::BOOT_SPLASH; + updateScreenState(); +} + +void DoorbellLogic::beginWiFi() { + _instance = this; + + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.setAutoReconnect(true); + WiFi.onEvent(onWiFiEvent); + + for (int i = 0; i < NUM_WIFI; i++) { + _wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass); + } + + _screen.screen = ScreenID::WIFI_CONNECTING; + updateScreenState(); +} + +void DoorbellLogic::connectWiFiBlocking() { + Serial.println("[WIFI] Connecting..."); + + int tries = 0; + while (_wifiMulti.run() != WL_CONNECTED && tries++ < 40) { + Serial.print("."); + delay(500); + } + Serial.println(); + + if (WiFi.isConnected()) { + Serial.printf("[WIFI] Connected: %s %s\n", + WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); + updateScreenState(); + _screen.screen = ScreenID::WIFI_CONNECTED; + } else { + Serial.println("[WIFI] FAILED — no networks reachable"); + _screen.screen = ScreenID::WIFI_FAILED; + } + updateScreenState(); +} + +void DoorbellLogic::finishBoot() { + if (WiFi.isConnected()) { + _timeClient->begin(); + Serial.println("[NTP] Starting sync..."); + + for (int i = 0; i < 5 && !_ntpSynced; i++) { + syncNTP(); + if (!_ntpSynced) delay(500); + } + + if (_ntpSynced) { + Serial.printf("[NTP] Synced: %s UTC\n", + _timeClient->getFormattedTime().c_str()); + } else { + Serial.println("[NTP] Initial sync failed — will retry in update()"); + } + + checkNetwork(); + + char bootMsg[80]; + snprintf(bootMsg, sizeof(bootMsg), "%s %s RSSI:%d", + WiFi.SSID().c_str(), + WiFi.localIP().toString().c_str(), + WiFi.RSSI()); + queueStatus("BOOTED", bootMsg); + flushStatus(); + } + + Serial.printf("[CONFIG] ALERT_URL: %s\n", ALERT_URL); + Serial.printf("[CONFIG] SILENCE_URL: %s\n", SILENCE_URL); + Serial.printf("[CONFIG] ADMIN_URL: %s\n", ADMIN_URL); + + transitionTo(DeviceState::SILENT); + Serial.printf("[BOOT] Grace period: %d ms\n", BOOT_GRACE_MS); +} + +// ===================================================================== +// Main Update Loop +// ===================================================================== +void DoorbellLogic::update() { + unsigned long now = millis(); + + if (_inBootGrace && (now - _bootTime >= BOOT_GRACE_MS)) { + _inBootGrace = false; + Serial.println("[BOOT] Grace period ended"); + } + + syncNTP(); + + if (!WiFi.isConnected()) { + if (_wifiMulti.run() == WL_CONNECTED) { + Serial.println("[WIFI] Reconnected"); + queueStatus("RECONNECTED", WiFi.SSID().c_str()); + } + } + + if (now - _lastPoll >= POLL_INTERVAL_MS) { + _lastPoll = now; + if (WiFi.isConnected() && _ntpSynced) { + pollTopics(); + } + } + + flushStatus(); + + switch (_state) { + case DeviceState::ALERTING: + if (now - _lastBlink >= BLINK_INTERVAL_MS) { + _lastBlink = now; + _blinkState = !_blinkState; + } + break; + + case DeviceState::WAKE: + if (now - _wakeStart > WAKE_DISPLAY_MS) { + transitionTo(DeviceState::SILENT); + } + break; + + case DeviceState::SILENT: + break; + } + + if (now - _lastHeartbeat >= HEARTBEAT_INTERVAL_MS) { + _lastHeartbeat = now; + uint32_t heap = ESP.getFreeHeap(); + Serial.printf("[%lus] %s | WiFi:%s RSSI:%d | heap:%dKB | minHeap:%dKB\n", + now / 1000, + _state == DeviceState::SILENT ? "SILENT" : + _state == DeviceState::ALERTING ? "ALERT" : "WAKE", + WiFi.isConnected() ? "OK" : "DOWN", + WiFi.RSSI(), + heap / 1024, + ESP.getMinFreeHeap() / 1024); + + if (heap < 20000) { + Serial.println("[HEAP] CRITICAL — rebooting!"); + queueStatus("REBOOTING", "low heap"); + flushStatus(); + delay(200); + ESP.restart(); + } + } + + updateScreenState(); +} + +// ===================================================================== +// Input Events +// ===================================================================== +void DoorbellLogic::onTouch(const TouchEvent& evt) { + if (!evt.pressed) return; + + static unsigned long lastAction = 0; + unsigned long now = millis(); + if (now - lastAction < TOUCH_DEBOUNCE_MS) return; + lastAction = now; + + Serial.printf("[TOUCH] x=%d y=%d state=%d\n", evt.x, evt.y, (int)_state); + + switch (_state) { + case DeviceState::ALERTING: + handleSilence("touch"); + break; + case DeviceState::SILENT: + transitionTo(DeviceState::WAKE); + break; + case DeviceState::WAKE: + transitionTo(DeviceState::SILENT); + break; + } +} + +void DoorbellLogic::onSerialCommand(const String& cmd) { + if (cmd == "CLEAR_DEDUP") { + _lastAlertId = _lastSilenceId = _lastAdminId = ""; + Serial.println("[CMD] Dedup cleared"); + } else if (cmd == "NET") { + checkNetwork(); + } else if (cmd == "STATUS") { + Serial.printf("[CMD] State:%s WiFi:%s RSSI:%d Heap:%dKB NTP:%s\n", + _state == DeviceState::SILENT ? "SILENT" : + _state == DeviceState::ALERTING ? "ALERT" : "WAKE", + WiFi.isConnected() ? WiFi.SSID().c_str() : "DOWN", + WiFi.RSSI(), + ESP.getFreeHeap() / 1024, + _ntpSynced ? _timeClient->getFormattedTime().c_str() : "no"); + } else if (cmd == "WAKE") { + transitionTo(DeviceState::WAKE); + } else if (cmd == "TEST") { + handleAlert("TEST ALERT"); + } else if (cmd == "REBOOT") { + Serial.println("[CMD] Rebooting..."); + queueStatus("REBOOTING", "serial"); + flushStatus(); + delay(200); + ESP.restart(); + } else { + Serial.printf("[CMD] Unknown: %s\n", cmd.c_str()); + } +} + +// ===================================================================== +// State Transitions +// ===================================================================== +void DoorbellLogic::transitionTo(DeviceState newState) { + _state = newState; + unsigned long now = millis(); + + switch (newState) { + case DeviceState::SILENT: + _screen.screen = ScreenID::OFF; + _alertMsgEpoch = 0; + Serial.println("-> SILENT"); + break; + case DeviceState::ALERTING: + _alertStart = now; + _lastBlink = now; + _blinkState = false; + _screen.screen = ScreenID::ALERT; + Serial.printf("-> ALERTING: %s\n", _currentMessage.c_str()); + break; + case DeviceState::WAKE: + _wakeStart = now; + _screen.screen = ScreenID::DASHBOARD; // ← CHANGED from STATUS + Serial.println("-> WAKE (dashboard)"); // ← CHANGED + break; + } +} + +// ===================================================================== +// Message Handlers +// ===================================================================== +void DoorbellLogic::handleAlert(const String& msg) { + if (_state == DeviceState::ALERTING && _currentMessage == msg) return; + _currentMessage = msg; + _alertMsgEpoch = _lastParsedMsgEpoch; + + for (int i = ALERT_HISTORY_SIZE - 1; i > 0; i--) { + _screen.alertHistory[i] = _screen.alertHistory[i - 1]; + } + strncpy(_screen.alertHistory[0].message, msg.c_str(), 63); + _screen.alertHistory[0].message[63] = '\0'; + strncpy(_screen.alertHistory[0].timestamp, + _ntpSynced ? _timeClient->getFormattedTime().c_str() : "??:??:??", 11); + _screen.alertHistory[0].timestamp[11] = '\0'; + + if (_screen.alertHistoryCount < ALERT_HISTORY_SIZE) + _screen.alertHistoryCount++; + + Serial.printf("[ALERT] Accepted. ntfy time=%ld history=%d\n", + (long)_alertMsgEpoch, _screen.alertHistoryCount); + transitionTo(DeviceState::ALERTING); + queueStatus("ALERTING", msg); +} + +void DoorbellLogic::handleSilence(const String& msg) { + if (_state != DeviceState::ALERTING) { + Serial.println("[SILENCE] Ignored — not alerting"); + return; + } + + if (_lastParsedMsgEpoch > 0 && _alertMsgEpoch > 0 && + _lastParsedMsgEpoch <= _alertMsgEpoch) { + Serial.printf("[SILENCE] Ignored — predates alert (silence:%ld <= alert:%ld)\n", + (long)_lastParsedMsgEpoch, (long)_alertMsgEpoch); + return; + } + + Serial.printf("[SILENCE] Accepted (silence:%ld > alert:%ld)\n", + (long)_lastParsedMsgEpoch, (long)_alertMsgEpoch); + _currentMessage = ""; + _alertMsgEpoch = 0; + transitionTo(DeviceState::SILENT); + queueStatus("SILENT", "silenced"); +} + +void DoorbellLogic::handleAdmin(const String& msg) { + Serial.printf("[ADMIN] %s\n", msg.c_str()); + + if (msg == "SILENCE") handleSilence("admin"); + else if (msg == "PING") queueStatus("PONG", "ping"); + else if (msg == "test") handleAlert("TEST ALERT"); + else if (msg == "status") { + char buf[128]; + snprintf(buf, sizeof(buf), "State:%s WiFi:%s RSSI:%d Heap:%dKB", + _state == DeviceState::SILENT ? "SILENT" : + _state == DeviceState::ALERTING ? "ALERT" : "WAKE", + WiFi.SSID().c_str(), WiFi.RSSI(), ESP.getFreeHeap() / 1024); + queueStatus("STATUS", buf); + } + else if (msg == "wake") { + transitionTo(DeviceState::WAKE); + } + else if (msg == "REBOOT") { + queueStatus("REBOOTING", "admin"); + flushStatus(); + delay(200); + ESP.restart(); + } +} + +// ===================================================================== +// Screen State Sync +// ===================================================================== +void DoorbellLogic::updateScreenState() { + _screen.deviceState = _state; + _screen.blinkPhase = _blinkState; + strncpy(_screen.alertMessage, _currentMessage.c_str(), sizeof(_screen.alertMessage) - 1); + _screen.alertMessage[sizeof(_screen.alertMessage) - 1] = '\0'; + + _screen.wifiConnected = WiFi.isConnected(); + if (_screen.wifiConnected) { + strncpy(_screen.wifiSSID, WiFi.SSID().c_str(), sizeof(_screen.wifiSSID) - 1); + _screen.wifiSSID[sizeof(_screen.wifiSSID) - 1] = '\0'; + strncpy(_screen.wifiIP, WiFi.localIP().toString().c_str(), sizeof(_screen.wifiIP) - 1); + _screen.wifiIP[sizeof(_screen.wifiIP) - 1] = '\0'; + _screen.wifiRSSI = WiFi.RSSI(); + } + + _screen.ntpSynced = _ntpSynced; + if (_ntpSynced) { + strncpy(_screen.timeString, _timeClient->getFormattedTime().c_str(), + sizeof(_screen.timeString) - 1); + _screen.timeString[sizeof(_screen.timeString) - 1] = '\0'; + } + + _screen.uptimeMinutes = (millis() - _bootTime) / 60000; + _screen.freeHeapKB = ESP.getFreeHeap() / 1024; + _screen.networkOK = _networkOK; +} + +// ===================================================================== +// WiFi & Network +// ===================================================================== +void DoorbellLogic::syncNTP() { + if (_timeClient->update()) { + _ntpSynced = true; + _lastEpoch = _timeClient->getEpochTime(); + } +} + +void DoorbellLogic::checkNetwork() { + Serial.printf("[NET] WiFi:%s RSSI:%d IP:%s\n", + WiFi.SSID().c_str(), WiFi.RSSI(), WiFi.localIP().toString().c_str()); + + IPAddress ip; + if (!WiFi.hostByName("ntfy.sh", ip)) { + Serial.println("[NET] DNS FAILED"); + _networkOK = false; + return; + } + Serial.printf("[NET] DNS OK: %s\n", ip.toString().c_str()); + + WiFiClientSecure tls; + tls.setInsecure(); + if (tls.connect("ntfy.sh", 443, 15000)) { + Serial.println("[NET] TLS OK"); + tls.stop(); + _networkOK = true; + } else { + Serial.println("[NET] TLS FAILED"); + _networkOK = false; + } +} + +// ===================================================================== +// ntfy Polling +// ===================================================================== +void DoorbellLogic::pollTopics() { + Serial.printf("[POLL] Starting poll cycle... WiFi:%s NTP:%d Grace:%d\n", + WiFi.isConnected() ? "OK" : "DOWN", _ntpSynced, _inBootGrace); + + pollTopic(ALERT_URL, &DoorbellLogic::handleAlert, "ALERT", _lastAlertId); + yield(); + pollTopic(SILENCE_URL, &DoorbellLogic::handleSilence, "SILENCE", _lastSilenceId); + yield(); + pollTopic(ADMIN_URL, &DoorbellLogic::handleAdmin, "ADMIN", _lastAdminId); + + Serial.printf("[POLL] Done. Heap: %dKB\n", ESP.getFreeHeap() / 1024); +} + +void DoorbellLogic::pollTopic(const char* url, + void (DoorbellLogic::*handler)(const String&), + const char* name, String& lastId) { + Serial.printf("[%s] Polling: %s\n", name, url); + + if (!WiFi.isConnected()) { + Serial.printf("[%s] SKIPPED — WiFi down\n", name); + return; + } + + WiFiClientSecure client; + client.setInsecure(); + client.setTimeout(10); + + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setTimeout(10000); + http.setReuse(false); + + if (!http.begin(client, url)) { + Serial.printf("[%s] begin() FAILED\n", name); + return; + } + + int code = http.GET(); + Serial.printf("[%s] HTTP %d\n", name, code); + + if (code == HTTP_CODE_OK) { + String response = http.getString(); + Serial.printf("[%s] %d bytes\n", name, response.length()); + if (response.length() > 0) { + parseMessages(response, name, handler, lastId); + } else { + Serial.printf("[%s] Empty response\n", name); + } + } else if (code < 0) { + Serial.printf("[%s] ERROR: %s\n", name, http.errorToString(code).c_str()); + _networkOK = false; + } else { + Serial.printf("[%s] Unexpected code: %d\n", name, code); + } + + http.end(); + client.stop(); + yield(); +} + +void DoorbellLogic::parseMessages(String& response, const char* name, + void (DoorbellLogic::*handler)(const String&), + String& lastId) { + Serial.printf("[%s] parseMessages: grace=%d ntp=%d epoch=%ld\n", + name, _inBootGrace, _ntpSynced, (long)_lastEpoch); + + if (_inBootGrace || !_ntpSynced || _lastEpoch == 0) { + Serial.printf("[%s] SKIPPED — guard failed\n", name); + return; + } + + int lineStart = 0; + int msgCount = 0; + while (lineStart < (int)response.length()) { + int lineEnd = response.indexOf('\n', lineStart); + if (lineEnd == -1) lineEnd = response.length(); + + String line = response.substring(lineStart, lineEnd); + line.trim(); + + if (line.length() > 0 && line.indexOf('{') >= 0) { + msgCount++; + JsonDocument doc; + if (deserializeJson(doc, line)) { + Serial.printf("[%s] JSON parse FAILED on line %d\n", name, msgCount); + lineStart = lineEnd + 1; + continue; + } + + const char* event = doc["event"]; + const char* msgId = doc["id"]; + const char* message = doc["message"]; + time_t msgTime = doc["time"] | 0; + + Serial.printf("[%s] msg#%d: event=%s id=%s time=%ld msg=%.30s\n", + name, msgCount, + event ? event : "null", + msgId ? msgId : "null", + (long)msgTime, + message ? message : "null"); + + if (event && strcmp(event, "message") != 0) { + Serial.printf("[%s] SKIP — not a message event (event=%s)\n", name, event); + lineStart = lineEnd + 1; + continue; + } + + if (!message || strlen(message) == 0) { + Serial.printf("[%s] SKIP — empty message\n", name); + lineStart = lineEnd + 1; + continue; + } + + String idStr = msgId ? String(msgId) : ""; + if (idStr.length() > 0 && idStr == lastId) { + Serial.printf("[%s] SKIP — dedup (id=%s)\n", name, msgId); + lineStart = lineEnd + 1; + continue; + } + + if (msgTime > 0 && (_lastEpoch - msgTime) > (time_t)STALE_MSG_THRESHOLD_S) { + Serial.printf("[%s] SKIP — stale (age=%llds, threshold=%ds)\n", + name, (long long)(_lastEpoch - msgTime), STALE_MSG_THRESHOLD_S); + lineStart = lineEnd + 1; + continue; + } + + Serial.printf("[%s] ACCEPTED: %.50s\n", name, message); + if (idStr.length() > 0) lastId = idStr; + + _lastParsedMsgEpoch = msgTime; + (this->*handler)(String(message)); + _lastParsedMsgEpoch = 0; + } + lineStart = lineEnd + 1; + } + + Serial.printf("[%s] Parsed %d JSON objects\n", name, msgCount); +} + +// ===================================================================== +// Status Publishing +// ===================================================================== +void DoorbellLogic::queueStatus(const char* st, const String& msg) { + _pendingStatus = true; + _pendStatusState = st; + _pendStatusMsg = msg; + Serial.printf("[STATUS] Queued: %s — %s\n", st, msg.c_str()); +} + +void DoorbellLogic::flushStatus() { + if (!_pendingStatus || !WiFi.isConnected()) return; + _pendingStatus = false; + + JsonDocument doc; + doc["state"] = _pendStatusState; + doc["message"] = _pendStatusMsg; + doc["timestamp"] = _ntpSynced ? (long long)_timeClient->getEpochTime() * 1000LL : 0LL; + + String payload; + serializeJson(doc, payload); + + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.begin(client, STATUS_URL); + http.addHeader("Content-Type", "application/json"); + int code = http.POST(payload); + http.end(); + + Serial.printf("[STATUS] Sent (%d): %s\n", code, _pendStatusState.c_str()); +} + +DoorbellLogic* DoorbellLogic::_instance = nullptr; + +void DoorbellLogic::onWiFiEvent(WiFiEvent_t event) { + if (!_instance) return; + switch (event) { + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: + Serial.println("[WIFI] Disconnected — will reconnect"); + WiFi.reconnect(); + break; + case ARDUINO_EVENT_WIFI_STA_CONNECTED: + Serial.println("[WIFI] Reconnected to AP"); + break; + case ARDUINO_EVENT_WIFI_STA_GOT_IP: + Serial.printf("[WIFI] Got IP: %s\n", WiFi.localIP().toString().c_str()); + break; + default: + break; + } +} + diff --git a/DoorbellLogic.h b/DoorbellLogic.h new file mode 100644 index 0000000..c13abd1 --- /dev/null +++ b/DoorbellLogic.h @@ -0,0 +1,95 @@ +#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; + + // Stale silence protection + time_t _alertMsgEpoch = 0; // ntfy timestamp of the alert that started ALERTING + time_t _lastParsedMsgEpoch = 0; // ntfy timestamp of message currently being handled + + + // Deferred status publish + bool _pendingStatus = false; + String _pendStatusState; + String _pendStatusMsg; + + // NTP — pointer because NTPClient has no default constructor + 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(); + static DoorbellLogic* _instance; // for static event callback + static void onWiFiEvent(WiFiEvent_t event); + +}; + diff --git a/README.md b/README.md new file mode 100644 index 0000000..8658ca4 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# 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 | 800x480 RGB parallel | Arduino_GFX | `mise run compile-s3-43` | + +## Quick Start + +1. Install prerequisites: arduino-cli (with esp32:esp32 platform) and mise. + +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 + + Then edit both secrets.h files with your WiFi SSIDs and passwords. + +3. Build and upload: + + mise run compile-s3-43 + mise run upload-s3-43 + + mise run compile-32e + mise run upload-32e + +## Project Structure + + . + ├── libraries/ + │ └── KlubhausCore/ Shared Arduino library + │ └── src/ + │ ├── KlubhausCore.h Umbrella include + │ ├── Config.h Shared constants + │ ├── ScreenState.h State enums / structs + │ ├── IDisplayDriver.h Abstract display interface + │ ├── DisplayManager.h Thin delegation wrapper + │ ├── NetManager.* WiFi, NTP, HTTP + │ └── DoorbellLogic.* State machine, ntfy polling + ├── boards/ + │ ├── esp32-32e/ ESP32-32E sketch + │ │ ├── esp32-32e.ino + │ │ ├── board_config.h + │ │ ├── secrets.h (gitignored) + │ │ ├── tft_user_setup.h + │ │ └── DisplayDriverTFT.* + │ └── esp32-s3-lcd-43/ ESP32-S3-LCD-4.3 sketch + │ ├── esp32-s3-lcd-43.ino + │ ├── board_config.h + │ ├── secrets.h (gitignored) + │ └── DisplayDriverGFX.* + ├── vendor/ Per-board vendored display libs + │ ├── esp32-32e/TFT_eSPI/ + │ └── esp32-s3-lcd-43/GFX_Library_for_Arduino/ + └── mise.toml Build harness + +## Serial Commands + +Type into the serial monitor at 115200 baud: + +| Command | Action | +|---|---| +| alert | Trigger a test alert | +| silence | Silence current alert | +| dashboard | Show dashboard screen | +| off | Turn off display | +| status | Print state + memory info | +| reboot | Restart device | + +## Architecture + +Each board target gets its own sketch directory with a concrete IDisplayDriver +implementation. The shared KlubhausCore library contains all business logic, +networking, and the abstract interface. Arduino CLI's --libraries flag ensures +each board only links its own vendored display library -- no preprocessor conflicts. + + Board sketch (.ino) + | + +-- #include (shared library) + | +-- DoorbellLogic (state machine + ntfy polling) + | +-- NetManager (WiFi, HTTP, NTP) + | +-- DisplayManager (delegates to IDisplayDriver) + | +-- IDisplayDriver (pure virtual interface) + | + +-- DisplayDriverXxx (board-specific, concrete driver) + +-- links against vendored display lib + (TFT_eSPI or Arduino_GFX, never both) diff --git a/ScreenData.h b/ScreenData.h new file mode 100644 index 0000000..16e145e --- /dev/null +++ b/ScreenData.h @@ -0,0 +1,58 @@ +#pragma once +#include + +enum class DeviceState : uint8_t { + SILENT, + ALERTING, + WAKE +}; + +enum class ScreenID : uint8_t { + BOOT_SPLASH, + WIFI_CONNECTING, + WIFI_CONNECTED, + WIFI_FAILED, + ALERT, + STATUS, + DASHBOARD, + OFF +}; + +#define ALERT_HISTORY_SIZE 3 + +struct AlertRecord { + char message[64] = ""; + char timestamp[12] = ""; +}; + +struct ScreenState { + ScreenID screen = ScreenID::BOOT_SPLASH; + DeviceState deviceState = DeviceState::SILENT; + bool blinkPhase = false; + + char alertMessage[64] = ""; + + bool wifiConnected = false; + char wifiSSID[33] = ""; + int wifiRSSI = 0; + char wifiIP[16] = ""; + + bool ntpSynced = false; + char timeString[12] = ""; + + uint32_t uptimeMinutes = 0; + uint32_t freeHeapKB = 0; + bool networkOK = false; + + bool debugMode = false; + + AlertRecord alertHistory[ALERT_HISTORY_SIZE] = {}; + int alertHistoryCount = 0; +}; + +struct TouchEvent { + bool pressed = false; + uint16_t x = 0; + uint16_t y = 0; +}; + diff --git a/TouchDriver.h b/TouchDriver.h new file mode 100644 index 0000000..0633cdb --- /dev/null +++ b/TouchDriver.h @@ -0,0 +1,21 @@ +#pragma once +#include "BoardConfig.h" + +// ═══════════════════════════════════════════════════════════════════ +// Touch driver abstraction +// +// XPT2046: integrated in TFT_eSPI — DisplayManager calls +// _tft.getTouch() / _tft.setTouch() directly inside +// #if USE_TOUCH_XPT2046 blocks. +// +// GT911: separate I2C controller — namespace below. +// ═══════════════════════════════════════════════════════════════════ + +#if USE_TOUCH_GT911 + +namespace TouchDriver { + void begin(); + bool read(uint16_t &x, uint16_t &y); +} + +#endif diff --git a/TouchDriverGT911.cpp b/TouchDriverGT911.cpp new file mode 100644 index 0000000..fe4630a --- /dev/null +++ b/TouchDriverGT911.cpp @@ -0,0 +1,43 @@ +#include "BoardConfig.h" +#include + +#if USE_TOUCH_GT911 + +#include "TouchDriver.h" +#include + +// ═══════════════════════════════════════════════════════════════════ +// GT911 capacitive touch — Waveshare ESP32-S3 Touch LCD 4.3" +// +// This is a compilable stub. To enable actual touch: +// 1. arduino-cli lib install "TAMC_GT911" +// 2. Uncomment the TAMC_GT911 lines below. +// ═══════════════════════════════════════════════════════════════════ + +// #include +// static TAMC_GT911 ts(TOUCH_SDA, TOUCH_SCL, TOUCH_INT, TOUCH_RST, +// SCREEN_WIDTH, SCREEN_HEIGHT); + +namespace TouchDriver { + +void begin() { + Wire.begin(TOUCH_SDA, TOUCH_SCL); + // ts.begin(); + // ts.setRotation(TOUCH_MAP_ROTATION); + Serial.println("[TOUCH] GT911 stub initialized"); +} + +bool read(uint16_t &x, uint16_t &y) { + // ts.read(); + // if (ts.isTouched) { + // x = ts.points[0].x; + // y = ts.points[0].y; + // return true; + // } + (void)x; (void)y; + return false; +} + +} // namespace TouchDriver + +#endif // USE_TOUCH_GT911 diff --git a/boards/board_e32r35t.h b/boards/board_e32r35t.h new file mode 100644 index 0000000..e8c95c7 --- /dev/null +++ b/boards/board_e32r35t.h @@ -0,0 +1,50 @@ +#pragma once +// ═══════════════════════════════════════════════════════════════════ +// Board: E32R35T — ESP32-WROOM-32E + 3.5" ST7796S SPI + XPT2046 +// ═══════════════════════════════════════════════════════════════════ + +#define BOARD_NAME "E32R35T" + +// ── Display ───────────────────────────────────────────────────── +#define SCREEN_WIDTH 480 +#define SCREEN_HEIGHT 320 +#define DISPLAY_ROTATION 1 + +// ── Driver selection ──────────────────────────────────────────── +#define USE_TFT_ESPI 1 +#define USE_ARDUINO_GFX 0 +#define USE_TOUCH_XPT2046 1 +#define USE_TOUCH_GT911 0 + +// ── Hardware capabilities ─────────────────────────────────────── +#define HAS_PSRAM 0 + +// ── 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 diff --git a/boards/board_waveshare_s3.h b/boards/board_waveshare_s3.h new file mode 100644 index 0000000..e76998b --- /dev/null +++ b/boards/board_waveshare_s3.h @@ -0,0 +1,73 @@ +#pragma once +// ═══════════════════════════════════════════════════════════════════ +// Board: Waveshare ESP32-S3 Touch LCD 4.3" +// 800x480 RGB parallel + GT911 capacitive touch +// +// NOTE: Pin assignments are typical for this board revision. +// Verify against your specific board's schematic. +// The Arduino board variant 'waveshare_esp32_s3_touch_lcd_43' +// may override some of these via its pins_arduino.h. +// ═══════════════════════════════════════════════════════════════════ + +#define BOARD_NAME "WS_S3_43" + +// ── Display ───────────────────────────────────────────────────── +#define SCREEN_WIDTH 800 +#define SCREEN_HEIGHT 480 +#define DISPLAY_ROTATION 0 // native landscape + +// ── Driver selection ──────────────────────────────────────────── +#define USE_TFT_ESPI 0 +#define USE_ARDUINO_GFX 1 +#define USE_TOUCH_XPT2046 0 +#define USE_TOUCH_GT911 1 + +// ── Hardware capabilities ─────────────────────────────────────── +#define HAS_PSRAM 1 + +// ── Backlight ─────────────────────────────────────────────────── +#define PIN_LCD_BL 2 + +// ── GT911 I2C touch controller ────────────────────────────────── +#define TOUCH_SDA 17 +#define TOUCH_SCL 18 +#define TOUCH_INT -1 +#define TOUCH_RST 38 + +// ── RGB LCD data pins (ESP32-S3 LCD_CAM peripheral) ───────────── +// Adjust if your board revision differs +#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 + +// ── Peripherals not present on this board ─────────────────────── +// These are left undefined intentionally. Code that uses them +// should guard with #ifdef PIN_LED_RED etc. +// Uncomment and set values if your carrier board adds them. +// +// #define PIN_LED_RED -1 +// #define PIN_LED_GREEN -1 +// #define PIN_LED_BLUE -1 +// #define PIN_AUDIO_EN -1 +// #define PIN_AUDIO_DAC -1 +// #define PIN_BAT_ADC -1 +// #define PIN_SD_CS -1 +// #define PIN_TOUCH_CS -1 +// #define PIN_TOUCH_IRQ -1 diff --git a/boards/esp32-32e/DisplayDriverTFT.cpp b/boards/esp32-32e/DisplayDriverTFT.cpp new file mode 100644 index 0000000..93dcd24 --- /dev/null +++ b/boards/esp32-32e/DisplayDriverTFT.cpp @@ -0,0 +1,155 @@ +#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); +} diff --git a/boards/esp32-32e/DisplayDriverTFT.h b/boards/esp32-32e/DisplayDriverTFT.h new file mode 100644 index 0000000..4b45c61 --- /dev/null +++ b/boards/esp32-32e/DisplayDriverTFT.h @@ -0,0 +1,30 @@ +#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; +}; diff --git a/boards/esp32-32e/board_config.h b/boards/esp32-32e/board_config.h new file mode 100644 index 0000000..7700ce0 --- /dev/null +++ b/boards/esp32-32e/board_config.h @@ -0,0 +1,22 @@ +#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 diff --git a/boards/esp32-32e/esp32-32e.ino b/boards/esp32-32e/esp32-32e.ino new file mode 100644 index 0000000..4088a6b --- /dev/null +++ b/boards/esp32-32e/esp32-32e.ino @@ -0,0 +1,54 @@ +// +// 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); + } +} diff --git a/boards/esp32-32e/secrets.h.example b/boards/esp32-32e/secrets.h.example new file mode 100644 index 0000000..82f6704 --- /dev/null +++ b/boards/esp32-32e/secrets.h.example @@ -0,0 +1,11 @@ +#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]); diff --git a/boards/esp32-32e/tft_user_setup.h b/boards/esp32-32e/tft_user_setup.h new file mode 100644 index 0000000..e299e0a --- /dev/null +++ b/boards/esp32-32e/tft_user_setup.h @@ -0,0 +1,47 @@ +// ═══════════════════════════════════════════════════════════ +// 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 diff --git a/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp new file mode 100644 index 0000000..70e8b7e --- /dev/null +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp @@ -0,0 +1,325 @@ +#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); +} diff --git a/boards/esp32-s3-lcd-43/DisplayDriverGFX.h b/boards/esp32-s3-lcd-43/DisplayDriverGFX.h new file mode 100644 index 0000000..5d21ccf --- /dev/null +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.h @@ -0,0 +1,46 @@ +#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; +}; diff --git a/boards/esp32-s3-lcd-43/board_config.h b/boards/esp32-s3-lcd-43/board_config.h new file mode 100644 index 0000000..24c573a --- /dev/null +++ b/boards/esp32-s3-lcd-43/board_config.h @@ -0,0 +1,52 @@ +#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 diff --git a/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino b/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino new file mode 100644 index 0000000..7300dcb --- /dev/null +++ b/boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino @@ -0,0 +1,54 @@ +// +// 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); + } +} diff --git a/boards/esp32-s3-lcd-43/secrets.h.example b/boards/esp32-s3-lcd-43/secrets.h.example new file mode 100644 index 0000000..82f6704 --- /dev/null +++ b/boards/esp32-s3-lcd-43/secrets.h.example @@ -0,0 +1,11 @@ +#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]); diff --git a/doorbell-touch.ino b/doorbell-touch.ino index a0a8c07..6b107f9 100644 --- a/doorbell-touch.ino +++ b/doorbell-touch.ino @@ -1,826 +1,137 @@ /* - * KLUBHAUS ALERT v4.2 — Touch Edition + * KLUBHAUS ALERT v5.1 — " BOARD_NAME " Edition * - * Target: Waveshare ESP32-S3-Touch-LCD-4.3 (non-B) + * Target: LCDWiki E32R35T (ESP32-WROOM-32E + 3.5" ST7796S + XPT2046) * - * v4.2 fixes: - * - OPI PSRAM mode (double bandwidth — fixes WiFi+RGB coexistence) - * - Bounce buffer for RGB DMA - * - Fixed variadic lambda crash in drawStatusScreen - * - Single tap for all interactions - * - Deferred status publishing (no nested HTTPS) - * - Per-topic message dedup - * - since=10s (no stale message replay) - * - DEBUG_MODE with _test topic suffix + * Hold-and-release interaction model: + * - Hold finger → progress bar fills + * - Bar full → jitter/flash ("RELEASE!") + * - Lift finger → action fires (finger already off screen) */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include "Config.h" +#include "DisplayManager.h" +#include "DoorbellLogic.h" +#include "BoardConfig.h" -// ===================================================================== -// DEBUG MODE — set to 1 to use _test topic suffix -// ===================================================================== -#define DEBUG_MODE 1 - -// ===================================================================== -// WiFi -// ===================================================================== -struct WiFiCred { const char *ssid; const char *pass; }; -WiFiCred wifiNetworks[] = { - { "Dobro Veče", "goodnight" }, - { "berylpunk", "dhgwilliam" }, - // { "iot-2GHz", "lesson-greater" }, // blocks outbound TCP -}; -const int NUM_WIFI = sizeof(wifiNetworks) / sizeof(wifiNetworks[0]); -WiFiMulti wifiMulti; - -// ===================================================================== -// ntfy.sh Topics — since=10s covers our 5s poll interval -// ===================================================================== -#define NTFY_BASE "https://ntfy.sh" - -#if DEBUG_MODE - #define TOPIC_SUFFIX "_test" -#else - #define TOPIC_SUFFIX "" +#include +#ifndef LOAD_GLCD + #error "LOAD_GLCD is NOT defined — fonts missing!" #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 - -#if DEBUG_MODE - #define BOOT_GRACE_MS 5000 -#else - #define BOOT_GRACE_MS 30000 +#if USE_TFT_ESPI + #ifndef ST7796_DRIVER + #if USE_TFT_ESPI +#error "TFT_eSPI setup mismatch — ST7796_DRIVER expected for E32R35T" #endif - -// ===================================================================== -// Screen & Colors -// ===================================================================== -#define SCREEN_WIDTH 800 -#define SCREEN_HEIGHT 480 - -#define COL_NEON_TEAL 0x07D7 -#define COL_HOT_FUCHSIA 0xF81F -#define COL_WHITE 0xFFDF -#define COL_BLACK 0x0000 -#define COL_MINT 0x67F5 -#define COL_DARK_GRAY 0x2104 -#define COL_GREEN 0x07E0 -#define COL_RED 0xF800 -#define COL_YELLOW 0xFFE0 - -// ===================================================================== -// I2C -// ===================================================================== -#define I2C_SDA 8 -#define I2C_SCL 9 - -// ===================================================================== -// CH422G IO Expander -// ===================================================================== -#define CH422G_SYS 0x24 -#define CH422G_OUT 0x38 -#define CH422G_OE 0x01 -#define IO_TP_RST (1 << 1) -#define IO_LCD_BL (1 << 2) -#define IO_LCD_RST (1 << 3) - -static uint8_t ioState = 0; - -void ch422g_write(uint8_t addr, uint8_t data) { - Wire.beginTransmission(addr); - Wire.write(data); - Wire.endTransmission(); -} - -void setBacklight(bool on) { - if (on) ioState |= IO_LCD_BL; - else ioState &= ~IO_LCD_BL; - ch422g_write(CH422G_OUT, ioState); -} - -// ===================================================================== -// GT911 Touch (direct I2C) -// ===================================================================== -#define GT911_ADDR1 0x14 -#define GT911_ADDR2 0x5D - -static uint8_t gt911Addr = 0; -static bool touchAvailable = false; - -void gt911_writeReg(uint16_t reg, uint8_t val) { - Wire.beginTransmission(gt911Addr); - Wire.write(reg >> 8); - Wire.write(reg & 0xFF); - Wire.write(val); - Wire.endTransmission(); -} - -bool gt911_init() { - for (uint8_t addr : { GT911_ADDR1, GT911_ADDR2 }) { - Wire.beginTransmission(addr); - if (Wire.endTransmission() == 0) { - gt911Addr = addr; - touchAvailable = true; - Serial.printf("[HW] GT911 at 0x%02X\n", addr); - return true; - } - } - Serial.println("[HW] GT911 not found"); - return false; -} - -bool gt911_read(int16_t *x, int16_t *y) { - if (!touchAvailable) return false; - - Wire.beginTransmission(gt911Addr); - Wire.write(0x81); Wire.write(0x4E); - Wire.endTransmission(false); - Wire.requestFrom(gt911Addr, (uint8_t)1); - if (!Wire.available()) return false; - uint8_t status = Wire.read(); - - bool ready = status & 0x80; - uint8_t pts = status & 0x0F; - - gt911_writeReg(0x814E, 0x00); - - if (!ready || pts == 0) return false; - - Wire.beginTransmission(gt911Addr); - Wire.write(0x81); Wire.write(0x50); - Wire.endTransmission(false); - Wire.requestFrom(gt911Addr, (uint8_t)4); - if (Wire.available() < 4) return false; - - uint8_t xl = Wire.read(), xh = Wire.read(); - uint8_t yl = Wire.read(), yh = Wire.read(); - *x = xl | (xh << 8); - *y = yl | (yh << 8); - return true; -} - -// ===================================================================== -// RGB Display — 4.3 non-B with bounce buffer for WiFi coexistence -// ===================================================================== -Arduino_ESP32RGBPanel *rgbpanel = new Arduino_ESP32RGBPanel( - 5, 3, 46, 7, - 1, 2, 42, 41, 40, - 39, 0, 45, 48, 47, 21, - 14, 38, 18, 17, 10, - 0, 8, 4, 8, - 0, 8, 4, 8, - 1, 16000000, // ← back to 16MHz - false, // useBigEndian - 0, // de_idle_high - 0, // pclk_idle_high - 8000 // bounce_buffer_size_px -); - -Arduino_RGB_Display *gfx = new Arduino_RGB_Display( - SCREEN_WIDTH, SCREEN_HEIGHT, rgbpanel, 0, true -); - -// ===================================================================== -// NTP -// ===================================================================== -WiFiUDP ntpUDP; -NTPClient timeClient(ntpUDP, "pool.ntp.org", 0, NTP_SYNC_INTERVAL_MS); - -// ===================================================================== -// State -// ===================================================================== -enum DeviceState { STATE_SILENT, STATE_ALERTING, STATE_WAKE }; -DeviceState currentState = STATE_SILENT; - -String currentMessage = ""; -String lastAlertId = ""; -String lastSilenceId = ""; -String lastAdminId = ""; -time_t lastKnownEpoch = 0; -bool ntpSynced = false; - -unsigned long bootTime = 0; -bool inBootGrace = true; - -unsigned long lastPoll = 0; -unsigned long lastBlinkToggle = 0; -bool blinkState = false; -unsigned long alertStart = 0; -unsigned long wakeStart = 0; - -bool pendingStatus = false; -String pendingStatusState = ""; -String pendingStatusMsg = ""; - -bool networkOK = false; - -// ===================================================================== -// Drawing Helpers -// ===================================================================== -void drawCentered(const char *txt, int y, int sz, uint16_t col) { - gfx->setTextSize(sz); - gfx->setTextColor(col); - int w = strlen(txt) * 6 * sz; - gfx->setCursor(max(0, (SCREEN_WIDTH - w) / 2), y); - gfx->print(txt); -} - -void drawInfoLine(int x, int y, uint16_t col, const char *text) { - gfx->setTextSize(2); - gfx->setTextColor(col); - gfx->setCursor(x, y); - gfx->print(text); -} - -void drawHeaderBar(uint16_t col, const char *label) { - gfx->setTextSize(2); - gfx->setTextColor(col); - gfx->setCursor(10, 10); - gfx->print(label); - - String t = timeClient.getFormattedTime(); - gfx->setCursor(SCREEN_WIDTH - t.length() * 12 - 10, 10); - gfx->print(t); -} - -void drawAlertScreen() { - uint16_t bg = blinkState ? COL_NEON_TEAL : COL_HOT_FUCHSIA; - uint16_t fg = blinkState ? COL_BLACK : COL_WHITE; - - gfx->fillScreen(bg); - drawHeaderBar(fg, "ALERT"); - - int sz = 8; - if (currentMessage.length() > 10) sz = 6; - if (currentMessage.length() > 20) sz = 4; - if (currentMessage.length() > 35) sz = 3; - - if (currentMessage.length() > 15) { - int mid = currentMessage.length() / 2; - int sp = currentMessage.lastIndexOf(' ', mid); - if (sp < 0) sp = mid; - String l1 = currentMessage.substring(0, sp); - String l2 = currentMessage.substring(sp + 1); - int lh = 8 * sz + 10; - int y1 = (SCREEN_HEIGHT - lh * 2) / 2; - drawCentered(l1.c_str(), y1, sz, fg); - drawCentered(l2.c_str(), y1 + lh, sz, fg); - } else { - drawCentered(currentMessage.c_str(), - (SCREEN_HEIGHT - 8 * sz) / 2, sz, fg); - } - - drawCentered("TAP TO SILENCE", SCREEN_HEIGHT - 35, 2, fg); -} - -void drawStatusScreen() { - gfx->fillScreen(COL_BLACK); - drawHeaderBar(COL_MINT, "KLUBHAUS"); - - drawCentered("MONITORING", 140, 5, COL_WHITE); - - char buf[128]; - int y = 240; - int spacing = 32; - int x = 60; - - snprintf(buf, sizeof(buf), "WiFi: %s (%d dBm)", - WiFi.isConnected() ? WiFi.SSID().c_str() : "DOWN", WiFi.RSSI()); - drawInfoLine(x, y, COL_WHITE, buf); - y += spacing; - - snprintf(buf, sizeof(buf), "IP: %s", - WiFi.isConnected() ? WiFi.localIP().toString().c_str() : "---"); - drawInfoLine(x, y, COL_WHITE, buf); - y += spacing; - - snprintf(buf, sizeof(buf), "Up: %lu min Heap: %d KB PSRAM: %d KB", - (millis() - bootTime) / 60000, ESP.getFreeHeap() / 1024, - ESP.getFreePsram() / 1024); - drawInfoLine(x, y, COL_WHITE, buf); - y += spacing; - - snprintf(buf, sizeof(buf), "NTP: %s UTC", - ntpSynced ? timeClient.getFormattedTime().c_str() : "not synced"); - drawInfoLine(x, y, COL_WHITE, buf); - y += spacing; - - snprintf(buf, sizeof(buf), "State: %s Net: %s", - currentState == STATE_SILENT ? "SILENT" : - currentState == STATE_ALERTING ? "ALERTING" : "WAKE", - networkOK ? "OK" : "FAIL"); - uint16_t stCol = currentState == STATE_ALERTING ? COL_RED : - currentState == STATE_SILENT ? COL_GREEN : COL_NEON_TEAL; - drawInfoLine(x, y, stCol, buf); - y += spacing; - - if (currentMessage.length() > 0) { - snprintf(buf, sizeof(buf), "Last: %.40s", currentMessage.c_str()); - drawInfoLine(x, y, COL_DARK_GRAY, buf); - y += spacing; - } - - #if DEBUG_MODE - drawInfoLine(x, y, COL_YELLOW, "DEBUG MODE - _test topics"); #endif +#endif +#define HOLD_TO_SILENCE_MS 1000 - drawCentered("tap to dismiss | auto-sleeps in 5s", - SCREEN_HEIGHT - 25, 1, COL_DARK_GRAY); -} +DoorbellLogic logic; +DisplayManager display; -// ===================================================================== -// Status Publishing — deferred to avoid nested HTTPS -// ===================================================================== -void queueStatus(const char *st, const String &msg) { - pendingStatus = true; - pendingStatusState = st; - pendingStatusMsg = msg; - Serial.printf("[STATUS] Queued: %s — %s\n", st, msg.c_str()); -} - -void flushStatus() { - if (!pendingStatus || !WiFi.isConnected()) return; - pendingStatus = false; - - JsonDocument doc; - doc["state"] = pendingStatusState; - doc["message"] = pendingStatusMsg; - doc["timestamp"] = (long long)timeClient.getEpochTime() * 1000LL; - - String payload; - serializeJson(doc, payload); - - WiFiClientSecure client; - client.setInsecure(); - - HTTPClient statusHttp; - statusHttp.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); - statusHttp.begin(client, STATUS_URL); - statusHttp.addHeader("Content-Type", "application/json"); - int code = statusHttp.POST(payload); - statusHttp.end(); - - Serial.printf("[STATUS] Sent (%d): %s\n", code, pendingStatusState.c_str()); - pendingStatusState = ""; - pendingStatusMsg = ""; -} - -// ===================================================================== -// Message Parsing — per-topic dedup -// ===================================================================== -void parseMessages(String &response, const char *topicName, - void (*handler)(const String&), - String &lastId) -{ - if (inBootGrace) return; - if (!ntpSynced || lastKnownEpoch == 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) { - String msgStr = String(message); - String idStr = msgId ? String(msgId) : ""; - - if (idStr.length() > 0 && idStr == lastId) { - lineStart = lineEnd + 1; - continue; - } - - if (msgTime > 0) { - time_t age = lastKnownEpoch - msgTime; - if (age > (time_t)STALE_MSG_THRESHOLD_S) { - Serial.printf("[%s] Stale (%lds): %.30s\n", - topicName, (long)age, msgStr.c_str()); - lineStart = lineEnd + 1; - continue; - } - } - - Serial.printf("[%s] %.50s\n", topicName, msgStr.c_str()); - if (idStr.length() > 0) lastId = idStr; - handler(msgStr); - } - } - } - lineStart = lineEnd + 1; - } -} - -// ===================================================================== -// Network Diagnostics -// ===================================================================== -void checkNetwork() { - Serial.printf("[NET] WiFi: %s RSSI: %d IP: %s\n", - WiFi.SSID().c_str(), WiFi.RSSI(), WiFi.localIP().toString().c_str()); - Serial.printf("[NET] GW: %s DNS: %s\n", - WiFi.gatewayIP().toString().c_str(), WiFi.dnsIP().toString().c_str()); - Serial.printf("[NET] Heap: %d KB PSRAM free: %d KB\n", - ESP.getFreeHeap() / 1024, ESP.getFreePsram() / 1024); - Serial.flush(); - - IPAddress ip; - if (!WiFi.hostByName("ntfy.sh", ip)) { - Serial.println("[NET] *** DNS FAILED ***"); - networkOK = false; - return; - } - Serial.printf("[NET] ntfy.sh -> %s\n", ip.toString().c_str()); - - WiFiClientSecure tls; - tls.setInsecure(); - Serial.println("[NET] Testing TLS to ntfy.sh:443..."); - Serial.flush(); - if (tls.connect("ntfy.sh", 443, 15000)) { - Serial.println("[NET] TLS OK!"); - tls.stop(); - networkOK = true; - } else { - Serial.println("[NET] *** TLS FAILED ***"); - networkOK = false; - } - Serial.flush(); -} - -// ===================================================================== -// ntfy Polling -// ===================================================================== -void pollTopic(const char *url, - void (*handler)(const String&), - const char *topicName, - 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", topicName); - return; - } - - int code = http.GET(); - if (code == HTTP_CODE_OK) { - String response = http.getString(); - if (response.length() > 0) - parseMessages(response, topicName, handler, lastId); - } else { - Serial.printf("[%s] HTTP %d: %s\n", topicName, code, - code < 0 ? http.errorToString(code).c_str() : ""); - } - http.end(); -} - -// ===================================================================== -// Message Handlers -// ===================================================================== -void handleAlertMessage(const String &message) { - if (currentState == STATE_ALERTING && currentMessage == message) return; - - currentState = STATE_ALERTING; - currentMessage = message; - alertStart = millis(); - blinkState = false; - lastBlinkToggle = millis(); - - setBacklight(true); - drawAlertScreen(); - queueStatus("ALERTING", message); - Serial.printf("-> ALERTING: %s\n", message.c_str()); -} - -void handleSilenceMessage(const String &message) { - currentState = STATE_SILENT; - currentMessage = ""; - setBacklight(false); - queueStatus("SILENT", "silenced"); - Serial.println("-> SILENT"); -} - -void handleAdminMessage(const String &message) { - Serial.printf("[ADMIN] %s\n", message.c_str()); - - if (message == "SILENCE") { - handleSilenceMessage("admin"); - } - else if (message == "PING") { - queueStatus("PONG", "ping"); - } - else if (message == "test") { - handleAlertMessage("TEST ALERT"); - } - else if (message == "status") { - char buf[256]; - snprintf(buf, sizeof(buf), - "State:%s WiFi:%s RSSI:%d Heap:%dKB Up:%lus Net:%s", - currentState == STATE_SILENT ? "SILENT" : - currentState == STATE_ALERTING ? "ALERT" : "WAKE", - WiFi.SSID().c_str(), WiFi.RSSI(), - ESP.getFreeHeap() / 1024, (millis() - bootTime) / 1000, - networkOK ? "OK" : "FAIL"); - queueStatus("STATUS", buf); - } - else if (message == "wake") { - currentState = STATE_WAKE; - wakeStart = millis(); - setBacklight(true); - drawStatusScreen(); - } - else if (message == "REBOOT") { - queueStatus("REBOOTING", "admin"); - flushStatus(); - delay(200); - ESP.restart(); - } -} - -// ===================================================================== -// Touch — trigger on finger DOWN only -// ===================================================================== -void handleTouch() { - int16_t tx, ty; - bool touching = gt911_read(&tx, &ty); - - static bool wasTouching = false; - static unsigned long lastAction = 0; - unsigned long now = millis(); - - if (touching && !wasTouching) { - if (now - lastAction >= TOUCH_DEBOUNCE_MS) { - lastAction = now; - - Serial.printf("[TOUCH] x=%d y=%d state=%d\n", tx, ty, currentState); - - switch (currentState) { - case STATE_ALERTING: - handleSilenceMessage("touch"); - break; - - case STATE_SILENT: - currentState = STATE_WAKE; - wakeStart = now; - setBacklight(true); - drawStatusScreen(); - Serial.println("-> WAKE"); - break; - - case STATE_WAKE: - currentState = STATE_SILENT; - setBacklight(false); - Serial.println("-> SILENT (dismiss)"); - break; - } - } - } - - wasTouching = touching; -} - -// ===================================================================== -// WiFi -// ===================================================================== -void connectWiFi() { - for (int i = 0; i < NUM_WIFI; i++) - wifiMulti.addAP(wifiNetworks[i].ssid, wifiNetworks[i].pass); - - setBacklight(true); - gfx->fillScreen(COL_BLACK); - drawCentered("Connecting to WiFi...", 220, 2, COL_NEON_TEAL); - - int tries = 0; - while (wifiMulti.run() != WL_CONNECTED && tries++ < 40) delay(500); - - if (WiFi.isConnected()) { - Serial.printf("[WIFI] %s %s\n", - WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); - gfx->fillScreen(COL_BLACK); - drawCentered("Connected!", 180, 3, COL_GREEN); - char buf[64]; - snprintf(buf, sizeof(buf), "%s %s", - WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); - drawCentered(buf, 240, 2, COL_WHITE); - delay(1500); - } else { - Serial.println("[WIFI] FAILED"); - gfx->fillScreen(COL_BLACK); - drawCentered("WiFi FAILED", 220, 3, COL_RED); - delay(2000); - } -} - -// ===================================================================== -// Hardware Init -// ===================================================================== -void initHardware() { - Wire.begin(I2C_SDA, I2C_SCL); - - ch422g_write(CH422G_SYS, CH422G_OE); - delay(10); - ioState = 0; - ch422g_write(CH422G_OUT, ioState); - delay(100); - - ioState = IO_TP_RST | IO_LCD_RST; - ch422g_write(CH422G_OUT, ioState); - delay(200); - - if (!gfx->begin()) { - Serial.println("[HW] Display FAILED"); - while (true) delay(1000); - } - gfx->fillScreen(COL_BLACK); - - gt911_init(); -} - -// ===================================================================== -// Setup -// ===================================================================== void setup() { Serial.begin(115200); unsigned long t = millis(); - while (!Serial && millis() - t < 5000) delay(10); + while (!Serial && millis() - t < 3000) delay(10); delay(500); - bootTime = millis(); - Serial.println("\n========================================"); - Serial.println(" KLUBHAUS ALERT v4.2 — Touch Edition"); - #if DEBUG_MODE - Serial.println(" *** DEBUG MODE — _test topics ***"); - #endif - Serial.printf(" Grace period: %d ms\n", BOOT_GRACE_MS); + Serial.println(" KLUBHAUS ALERT v5.1 — " BOARD_NAME ""); +#if DEBUG_MODE + Serial.println(" *** DEBUG MODE — _test topics ***"); +#endif Serial.println("========================================"); - Serial.printf("Heap: %d KB PSRAM: %d KB\n", - ESP.getFreeHeap() / 1024, ESP.getPsramSize() / 1024); - if (ESP.getPsramSize() == 0) { - Serial.println("PSRAM required! Check FQBN has PSRAM=opi"); - while (true) delay(1000); - } + display.begin(); - initHardware(); - - setBacklight(true); - gfx->fillScreen(COL_BLACK); - drawCentered("KLUBHAUS", 120, 6, COL_NEON_TEAL); - drawCentered("ALERT", 200, 6, COL_HOT_FUCHSIA); - drawCentered("v4.2 Touch", 300, 2, COL_DARK_GRAY); - #if DEBUG_MODE - drawCentered("DEBUG MODE", 340, 2, COL_YELLOW); - #endif + logic.begin(); + display.render(logic.getScreenState()); delay(1500); - connectWiFi(); + logic.beginWiFi(); + display.render(logic.getScreenState()); - if (WiFi.isConnected()) { - checkNetwork(); - } + logic.connectWiFiBlocking(); + display.render(logic.getScreenState()); + delay(1500); - timeClient.begin(); - if (timeClient.update()) { - ntpSynced = true; - lastKnownEpoch = timeClient.getEpochTime(); - Serial.printf("[NTP] Synced: %ld\n", lastKnownEpoch); - } + logic.finishBoot(); + display.setBacklight(false); - queueStatus("BOOTED", "v4.2 Touch Edition"); - - currentState = STATE_SILENT; - setBacklight(false); + Serial.printf("[MEM] Free heap: %d | Free PSRAM: %d\n", + ESP.getFreeHeap(), ESP.getFreePsram()); Serial.println("[BOOT] Ready — monitoring ntfy.sh\n"); } -// ===================================================================== -// Loop -// ===================================================================== -void loop() { - unsigned long now = millis(); - if (Serial.available()) { - String cmd = Serial.readStringUntil('\n'); - cmd.trim(); - if (cmd == "CLEAR_DEDUP") { - lastAlertId = ""; - lastSilenceId = ""; - lastAdminId = ""; - Serial.println("[CMD] Dedup cleared (all topics)"); - } - else if (cmd == "NET") { - checkNetwork(); - } - } - if (timeClient.update()) { - ntpSynced = true; - lastKnownEpoch = timeClient.getEpochTime(); - } - - if (inBootGrace && (now - bootTime >= BOOT_GRACE_MS)) { - inBootGrace = false; - Serial.println("[BOOT] Grace period ended — now monitoring"); - } - - handleTouch(); - - 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) { - pollTopic(ALERT_URL, handleAlertMessage, "ALERT", lastAlertId); - pollTopic(SILENCE_URL, handleSilenceMessage, "SILENCE", lastSilenceId); - pollTopic(ADMIN_URL, handleAdminMessage, "ADMIN", lastAdminId); - } - } - - flushStatus(); - - switch (currentState) { - case STATE_ALERTING: - if (now - alertStart > ALERT_TIMEOUT_MS) { - handleSilenceMessage("timeout"); - break; - } - if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) { - lastBlinkToggle = now; - blinkState = !blinkState; - drawAlertScreen(); - } - break; - - case STATE_WAKE: - if (now - wakeStart > WAKE_DISPLAY_MS) { - currentState = STATE_SILENT; - setBacklight(false); - } - break; - - case STATE_SILENT: - break; - } - - static unsigned long lastHB = 0; - if (now - lastHB >= 30000) { - lastHB = now; - Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB | net:%s\n", - now / 1000, - currentState == STATE_SILENT ? "SILENT" : - currentState == STATE_ALERTING ? "ALERT" : "WAKE", - WiFi.isConnected() ? "OK" : "DOWN", - ESP.getFreeHeap() / 1024, - networkOK ? "OK" : "FAIL"); - } - - delay(20); +// ── Silence handler (delegates to DoorbellLogic) ──────────────── +void silenceAlerts() { + Serial.println("[SILENCE] User completed hold-to-silence gesture"); + logic.onTouch(TouchEvent{true, 0, 0}); +} + +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/libraries/KlubhausCore/library.properties b/libraries/KlubhausCore/library.properties new file mode 100644 index 0000000..8880f7a --- /dev/null +++ b/libraries/KlubhausCore/library.properties @@ -0,0 +1,11 @@ +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 diff --git a/libraries/KlubhausCore/src/Config.h b/libraries/KlubhausCore/src/Config.h new file mode 100644 index 0000000..acc636e --- /dev/null +++ b/libraries/KlubhausCore/src/Config.h @@ -0,0 +1,26 @@ +#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; +}; diff --git a/libraries/KlubhausCore/src/DisplayManager.h b/libraries/KlubhausCore/src/DisplayManager.h new file mode 100644 index 0000000..f9da315 --- /dev/null +++ b/libraries/KlubhausCore/src/DisplayManager.h @@ -0,0 +1,24 @@ +#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; +}; diff --git a/libraries/KlubhausCore/src/DoorbellLogic.cpp b/libraries/KlubhausCore/src/DoorbellLogic.cpp new file mode 100644 index 0000000..ba66da2 --- /dev/null +++ b/libraries/KlubhausCore/src/DoorbellLogic.cpp @@ -0,0 +1,260 @@ +#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")); +} diff --git a/libraries/KlubhausCore/src/DoorbellLogic.h b/libraries/KlubhausCore/src/DoorbellLogic.h new file mode 100644 index 0000000..f9714fc --- /dev/null +++ b/libraries/KlubhausCore/src/DoorbellLogic.h @@ -0,0 +1,55 @@ +#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; +}; diff --git a/libraries/KlubhausCore/src/IDisplayDriver.h b/libraries/KlubhausCore/src/IDisplayDriver.h new file mode 100644 index 0000000..dfb1add --- /dev/null +++ b/libraries/KlubhausCore/src/IDisplayDriver.h @@ -0,0 +1,38 @@ +#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; +}; diff --git a/libraries/KlubhausCore/src/KlubhausCore.h b/libraries/KlubhausCore/src/KlubhausCore.h new file mode 100644 index 0000000..4b765ab --- /dev/null +++ b/libraries/KlubhausCore/src/KlubhausCore.h @@ -0,0 +1,9 @@ +#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" diff --git a/libraries/KlubhausCore/src/NetManager.cpp b/libraries/KlubhausCore/src/NetManager.cpp new file mode 100644 index 0000000..43cf916 --- /dev/null +++ b/libraries/KlubhausCore/src/NetManager.cpp @@ -0,0 +1,102 @@ +#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; +} diff --git a/libraries/KlubhausCore/src/NetManager.h b/libraries/KlubhausCore/src/NetManager.h new file mode 100644 index 0000000..570f379 --- /dev/null +++ b/libraries/KlubhausCore/src/NetManager.h @@ -0,0 +1,37 @@ +#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; +}; diff --git a/libraries/KlubhausCore/src/ScreenState.h b/libraries/KlubhausCore/src/ScreenState.h new file mode 100644 index 0000000..0f02126 --- /dev/null +++ b/libraries/KlubhausCore/src/ScreenState.h @@ -0,0 +1,54 @@ +#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 "?"; +} diff --git a/mise.toml b/mise.toml index d366acf..6d1254e 100644 --- a/mise.toml +++ b/mise.toml @@ -1,31 +1,115 @@ -[tools] -arduino-cli = "latest" -lazygit = "latest" +# ═══════════════════════════════════════════════════════════ +# Klubhaus Doorbell — Multi-Target Build Harness +# ═══════════════════════════════════════════════════════════ -[env] -FQBN = "esp32:esp32:waveshare_esp32_s3_touch_lcd_43:UploadSpeed=921600,USBMode=hwcdc,CDCOnBoot=cdc,CPUFreq=240,FlashMode=qio,FlashSize=16M,PartitionScheme=app3M_fat9M_16MB,DebugLevel=info,PSRAM=enabled,LoopCore=1,EventsCore=1,EraseFlash=none" - -[tasks.install-core] -run = "arduino-cli core update-index && arduino-cli core install esp32:esp32" - -[tasks.install-libs] +[tasks.install-libs-shared] +description = "Install shared (platform-independent) libraries" run = """ -arduino-cli lib install "GFX Library for Arduino" -arduino-cli lib install "ArduinoJson" -arduino-cli lib install "NTPClient" +arduino-cli lib install "ArduinoJson@7.4.1" +arduino-cli lib install "NTPClient@3.2.1" +echo "[OK] Shared libraries installed" """ -[tasks.compile] -run = "arduino-cli compile --fqbn $FQBN ." +[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 V2.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.upload] -depends = ["compile"] -run = "arduino-cli upload --fqbn $FQBN -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') ." +[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.monitor] -depends = ["upload"] -run = "arduino-cli monitor -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') -c baudrate=115200" +[tasks.install-libs] +description = "Install all libraries (shared + vendored)" +depends = ["install-libs-shared", "install-libs-32e", "install-libs-s3-43"] -[tasks.all] -depends = ["monitor"] +# ── 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=enabled,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" +""" diff --git a/scaffold.sh b/scaffold.sh new file mode 100644 index 0000000..fb987f1 --- /dev/null +++ b/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