From 89f3248747e4a6ef130a5b258630364216563df5 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Feb 2026 20:51:56 -0800 Subject: [PATCH] refactor(doorbell): migrate ESP32-S3 to ESP_IOExpander library --- DisplayManager.cpp | 6 +- boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp | 456 ++--- boards/esp32-s3-lcd-43/DisplayDriverGFX.h | 41 +- boards/esp32-s3-lcd-43/board_config.h | 75 +- mise.toml | 1 + scaffold.sh | 1772 ------------------- 6 files changed, 225 insertions(+), 2126 deletions(-) delete mode 100644 scaffold.sh diff --git a/DisplayManager.cpp b/DisplayManager.cpp index 6bffdfb..ecd1f65 100644 --- a/DisplayManager.cpp +++ b/DisplayManager.cpp @@ -22,8 +22,10 @@ void DisplayManager::begin() { #endif } -void DisplayManager::setBacklight(bool on) { - digitalWrite(PIN_LCD_BL, on ? HIGH : LOW); +void DisplayDriverGFX::setBacklight(bool on) { + // Cannot control after gfx->begin() — GPIO 8/9 are LCD data. + // Backlight is permanently ON, set during ch422gInit(). + (void)on; } TouchEvent DisplayManager::readTouch() { diff --git a/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp index dbec78e..aabce8a 100644 --- a/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp @@ -1,157 +1,176 @@ #include "DisplayDriverGFX.h" +#include "board_config.h" +#include +#include -// Arduino_GFX uses RGB565 values directly; define convenience names #ifndef BLACK #define BLACK 0x0000 #endif #ifndef WHITE #define WHITE 0xFFFF #endif - #ifndef RED #define RED 0xF800 #endif -#ifndef GREEN -#define GREEN 0x07E0 -#endif -#ifndef BLUE -#define BLUE 0x001F -#endif -#ifndef YELLOW -#define YELLOW 0xFFE0 -#endif -#ifndef CYAN -#define CYAN 0x07FF -#endif -#ifndef MAGENTA -#define MAGENTA 0xF81F -#endif -#ifndef ORANGE -#define ORANGE 0xFD20 -#endif +// CH422G logical pin numbers +#define EXIO_TP_RST IO_EXPANDER_PIN_NUM_1 +#define EXIO_LCD_BL IO_EXPANDER_PIN_NUM_2 +#define EXIO_LCD_RST IO_EXPANDER_PIN_NUM_3 +#define EXIO_SD_CS IO_EXPANDER_PIN_NUM_4 +#define EXIO_USB_SEL IO_EXPANDER_PIN_NUM_5 -// ═══════════════════════════════════════════════════════════ -// CH422G IO Expander -// ═══════════════════════════════════════════════════════════ +static ESP_IOExpander* expander = nullptr; -void DisplayDriverGFX::ch422gInit() { - Wire.begin(I2C_SDA, I2C_SCL, I2C_FREQ); +// ── IO Expander ── - // 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); +void DisplayDriverGFX::expanderInit() { + Serial.println("[IO] IO expander init..."); + expander = new ESP_IOExpander_CH422G( + I2C_MASTER_NUM, + ESP_IO_EXPANDER_I2C_CH422G_ADDRESS + ); + expander->init(); + expander->begin(); + expander->multiPinMode( + EXIO_TP_RST | EXIO_LCD_BL | EXIO_LCD_RST | EXIO_SD_CS | EXIO_USB_SEL, + OUTPUT + ); + // Deassert resets, backlight OFF for now + expander->multiDigitalWrite( + EXIO_TP_RST | EXIO_LCD_RST | EXIO_SD_CS, + 0xFF + ); + expander->digitalWrite(EXIO_LCD_BL, LOW); 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; +void DisplayDriverGFX::setBacklight(bool on) { + if (expander) { + expander->digitalWrite(EXIO_LCD_BL, on ? HIGH : LOW); + Serial.printf("[GFX] Backlight %s\n", on ? "ON" : "OFF"); } +} - // Read first touch point (0x8150..0x8157) +// ── Touch ── + +void DisplayDriverGFX::touchInit() { + Wire.begin(I2C_MASTER_SDA, I2C_MASTER_SCL); 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; + uint8_t err = Wire.endTransmission(); + if (err == 0) { + Serial.println("[TOUCH] GT911 initialized"); + } else { + Serial.printf("[TOUCH] GT911 not found (I2C err %d)\n", err); + } +} + +TouchEvent DisplayDriverGFX::readTouch() { + TouchEvent ev = { false, 0, 0 }; + Wire.beginTransmission(GT911_ADDR); + if (Wire.endTransmission() != 0) return ev; + + // Read touch status register (0x814E) + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); + Wire.write(0x4E); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)GT911_ADDR, (uint8_t)1); + if (!Wire.available()) return ev; + + uint8_t status = Wire.read(); + uint8_t touches = status & 0x0F; + + if ((status & 0x80) && touches > 0 && touches <= 5) { + // Read first touch point (0x8150) + Wire.beginTransmission(GT911_ADDR); + Wire.write(0x81); + Wire.write(0x50); + Wire.endTransmission(false); + 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(); + ev.pressed = true; + ev.x = (xh << 8) | xl; + ev.y = (yh << 8) | yl; + } } // Clear status Wire.beginTransmission(GT911_ADDR); - Wire.write(0x81); Wire.write(0x4E); Wire.write(0x00); + Wire.write(0x81); + Wire.write(0x4E); + Wire.write(0x00); Wire.endTransmission(); - return true; + return ev; } -// ═══════════════════════════════════════════════════════════ -// Display Init -// ═══════════════════════════════════════════════════════════ +int DisplayDriverGFX::dashboardTouch(int x, int y) { + // Simple 2x2 grid: 4 tiles + int col = (x < DISPLAY_WIDTH / 2) ? 0 : 1; + int row = (y < DISPLAY_HEIGHT / 2) ? 0 : 1; + return row * 2 + col; +} + +HoldState DisplayDriverGFX::updateHold(unsigned long holdMs) { + TouchEvent ev = readTouch(); + if (ev.pressed) { + if (!_lastTouched) { + _holdStart = millis(); + _lastTouched = true; + } + unsigned long elapsed = millis() - _holdStart; + if (elapsed >= holdMs) { + return {false, true}; + } + return {true, false}; + } else { + _lastTouched = false; + _holdStart = 0; + return {false, false}; + } +} + +void DisplayDriverGFX::updateHint() { + // placeholder for idle hint animation +} + +// ── Display ── void DisplayDriverGFX::begin() { - // 1. IO expander first (controls resets + backlight) - ch422gInit(); + // 1. Touch (I2C on GPIO 8/9) + touchInit(); + delay(200); - // 2. Create RGB bus with corrected Waveshare timing + // 2. IO expander + expanderInit(); + + // 3. RGB display + Serial.println("[GFX] GFX init..."); 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 + 0, // hsync_polarity + 40, // hsync_front_porch + 48, // hsync_pulse_width + 88, // hsync_back_porch + 0, // vsync_polarity + 13, // vsync_front_porch + 3, // vsync_pulse_width + 32, // vsync_back_porch + 1, // pclk_active_neg + 16000000 // prefer_speed ); - // 3. Create display _gfx = new Arduino_RGB_Display( DISPLAY_WIDTH, DISPLAY_HEIGHT, rgbPanel, - DISPLAY_ROTATION, - true // auto_flush + DISPLAY_ROTATION, true ); if (!_gfx->begin()) { @@ -160,197 +179,74 @@ void DisplayDriverGFX::begin() { } 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) + // 4. Clear and backlight on _gfx->fillScreen(BLACK); + setBacklight(true); drawBoot(); - - // 6. Backlight ON - exioSet(EXIO_LCD_BL, true); - Serial.println("[GFX] Backlight ON"); } -void DisplayDriverGFX::setBacklight(bool on) { - exioSet(EXIO_LCD_BL, on); -} +int DisplayDriverGFX::width() { return _gfx ? _gfx->width() : DISPLAY_WIDTH; } +int DisplayDriverGFX::height() { return _gfx ? _gfx->height() : DISPLAY_HEIGHT; } -// ═══════════════════════════════════════════════════════════ -// Rendering -// ═══════════════════════════════════════════════════════════ +// ── Render (state machine driven) ── -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::render(const ScreenState& state) { + if (!_gfx) return; + switch (state.screen) { + case ScreenID::BOOT_SPLASH: drawBoot(); break; + case ScreenID::DASHBOARD: drawDashboard(state); break; + case ScreenID::ALERT: drawAlert(state); break; + case ScreenID::OFF: drawOff(); break; + default: drawBoot(); break; } } void DisplayDriverGFX::drawBoot() { + if (!_gfx) return; + _gfx->fillScreen(BLACK); + _gfx->setTextColor(WHITE); + _gfx->setTextSize(4); + _gfx->setCursor(250, 200); + _gfx->println("Klubhaus Alert"); + _gfx->setTextSize(2); + _gfx->setCursor(300, 260); + _gfx->println("Booting..."); +} + +void DisplayDriverGFX::drawDashboard(const ScreenState& state) { + if (!_gfx) return; _gfx->fillScreen(BLACK); _gfx->setTextColor(WHITE); _gfx->setTextSize(3); - _gfx->setCursor(40, 40); - _gfx->printf("KLUBHAUS ALERT v%s", FW_VERSION); + _gfx->setCursor(20, 20); + _gfx->println("KLUBHAUS DASHBOARD"); _gfx->setTextSize(2); - _gfx->setCursor(40, 100); - _gfx->print(BOARD_NAME); - _gfx->setCursor(40, 140); - _gfx->print("Booting..."); + _gfx->setCursor(20, 80); + _gfx->printf("IP: %s", state.wifiIP); + _gfx->setCursor(20, 110); + _gfx->printf("RSSI: %d dBm", state.wifiRSSI); + _gfx->setCursor(20, 140); + _gfx->printf("Time: %s", state.timeString); } -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); +void DisplayDriverGFX::drawAlert(const ScreenState& state) { + if (!_gfx) return; + _gfx->fillScreen(RED); _gfx->setTextColor(WHITE); - - // Title _gfx->setTextSize(5); - _gfx->setCursor(40, 80); - _gfx->print(st.alertTitle.length() > 0 ? st.alertTitle : "ALERT"); - - // Body + _gfx->setCursor(200, 180); + _gfx->println("DOORBELL!"); _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..."); + _gfx->setCursor(100, 280); + _gfx->println(state.alertMessage ? state.alertMessage : "Someone is at the door"); } -void DisplayDriverGFX::drawDashboard(const ScreenState& st) { +void DisplayDriverGFX::drawOff() { + if (!_gfx) return; _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); + setBacklight(false); } -// ═══════════════════════════════════════════════════════════ -// 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 index 5d21ccf..a4a3614 100644 --- a/boards/esp32-s3-lcd-43/DisplayDriverGFX.h +++ b/boards/esp32-s3-lcd-43/DisplayDriverGFX.h @@ -1,9 +1,7 @@ #pragma once -#include +#include #include -#include -#include "board_config.h" class DisplayDriverGFX : public IDisplayDriver { public: @@ -14,33 +12,18 @@ public: 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; } + int width() override; + int height() override; 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 expanderInit(); + void touchInit(); 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; + void drawDashboard(const ScreenState& state); + void drawAlert(const ScreenState& state); + void drawOff(); + Arduino_RGB_Display* _gfx = nullptr; + bool _lastTouched = false; + unsigned long _holdStart = 0; }; + diff --git a/boards/esp32-s3-lcd-43/board_config.h b/boards/esp32-s3-lcd-43/board_config.h index 24c573a..a1ad825 100644 --- a/boards/esp32-s3-lcd-43/board_config.h +++ b/boards/esp32-s3-lcd-43/board_config.h @@ -1,52 +1,41 @@ #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 +#define BOARD_NAME "WS_S3_43" +#define DISPLAY_WIDTH 800 +#define DISPLAY_HEIGHT 480 +#define DISPLAY_ROTATION 0 -// ── RGB parallel bus pins (directly to ST7262 panel) ── -#define LCD_DE 40 -#define LCD_VSYNC 41 -#define LCD_HSYNC 39 -#define LCD_PCLK 42 +// ── RGB parallel bus (from Westcott1 reference) ── +#define LCD_DE 5 +#define LCD_VSYNC 3 +#define LCD_HSYNC 46 +#define LCD_PCLK 7 -#define LCD_R0 45 -#define LCD_R1 48 -#define LCD_R2 47 -#define LCD_R3 21 -#define LCD_R4 14 +#define LCD_R0 1 +#define LCD_R1 2 +#define LCD_R2 42 +#define LCD_R3 41 +#define LCD_R4 40 -#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_G0 39 +#define LCD_G1 0 +#define LCD_G2 45 +#define LCD_G3 48 +#define LCD_G4 47 +#define LCD_G5 21 -#define LCD_B0 8 -#define LCD_B1 3 -#define LCD_B2 46 -#define LCD_B3 9 -#define LCD_B4 1 +#define LCD_B0 14 +#define LCD_B1 38 +#define LCD_B2 18 +#define LCD_B3 17 +#define LCD_B4 10 -// ── 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 +// ── I2C bus (shared: CH422G + GT911) ── +#define I2C_MASTER_NUM 0 +#define I2C_MASTER_SDA 8 +#define I2C_MASTER_SCL 9 // ── GT911 Touch ── -#define GT911_ADDR 0x5D -#define TOUCH_INT -1 // not wired to a readable GPIO on this board +#define GT911_ADDR 0x5D +#define TOUCH_INT -1 + diff --git a/mise.toml b/mise.toml index baae35a..d3112d4 100644 --- a/mise.toml +++ b/mise.toml @@ -7,6 +7,7 @@ description = "Install shared (platform-independent) libraries" run = """ arduino-cli lib install "ArduinoJson@7.4.1" arduino-cli lib install "NTPClient@3.2.1" +arduino-cli lib install "ESP32_IO_Expander@0.0.4" echo "[OK] Shared libraries installed" """ diff --git a/scaffold.sh b/scaffold.sh deleted file mode 100644 index fb987f1..0000000 --- a/scaffold.sh +++ /dev/null @@ -1,1772 +0,0 @@ -#!/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