consolidate sketches

This commit is contained in:
2026-02-16 17:53:06 -08:00
parent 75c3f5706b
commit 838afaa36f
42 changed files with 5655 additions and 817 deletions

5
.arduino_config.lua Normal file
View File

@@ -0,0 +1,5 @@
local M = {}
M.board = 'arduino:avr:uno'
M.port = '/dev/ttyUSB0'
M.baudrate =115200
return M

16
.gitignore vendored Normal file
View File

@@ -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

18
BoardConfig.h Normal file
View File

@@ -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

59
Config.h Normal file
View File

@@ -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.
// =====================================================================

196
Dashboard.cpp Normal file
View File

@@ -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);
}

56
Dashboard.h Normal file
View File

@@ -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);
};

117
DisplayDriver.h Normal file
View File

@@ -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 <TFT_eSPI.h>
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 <Arduino_GFX_Library.h>
// 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

194
DisplayDriverGFX.cpp Normal file
View File

@@ -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 <Arduino.h>
#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

481
DisplayManager.cpp Normal file
View File

@@ -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);
}

99
DisplayManager.h Normal file
View File

@@ -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);
};

576
DoorbellLogic.cpp Normal file
View File

@@ -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;
}
}

95
DoorbellLogic.h Normal file
View File

@@ -0,0 +1,95 @@
#pragma once
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#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);
};

91
README.md Normal file
View File

@@ -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 <KlubhausCore.h> (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)

58
ScreenData.h Normal file
View File

@@ -0,0 +1,58 @@
#pragma once
#include <Arduino.h>
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;
};

21
TouchDriver.h Normal file
View File

@@ -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

43
TouchDriverGT911.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "BoardConfig.h"
#include <Arduino.h>
#if USE_TOUCH_GT911
#include "TouchDriver.h"
#include <Wire.h>
// ═══════════════════════════════════════════════════════════════════
// 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 <TAMC_GT911.h>
// 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

50
boards/board_e32r35t.h Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <KlubhausCore.h>
#include <TFT_eSPI.h>
#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;
};

View File

@@ -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

View File

@@ -0,0 +1,54 @@
//
// Klubhaus Doorbell — ESP32-32E target
//
#include <KlubhausCore.h>
#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);
}
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include <KlubhausCore.h>
// 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]);

View File

@@ -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

View File

@@ -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);
}

View File

@@ -0,0 +1,46 @@
#pragma once
#include <KlubhausCore.h>
#include <Arduino_GFX_Library.h>
#include <Wire.h>
#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;
};

View File

@@ -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

View File

@@ -0,0 +1,54 @@
//
// Klubhaus Doorbell — ESP32-S3-Touch-LCD-4.3 target
//
#include <KlubhausCore.h>
#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);
}
}

View File

@@ -0,0 +1,11 @@
#pragma once
#include <KlubhausCore.h>
// 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]);

View File

@@ -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 <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Arduino_GFX_Library.h>
#include <SPI.h>
#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 <TFT_eSPI.h>
#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(" KLUBHAUS ALERT v5.1 — " BOARD_NAME "");
#if DEBUG_MODE
Serial.println(" *** DEBUG MODE — _test topics ***");
#endif
Serial.printf(" Grace period: %d ms\n", BOOT_GRACE_MS);
#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();
// ── 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 == "CLEAR_DEDUP") {
lastAlertId = "";
lastSilenceId = "";
lastAdminId = "";
Serial.println("[CMD] Dedup cleared (all topics)");
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);
}
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);
}

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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"));
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#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;
};

View File

@@ -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;
};

View File

@@ -0,0 +1,9 @@
#pragma once
// Umbrella header — board sketches just #include <KlubhausCore.h>
#include "Config.h"
#include "ScreenState.h"
#include "IDisplayDriver.h"
#include "DisplayManager.h"
#include "NetManager.h"
#include "DoorbellLogic.h"

View File

@@ -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;
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiMulti.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#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;
};

View File

@@ -0,0 +1,54 @@
#pragma once
#include <Arduino.h>
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 "?";
}

130
mise.toml
View File

@@ -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"
"""

1772
scaffold.sh Normal file

File diff suppressed because it is too large Load Diff