consolidate sketches
This commit is contained in:
5
.arduino_config.lua
Normal file
5
.arduino_config.lua
Normal 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
16
.gitignore
vendored
Normal 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
18
BoardConfig.h
Normal 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
59
Config.h
Normal 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
196
Dashboard.cpp
Normal 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
56
Dashboard.h
Normal 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
117
DisplayDriver.h
Normal 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
194
DisplayDriverGFX.cpp
Normal 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
481
DisplayManager.cpp
Normal 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
99
DisplayManager.h
Normal 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
576
DoorbellLogic.cpp
Normal 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
95
DoorbellLogic.h
Normal 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
91
README.md
Normal 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
58
ScreenData.h
Normal 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
21
TouchDriver.h
Normal 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
43
TouchDriverGT911.cpp
Normal 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
50
boards/board_e32r35t.h
Normal 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
|
||||||
73
boards/board_waveshare_s3.h
Normal file
73
boards/board_waveshare_s3.h
Normal 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
|
||||||
155
boards/esp32-32e/DisplayDriverTFT.cpp
Normal file
155
boards/esp32-32e/DisplayDriverTFT.cpp
Normal 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);
|
||||||
|
}
|
||||||
30
boards/esp32-32e/DisplayDriverTFT.h
Normal file
30
boards/esp32-32e/DisplayDriverTFT.h
Normal 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;
|
||||||
|
};
|
||||||
22
boards/esp32-32e/board_config.h
Normal file
22
boards/esp32-32e/board_config.h
Normal 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
|
||||||
54
boards/esp32-32e/esp32-32e.ino
Normal file
54
boards/esp32-32e/esp32-32e.ino
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
boards/esp32-32e/secrets.h.example
Normal file
11
boards/esp32-32e/secrets.h.example
Normal 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]);
|
||||||
47
boards/esp32-32e/tft_user_setup.h
Normal file
47
boards/esp32-32e/tft_user_setup.h
Normal 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
|
||||||
325
boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp
Normal file
325
boards/esp32-s3-lcd-43/DisplayDriverGFX.cpp
Normal 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);
|
||||||
|
}
|
||||||
46
boards/esp32-s3-lcd-43/DisplayDriverGFX.h
Normal file
46
boards/esp32-s3-lcd-43/DisplayDriverGFX.h
Normal 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;
|
||||||
|
};
|
||||||
52
boards/esp32-s3-lcd-43/board_config.h
Normal file
52
boards/esp32-s3-lcd-43/board_config.h
Normal 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
|
||||||
54
boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino
Normal file
54
boards/esp32-s3-lcd-43/esp32-s3-lcd-43.ino
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
boards/esp32-s3-lcd-43/secrets.h.example
Normal file
11
boards/esp32-s3-lcd-43/secrets.h.example
Normal 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]);
|
||||||
@@ -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:
|
* Hold-and-release interaction model:
|
||||||
* - OPI PSRAM mode (double bandwidth — fixes WiFi+RGB coexistence)
|
* - Hold finger → progress bar fills
|
||||||
* - Bounce buffer for RGB DMA
|
* - Bar full → jitter/flash ("RELEASE!")
|
||||||
* - Fixed variadic lambda crash in drawStatusScreen
|
* - Lift finger → action fires (finger already off screen)
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <SPI.h>
|
||||||
#include <Wire.h>
|
#include "Config.h"
|
||||||
#include <WiFi.h>
|
#include "DisplayManager.h"
|
||||||
#include <WiFiMulti.h>
|
#include "DoorbellLogic.h"
|
||||||
#include <WiFiClientSecure.h>
|
#include "BoardConfig.h"
|
||||||
#include <HTTPClient.h>
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include <NTPClient.h>
|
|
||||||
#include <WiFiUdp.h>
|
|
||||||
#include <Arduino_GFX_Library.h>
|
|
||||||
|
|
||||||
// =====================================================================
|
#include <TFT_eSPI.h>
|
||||||
// DEBUG MODE — set to 1 to use _test topic suffix
|
#ifndef LOAD_GLCD
|
||||||
// =====================================================================
|
#error "LOAD_GLCD is NOT defined — fonts missing!"
|
||||||
#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 ""
|
|
||||||
#endif
|
#endif
|
||||||
|
#if USE_TFT_ESPI
|
||||||
#define ALERT_URL NTFY_BASE "/ALERT_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
#ifndef ST7796_DRIVER
|
||||||
#define SILENCE_URL NTFY_BASE "/SILENCE_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
#if USE_TFT_ESPI
|
||||||
#define ADMIN_URL NTFY_BASE "/ADMIN_klubhaus_topic" TOPIC_SUFFIX "/json?since=10s&poll=1"
|
#error "TFT_eSPI setup mismatch — ST7796_DRIVER expected for E32R35T"
|
||||||
#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
|
|
||||||
#endif
|
#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
|
||||||
|
#endif
|
||||||
|
#define HOLD_TO_SILENCE_MS 1000
|
||||||
|
|
||||||
drawCentered("tap to dismiss | auto-sleeps in 5s",
|
DoorbellLogic logic;
|
||||||
SCREEN_HEIGHT - 25, 1, COL_DARK_GRAY);
|
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() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
unsigned long t = millis();
|
unsigned long t = millis();
|
||||||
while (!Serial && millis() - t < 5000) delay(10);
|
while (!Serial && millis() - t < 3000) delay(10);
|
||||||
delay(500);
|
delay(500);
|
||||||
|
|
||||||
bootTime = millis();
|
|
||||||
|
|
||||||
Serial.println("\n========================================");
|
Serial.println("\n========================================");
|
||||||
Serial.println(" KLUBHAUS ALERT v4.2 — Touch Edition");
|
Serial.println(" KLUBHAUS ALERT v5.1 — " BOARD_NAME "");
|
||||||
#if DEBUG_MODE
|
#if DEBUG_MODE
|
||||||
Serial.println(" *** DEBUG MODE — _test topics ***");
|
Serial.println(" *** DEBUG MODE — _test topics ***");
|
||||||
#endif
|
#endif
|
||||||
Serial.printf(" Grace period: %d ms\n", BOOT_GRACE_MS);
|
|
||||||
Serial.println("========================================");
|
Serial.println("========================================");
|
||||||
Serial.printf("Heap: %d KB PSRAM: %d KB\n",
|
|
||||||
ESP.getFreeHeap() / 1024, ESP.getPsramSize() / 1024);
|
|
||||||
|
|
||||||
if (ESP.getPsramSize() == 0) {
|
display.begin();
|
||||||
Serial.println("PSRAM required! Check FQBN has PSRAM=opi");
|
|
||||||
while (true) delay(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
initHardware();
|
logic.begin();
|
||||||
|
display.render(logic.getScreenState());
|
||||||
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
|
|
||||||
delay(1500);
|
delay(1500);
|
||||||
|
|
||||||
connectWiFi();
|
logic.beginWiFi();
|
||||||
|
display.render(logic.getScreenState());
|
||||||
|
|
||||||
if (WiFi.isConnected()) {
|
logic.connectWiFiBlocking();
|
||||||
checkNetwork();
|
display.render(logic.getScreenState());
|
||||||
}
|
delay(1500);
|
||||||
|
|
||||||
timeClient.begin();
|
logic.finishBoot();
|
||||||
if (timeClient.update()) {
|
display.setBacklight(false);
|
||||||
ntpSynced = true;
|
|
||||||
lastKnownEpoch = timeClient.getEpochTime();
|
|
||||||
Serial.printf("[NTP] Synced: %ld\n", lastKnownEpoch);
|
|
||||||
}
|
|
||||||
|
|
||||||
queueStatus("BOOTED", "v4.2 Touch Edition");
|
Serial.printf("[MEM] Free heap: %d | Free PSRAM: %d\n",
|
||||||
|
ESP.getFreeHeap(), ESP.getFreePsram());
|
||||||
currentState = STATE_SILENT;
|
|
||||||
setBacklight(false);
|
|
||||||
Serial.println("[BOOT] Ready — monitoring ntfy.sh\n");
|
Serial.println("[BOOT] Ready — monitoring ntfy.sh\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
|
||||||
// Loop
|
|
||||||
// =====================================================================
|
|
||||||
void loop() {
|
|
||||||
unsigned long now = millis();
|
|
||||||
|
|
||||||
if (Serial.available()) {
|
|
||||||
String cmd = Serial.readStringUntil('\n');
|
|
||||||
cmd.trim();
|
|
||||||
if (cmd == "CLEAR_DEDUP") {
|
|
||||||
lastAlertId = "";
|
|
||||||
lastSilenceId = "";
|
|
||||||
lastAdminId = "";
|
|
||||||
Serial.println("[CMD] Dedup cleared (all topics)");
|
|
||||||
}
|
|
||||||
else if (cmd == "NET") {
|
|
||||||
checkNetwork();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeClient.update()) {
|
// ── Silence handler (delegates to DoorbellLogic) ────────────────
|
||||||
ntpSynced = true;
|
void silenceAlerts() {
|
||||||
lastKnownEpoch = timeClient.getEpochTime();
|
Serial.println("[SILENCE] User completed hold-to-silence gesture");
|
||||||
}
|
logic.onTouch(TouchEvent{true, 0, 0});
|
||||||
|
}
|
||||||
if (inBootGrace && (now - bootTime >= BOOT_GRACE_MS)) {
|
|
||||||
inBootGrace = false;
|
void loop() {
|
||||||
Serial.println("[BOOT] Grace period ended — now monitoring");
|
logic.update();
|
||||||
}
|
display.render(logic.getScreenState());
|
||||||
|
|
||||||
handleTouch();
|
// Touch → hold-to-silence gesture
|
||||||
|
TouchEvent evt = display.readTouch();
|
||||||
if (!WiFi.isConnected()) {
|
if (evt.pressed) {
|
||||||
if (wifiMulti.run() == WL_CONNECTED) {
|
// Dashboard tile tap
|
||||||
Serial.println("[WIFI] Reconnected");
|
if (logic.getScreenState().screen == ScreenID::DASHBOARD) {
|
||||||
queueStatus("RECONNECTED", WiFi.SSID().c_str());
|
int tile = display.dashboardTouch(evt.x, evt.y);
|
||||||
}
|
if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (now - lastPoll >= POLL_INTERVAL_MS) {
|
|
||||||
lastPoll = now;
|
// Hold-to-silence during ALERT
|
||||||
if (WiFi.isConnected() && ntpSynced) {
|
if (logic.getScreenState().deviceState == DeviceState::ALERTING) {
|
||||||
pollTopic(ALERT_URL, handleAlertMessage, "ALERT", lastAlertId);
|
HoldState h = display.updateHold(HOLD_TO_SILENCE_MS);
|
||||||
pollTopic(SILENCE_URL, handleSilenceMessage, "SILENCE", lastSilenceId);
|
if (h.completed) silenceAlerts();
|
||||||
pollTopic(ADMIN_URL, handleAdminMessage, "ADMIN", lastAdminId);
|
|
||||||
}
|
// Hint animation when not touching
|
||||||
}
|
if (!h.active) display.updateHint();
|
||||||
|
}
|
||||||
flushStatus();
|
|
||||||
|
// Serial commands
|
||||||
switch (currentState) {
|
if (Serial.available()) {
|
||||||
case STATE_ALERTING:
|
String cmd = Serial.readStringUntil('\n');
|
||||||
if (now - alertStart > ALERT_TIMEOUT_MS) {
|
cmd.trim();
|
||||||
handleSilenceMessage("timeout");
|
if (cmd.length() > 0) logic.onSerialCommand(cmd);
|
||||||
break;
|
}
|
||||||
}
|
}
|
||||||
if (now - lastBlinkToggle >= BLINK_INTERVAL_MS) {
|
|
||||||
lastBlinkToggle = now;
|
void loop() {
|
||||||
blinkState = !blinkState;
|
logic.update();
|
||||||
drawAlertScreen();
|
display.render(logic.getScreenState());
|
||||||
}
|
|
||||||
break;
|
// Touch → hold-to-silence gesture
|
||||||
|
TouchEvent evt = display.readTouch();
|
||||||
case STATE_WAKE:
|
if (evt.pressed) {
|
||||||
if (now - wakeStart > WAKE_DISPLAY_MS) {
|
// Dashboard tile tap
|
||||||
currentState = STATE_SILENT;
|
if (logic.getScreenState().screen == ScreenID::DASHBOARD) {
|
||||||
setBacklight(false);
|
int tile = display.dashboardTouch(evt.x, evt.y);
|
||||||
}
|
if (tile >= 0) Serial.printf("[DASH] Tile %d tapped\n", tile);
|
||||||
break;
|
}
|
||||||
|
}
|
||||||
case STATE_SILENT:
|
|
||||||
break;
|
// Hold-to-silence during ALERT
|
||||||
}
|
if (logic.getScreenState().deviceState == DeviceState::ALERTING) {
|
||||||
|
HoldState h = display.updateHold(HOLD_TO_SILENCE_MS);
|
||||||
static unsigned long lastHB = 0;
|
if (h.completed) silenceAlerts();
|
||||||
if (now - lastHB >= 30000) {
|
|
||||||
lastHB = now;
|
// Hint animation when not touching
|
||||||
Serial.printf("[%lus] %s | WiFi:%s | heap:%dKB | net:%s\n",
|
if (!h.active) display.updateHint();
|
||||||
now / 1000,
|
}
|
||||||
currentState == STATE_SILENT ? "SILENT" :
|
|
||||||
currentState == STATE_ALERTING ? "ALERT" : "WAKE",
|
// Serial commands
|
||||||
WiFi.isConnected() ? "OK" : "DOWN",
|
if (Serial.available()) {
|
||||||
ESP.getFreeHeap() / 1024,
|
String cmd = Serial.readStringUntil('\n');
|
||||||
networkOK ? "OK" : "FAIL");
|
cmd.trim();
|
||||||
}
|
if (cmd.length() > 0) logic.onSerialCommand(cmd);
|
||||||
|
}
|
||||||
delay(20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
libraries/KlubhausCore/library.properties
Normal file
11
libraries/KlubhausCore/library.properties
Normal 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
|
||||||
26
libraries/KlubhausCore/src/Config.h
Normal file
26
libraries/KlubhausCore/src/Config.h
Normal 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;
|
||||||
|
};
|
||||||
24
libraries/KlubhausCore/src/DisplayManager.h
Normal file
24
libraries/KlubhausCore/src/DisplayManager.h
Normal 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;
|
||||||
|
};
|
||||||
260
libraries/KlubhausCore/src/DoorbellLogic.cpp
Normal file
260
libraries/KlubhausCore/src/DoorbellLogic.cpp
Normal 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"));
|
||||||
|
}
|
||||||
55
libraries/KlubhausCore/src/DoorbellLogic.h
Normal file
55
libraries/KlubhausCore/src/DoorbellLogic.h
Normal 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;
|
||||||
|
};
|
||||||
38
libraries/KlubhausCore/src/IDisplayDriver.h
Normal file
38
libraries/KlubhausCore/src/IDisplayDriver.h
Normal 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;
|
||||||
|
};
|
||||||
9
libraries/KlubhausCore/src/KlubhausCore.h
Normal file
9
libraries/KlubhausCore/src/KlubhausCore.h
Normal 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"
|
||||||
102
libraries/KlubhausCore/src/NetManager.cpp
Normal file
102
libraries/KlubhausCore/src/NetManager.cpp
Normal 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;
|
||||||
|
}
|
||||||
37
libraries/KlubhausCore/src/NetManager.h
Normal file
37
libraries/KlubhausCore/src/NetManager.h
Normal 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;
|
||||||
|
};
|
||||||
54
libraries/KlubhausCore/src/ScreenState.h
Normal file
54
libraries/KlubhausCore/src/ScreenState.h
Normal 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
130
mise.toml
@@ -1,31 +1,115 @@
|
|||||||
[tools]
|
# ═══════════════════════════════════════════════════════════
|
||||||
arduino-cli = "latest"
|
# Klubhaus Doorbell — Multi-Target Build Harness
|
||||||
lazygit = "latest"
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
[env]
|
[tasks.install-libs-shared]
|
||||||
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"
|
description = "Install shared (platform-independent) libraries"
|
||||||
|
|
||||||
[tasks.install-core]
|
|
||||||
run = "arduino-cli core update-index && arduino-cli core install esp32:esp32"
|
|
||||||
|
|
||||||
[tasks.install-libs]
|
|
||||||
run = """
|
run = """
|
||||||
arduino-cli lib install "GFX Library for Arduino"
|
arduino-cli lib install "ArduinoJson@7.4.1"
|
||||||
arduino-cli lib install "ArduinoJson"
|
arduino-cli lib install "NTPClient@3.2.1"
|
||||||
arduino-cli lib install "NTPClient"
|
echo "[OK] Shared libraries installed"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
[tasks.compile]
|
[tasks.install-libs-32e]
|
||||||
run = "arduino-cli compile --fqbn $FQBN ."
|
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]
|
[tasks.install-libs-s3-43]
|
||||||
depends = ["compile"]
|
description = "Vendor Arduino_GFX into vendor/esp32-s3-lcd-43"
|
||||||
run = "arduino-cli upload --fqbn $FQBN -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') ."
|
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]
|
[tasks.install-libs]
|
||||||
depends = ["upload"]
|
description = "Install all libraries (shared + vendored)"
|
||||||
run = "arduino-cli monitor -p $(arduino-cli board list | grep -i 'esp32\\|usb\\|ttyACM' | head -1 | awk '{print $1}') -c baudrate=115200"
|
depends = ["install-libs-shared", "install-libs-32e", "install-libs-s3-43"]
|
||||||
|
|
||||||
[tasks.all]
|
# ── ESP32-32E ────────────────────────────────────────────
|
||||||
depends = ["monitor"]
|
|
||||||
|
|
||||||
|
[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
1772
scaffold.sh
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user