From 46b0cb96a94cf9eecd2fff253504bf754f187e08 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Feb 2026 11:53:09 -0800 Subject: [PATCH] snapshot --- .../DisplayManager.cpp | 214 +++++++++++++----- .../doorbell-touch-esp32-32e/DisplayManager.h | 24 ++ .../doorbell-touch-esp32-32e.ino | 20 +- sketches/doorbell-touch-esp32-32e/mise.toml | 1 + 4 files changed, 189 insertions(+), 70 deletions(-) diff --git a/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp b/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp index dba6289..7d575d4 100644 --- a/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp +++ b/sketches/doorbell-touch-esp32-32e/DisplayManager.cpp @@ -171,77 +171,59 @@ void DisplayManager::drawDashboard(const ScreenState& 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) { - int barX = 10; - int barY = SCREEN_HEIGHT - 40; - int barW = SCREEN_WIDTH - 20; - int barH = 30; + 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; - if (charged) { - // ---- CHARGED: jitter + flash effect ---- - unsigned long elapsed = millis() - _holdChargeMs; - int cycle = (elapsed / 60) % 6; // fast 60ms cycle, 6 frames + // ── Background track ── + _tft.fillRoundRect(barX, barY, barW, barH, radius, COL_DARK_GRAY); - // Jitter: offset bar position by ±2-3px - int jitterX = 0; - int jitterY = 0; - switch (cycle) { - case 0: jitterX = -3; jitterY = 1; break; - case 1: jitterX = 2; jitterY = -2; break; - case 2: jitterX = -1; jitterY = 2; break; - case 3: jitterX = 3; jitterY = -1; break; - case 4: jitterX = -2; jitterY = -1; break; - case 5: jitterX = 1; jitterY = 2; break; + 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.0–1.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; } - // Clear slightly larger area to avoid jitter artifacts - _tft.fillRect(barX - 4, barY - 4, barW + 8, barH + 8, COL_BLACK); - - // Flash between green and white - uint16_t flashCol = (cycle % 2 == 0) ? COL_GREEN : COL_WHITE; - uint16_t textCol = (cycle % 2 == 0) ? COL_BLACK : COL_BLACK; - - _tft.fillRoundRect(barX + jitterX, barY + jitterY, barW, barH, 6, flashCol); - _tft.drawRoundRect(barX + jitterX, barY + jitterY, barW, barH, 6, COL_YELLOW); - - // ">> RELEASE! <<" text - _tft.setTextFont(1); - _tft.setTextSize(2); - _tft.setTextDatum(MC_DATUM); - _tft.setTextColor(textCol, flashCol); - - const char* labels[] = { ">> RELEASE! <<", "<< RELEASE! >>", - ">> RELEASE! <<", "<< RELEASE! >>" }; - _tft.drawString(labels[cycle % 4], - SCREEN_WIDTH / 2 + jitterX, - barY + barH / 2 + jitterY); - - } else { - // ---- FILLING: normal progress bar ---- - int fillW = (int)(barW * progress); - - _tft.fillRect(barX, barY, barW, barH, COL_BLACK); - if (fillW > 0) { - // Gradient: dark green (left) → bright green (right) - for (int i = 0; i < fillW; i++) { - uint8_t g = map(i, 0, barW, 20, 63); // 6-bit green channel (RGB565) - uint16_t col = (g << 5); // pure green in RGB565 - _tft.drawFastVLine(barX + i, barY, barH, col); - } - // Redraw rounded border on top since the slices are square - _tft.drawRoundRect(barX, barY, fillW, barH, 6, COL_WHITE); + // ── 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)); } - _tft.drawRoundRect(barX, barY, barW, barH, 6, COL_WHITE); - // Percentage indicator inside bar - _tft.setTextFont(1); - _tft.setTextSize(2); + // Border on top of gradient slices + _tft.drawRoundRect(barX, barY, barW, barH, radius, COL_WHITE); + + // Label _tft.setTextDatum(MC_DATUM); - _tft.setTextColor(COL_WHITE, COL_BLACK); - _tft.drawString("HOLD TO SILENCE", - SCREEN_WIDTH / 2, barY + barH / 2); - } + _tft.setTextFont(2); + _tft.setTextSize(1); + _tft.setTextColor(COL_WHITE, COL_DARK_GRAY); + _tft.drawString("HOLD", barX + barW / 2, barY + barH / 2); } // ===================================================================== @@ -386,3 +368,111 @@ void DisplayManager::drawStatusScreen(const ScreenState& s) { 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 12–30 (muted) + uint8_t b = 8 + (uint8_t)(frac * 10.0f); // blue 8–18 (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); +} diff --git a/sketches/doorbell-touch-esp32-32e/DisplayManager.h b/sketches/doorbell-touch-esp32-32e/DisplayManager.h index 9a2d0ad..66fcdbb 100644 --- a/sketches/doorbell-touch-esp32-32e/DisplayManager.h +++ b/sketches/doorbell-touch-esp32-32e/DisplayManager.h @@ -16,6 +16,24 @@ struct HoldState { float progress = 0.0f; // 0.0 to 1.0 }; +// Add near the top, alongside existing structs +struct HintAnim { + bool running = false; + unsigned long startMs = 0; + unsigned long lastPlayMs = 0; + + // Timing (ms) + static const unsigned long INITIAL_DELAY = 1500; // pause before first hint + static const unsigned long FILL_DUR = 400; // ease-in to peak + static const unsigned long HOLD_DUR = 250; // dwell at peak + static const unsigned long DRAIN_DUR = 500; // ease-out back to 0 + static const unsigned long REPEAT_DELAY = 5000; // gap before replaying + + static constexpr float PEAK = 0.35f; // fill to 35% + + unsigned long totalDur() const { return FILL_DUR + HOLD_DUR + DRAIN_DUR; } +}; + class DisplayManager { public: DisplayManager(); @@ -26,8 +44,14 @@ public: int dashboardTouch(uint16_t x, uint16_t y); HoldState updateHold(unsigned long requiredMs); + void startHintCycle(); // call when entering alert screen + void stopHint(); // call when real hold begins or leaving alert + bool updateHint(); // call each loop; returns true if it drew something + private: + HintAnim _hint; + void drawHintBar(float progress); // ← add this TFT_eSPI _tft; Dashboard _dash; diff --git a/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino b/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino index d0133b4..55a8753 100644 --- a/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino +++ b/sketches/doorbell-touch-esp32-32e/doorbell-touch-esp32-32e.ino @@ -67,18 +67,21 @@ void loop() { // ---- Touch handling varies by screen ---- if (state.screen == ScreenID::ALERT) { - // Hold-and-release to silence + // Hold-and-release to silence HoldState hold = display.updateHold(HOLD_TO_SILENCE_MS); if (hold.completed) { - // Finger lifted after full charge → silence now - // Small delay so user sees the clean transition - delay(80); - logic.onTouch(TouchEvent{true, hold.x, hold.y}); + // Finger lifted after full charge → silence now + display.stopHint(); + delay(80); + silenceAlerts(); + } else if (hold.active || hold.charged) { + // Real interaction in progress — suppress hint + display.stopHint(); + } else { + // No touch — run coaching hint + display.updateHint(); } - // charged/filling states are rendered by drawSilenceProgress() - // cancelled = finger lifted early, no action needed - } else if (state.screen == ScreenID::DASHBOARD) { TouchEvent evt = display.readTouch(); if (evt.pressed) { @@ -119,5 +122,6 @@ void loop() { cmd.trim(); if (cmd.length() > 0) logic.onSerialCommand(cmd); } + } diff --git a/sketches/doorbell-touch-esp32-32e/mise.toml b/sketches/doorbell-touch-esp32-32e/mise.toml index 24f29f7..8bceb1d 100644 --- a/sketches/doorbell-touch-esp32-32e/mise.toml +++ b/sketches/doorbell-touch-esp32-32e/mise.toml @@ -2,6 +2,7 @@ arduino-cli = "latest" lazygit = "latest" python = "latest" +ruby = "latest" [env] FQBN = "esp32:esp32:esp32:UploadSpeed=921600,CPUFreq=240,FlashFreq=80,FlashMode=qio,FlashSize=4M,PartitionScheme=default,DebugLevel=info,EraseFlash=none"