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