Files
klubhaus-doorbell/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp
2026-02-16 19:05:13 -08:00

479 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "DisplayManager.h"
#include "Config.h"
DisplayManager::DisplayManager()
: _dash(_tft)
{
}
void DisplayManager::begin() {
pinMode(PIN_LCD_BL, OUTPUT);
setBacklight(true);
_tft.init();
_tft.setRotation(1);
_tft.fillScreen(COL_BLACK);
uint16_t calData[5] = { 300, 3600, 300, 3600, 1 };
_tft.setTouch(calData);
}
void DisplayManager::setBacklight(bool on) {
digitalWrite(PIN_LCD_BL, on ? HIGH : LOW);
}
TouchEvent DisplayManager::readTouch() {
TouchEvent evt;
uint16_t x, y;
if (_tft.getTouch(&x, &y)) {
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 — two-phase: charge up, then release to confirm
// =====================================================================
HoldState DisplayManager::updateHold(unsigned long requiredMs) {
HoldState h;
h.targetMs = requiredMs;
uint16_t tx, ty;
bool touching = _tft.getTouch(&tx, &ty);
if (touching) {
if (!_holdActive) {
// New press begins
_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) {
// Charged! But don't fire yet — wait for release
_holdCharged = true;
if (_holdChargeMs == 0) _holdChargeMs = millis();
h.charged = true;
}
} else {
// Finger lifted
if (_holdActive) {
if (_holdCharged) {
// Released after full charge → FIRE
h.completed = true;
h.x = _holdX;
h.y = _holdY;
Serial.println("[HOLD] Charged + released → completed!");
} else {
// Released too early → cancelled
h.cancelled = true;
Serial.println("[HOLD] Released early → cancelled");
}
}
_holdActive = false;
_holdCharged = false;
_holdProgress = 0.0f;
_holdChargeMs = 0;
}
return h;
}
// =====================================================================
// 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;
}
// Overlay progress bar when holding
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 — with jitter/flash when charged
// =====================================================================
// =====================================================================
// Silence progress bar — gradient fill + breathing plateau on charge
// =====================================================================
void DisplayManager::drawSilenceProgress(float progress, bool charged) {
const int barX = 20;
const int barY = SCREEN_HEIGHT - 50; // near bottom of alert screen
const int barW = SCREEN_WIDTH - 40;
const int barH = 26;
const int radius = 6;
// ── Background track ──
_tft.fillRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY);
if (charged) {
// ── Plateau: full bar with breathing pulse ──
// Sine-based brightness oscillation (~1 Hz)
float breath = (sinf(millis() / 150.0f) + 1.0f) / 2.0f; // 0.01.0
uint8_t gLo = 42, gHi = 63; // green channel range (RGB565 6-bit)
uint8_t g = gLo + (uint8_t)(breath * (float)(gHi - gLo));
uint16_t pulseCol = (g << 5); // pure green in RGB565
_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;
}
// ── Filling: ease-out cubic ──
float eased = 1.0f - powf(1.0f - progress, 3.0f);
int fillW = max(1, (int)(eased * (float)barW));
// Gradient slices: dark green → bright green
uint8_t gMin = 16, gMax = 58; // RGB565 green channel range
for (int i = 0; i < fillW; i++) {
float frac = (float)i / (float)barW; // position relative to full bar
uint8_t g = gMin + (uint8_t)(frac * (float)(gMax - gMin));
_tft.drawFastVLine(barX + i, barY + 1, barH - 2, (uint16_t)(g << 5));
}
// Border on top of gradient slices
_tft.drawRoundRect(barX, barY, barW, barH, radius, COL_WHITE);
// Label
_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);
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);
_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);
_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);
drawCentered("v5.1 E32R35T", 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);
}
// Show hint text only when NOT interacting
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 — "nohup" coaching affordance
// =====================================================================
void DisplayManager::startHintCycle() {
_hint = HintAnim{}; // reset everything
_hint.lastPlayMs = millis(); // will wait INITIAL_DELAY before first play
}
void DisplayManager::stopHint() {
_hint.running = false;
}
bool DisplayManager::updateHint() {
unsigned long now = millis();
// If a real hold is active, don't draw hints
// (caller should also just not call this, but belt-and-suspenders)
if (_holdActive) {
_hint.running = false;
return false;
}
// ── Not currently animating: check if it's time to start ──
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;
}
}
// ── Currently animating ──
unsigned long elapsed = now - _hint.startMs;
if (elapsed > _hint.totalDur()) {
// Animation complete — clear bar and schedule next
_hint.running = false;
_hint.lastPlayMs = now;
drawSilenceProgress(0.0f, false); // clear to empty track
return true;
}
float progress = 0.0f;
if (elapsed < HintAnim::FILL_DUR) {
// Phase 1: ease-in quadratic fill to peak
float t = (float)elapsed / (float)HintAnim::FILL_DUR;
progress = HintAnim::PEAK * (t * t); // ease-in
} else if (elapsed < HintAnim::FILL_DUR + HintAnim::HOLD_DUR) {
// Phase 2: dwell at peak
progress = HintAnim::PEAK;
} else {
// Phase 3: ease-out quadratic drain
float t = (float)(elapsed - HintAnim::FILL_DUR - HintAnim::HOLD_DUR)
/ (float)HintAnim::DRAIN_DUR;
progress = HintAnim::PEAK * (1.0f - t * t); // ease-out
}
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;
// Background track
_tft.fillRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY);
if (progress > 0.001f) {
int fillW = max(1, (int)(progress * (float)barW));
// Ghost gradient: muted teal/cyan instead of green
// RGB565 pure-ish teal: low red, mid green, mid blue
for (int i = 0; i < fillW; i++) {
float frac = (float)i / (float)barW;
uint8_t g = 12 + (uint8_t)(frac * 18.0f); // green 1230 (muted)
uint8_t b = 8 + (uint8_t)(frac * 10.0f); // blue 818 (teal tint)
uint16_t col = (g << 5) | b;
_tft.drawFastVLine(barX + i, barY + 1, barH - 2, col);
}
}
_tft.drawRoundRect(barX, barY, barW, barH, radius, COL_GRAY); // muted border (not white)
// Ghost label
_tft.setTextDatum(MC_DATUM);
_tft.setTextFont(2);
_tft.setTextSize(1);
_tft.setTextColor(COL_GRAY, COL_DARK_GRAY);
_tft.drawString("HOLD TO SILENCE", barX + barW / 2, barY + barH / 2);
}
float DisplayManager::holdProgress() const {
if (!_holdActive) return 0.0f;
return constrain((float)(millis() - _holdStartMs) / (float)HOLD_DURATION_MS, 0.0f, 1.0f);
}