# DisplayIdleService & Screensaver Disable-When-Charging + MCP Persistence ## Files - `Tactility/Source/service/displayidle/DisplayIdle.cpp` - `Tactility/Private/Tactility/service/displayidle/DisplayIdleService.h` - `Tactility/Source/service/displayidle/Screensaver.h` (base) + BouncingBalls, MatrixRain, Mystify, StackChan, McpScreensaver Only compiled `#ifdef ESP_PLATFORM` — not in simulator. tick()` flow (final — includes 50d86de4 wake fix for RLCD + 6f095081 no-auto-screensaver for monochrome): - `onStart()`: - `srand(time(nullptr))` - `cachedDisplaySettings = loadOrGetDefault()` — always, even on RLCD/no-backlight (pre-2026-07-11 early-return killed timer + settings, broke MCP) - If `!supportsBacklightDuty()` → log "Monochrome/RLCD detected: idle timer will run but auto-backlight off is disabled" but continue - Timer 50ms `TICK_INTERVAL_MS`, Lower priority, start - `tick()` flow (final): 1. **MCP active check OUTSIDE LVGL lock (3629ffef)**: `bool isMcpActive = (drawArea != nullptr || overrideActive)` under `state.mutex` BEFORE `lvgl::lock()` — avoids AB/BA deadlock 2. `lvgl::lock(100)` else return 3. `lv_display_get_default()==nullptr` → unlock return 4. `settingsReloadRequested.exchange(false)` → reload under lock 5. `inactive_ms = lv_display_get_inactive_time(nullptr)` — always read, for all displays (50d86de4: not gated by supportsBacklight) 6. If `displayDimmed && overlay && !stopRequested && !isMcpActive`: auto-off counter or `updateScreensaver()` 7. `lvgl::unlock()` 8. If `stopScreensaverRequested` → `stopScreensaver()` 9. Get display: `bool supportsBacklight = display != nullptr && display->supportsBacklightDuty()` 10. **Timeout logic runs for ALL displays (50d86de4)**: - If `!timeoutEnabled` or `timeoutMs==0`: if `displayDimmed && !isMcpActive` → if `supportsBacklight` restore duty, `displayDimmed=false` (wake for RLCD too) - Else: - `charging_blocks = disableScreensaverWhenCharging && isDeviceCharging()` - Not dimmed && inactive>=timeout && !charging_blocks → activate (creates screensaver even on RLCD) - Dimmed && !isMcpActive && inactive<100ms → stop (wake on touch/button, for RLCD too) - Dimmed && !isMcpActive && charging_blocks → stop (wake when plugged) - Only `setBacklightDuty()` calls guarded by `supportsBacklight && display != nullptr` - `stopScreensaverLocked()` requires LVGL lock. ## RLCD Wake Lock Bug (50d86de4) + Permanent Freeze (6f095081) — MUST READ **Symptom 50d86de4:** "screen seem to be locked..." — RLCD frozen after dim, buttons do nothing, looks crash but serial no Guru Meditation. **Root Cause 50d86de4:** Previous MCP fix changed tick() to `if (supportsBacklight) { if (!timeoutEnabled) restore else { activate/wake } }`. RLCD `supportsBacklightDuty()==false` → entire state machine skipped → once `displayDimmed=true`, wake branch `inactive<100ms` never runs → screensaver stuck forever → appears locked. **Fix 50d86de4:** Run timeout logic for all displays, guard only duty calls. See final flow above. Build RLCD `0x2b78b0 32% free`. **Symptom 6f095081:** "screen is still showing the freezed image" after 3770ac89 + 50d86de4 flashed. **Root Cause 6f095081:** Even with wake logic fixed, `BouncingBalls` screensaver `full_refresh=true` 300x400 120k pixels SPI 10MHz ~100ms/frame hogs LVGL lock. If SD has `display.properties` with `backlightTimeoutEnabled=1` 60s + BouncingBalls (carried from ES3C28P SD), RLCD auto-enters heavy animation → looks frozen stuck on weird image. RTS reset doesn't clear ST7305 RAM, frozen image persists until power cycle. **Fix 6f095081:** - For monochrome/RLCD `!supportsBacklight`: don't auto-enter screensaver, only handle wake. Switch `else {` → `else if (supportsBacklight) {` + `else { // monochrome don't auto-enter }` - Video stream idle 50→100ms, streaming 5→30ms - Advise user: unplug USB 3s power cycle to clear frozen RAM, set Timeout=Never or None on RLCD Verification: outer `if (supportsBacklight)` gates full handling, `else` monochrome only wake, `setBacklightDuty` guarded, `!isMcpActive>=3`. ## MCP Flash-Then-Back Bug (6235fa05) **Symptom:** "when the text tool is called it is shown on the screen for a small time (less than a second) and back to the previous screen." **Cause:** After `startMcpScreensaver()` sets `displayDimmed=true`, next `tick()` sees `inactive_ms < 100ms` → `stopScreensaver()` deletes overlay. **Fix 6235fa05:** Guard all auto-stop with `!isMcpActive` (count >=3). MCP remains closable via `stopScreensaverCb` CLICKABLE overlay. ## Lock Inversion Deadlock (3629ffef) **Symptom suspected as crash:** RLCD freeze after text tool, serial clean boot to AP `Tactility-5C7D`. - Video task: `state.mutex -> lvgl::lock()` in `ensureOverrideScreen()` - Old tick(): `lvgl::lock() -> state.mutex` - Fix: read `isMcpActive` BEFORE LVGL lock. Rule: MCP mutex first, never LVGL->MCP. - Verify: `mcp::getState()` pos < `lvgl::lock()` pos. Build `waveshare-esp32-s3-rlcd 0x2b78b0`. ## Screenshot Lock Contention (2026-07-14 observed, fixed 2026-07-14 6f095081) **Symptom:** "Screenshot failed: could not acquire LVGL lock" from `/api/screenshot` (WebServerService 1522 logs `could not acquire LVGL lock within 100ms`) and `ScreenshotTask` 50ms. Also "screen is still showing the freezed image" when video stream hogs. **Root Causes:** - `WebServerService::handleApiScreenshot` used `lvgl::lock(100)` — RLCD ST7305 SPI 10MHz full_refresh 120k pixels ~100ms/copy, 100ms too short - `ScreenshotTask::makeScreenshot` used 50ms - `mcp_video_stream_task` loop 5ms streaming hogs LVGL lock → UI appears frozen **Fixes 6f095081:** - Web handler 100→500ms + error msg update - ScreenshotTask 50→300ms - Video stream idle 50→100ms, streaming 5→30ms + comment "RLCD ST7305 SPI is slow and shared" - DisplayIdle: for monochrome/RLCD don't auto-enter screensaver (bouncing balls heavy) - Verification 14 checks PASS hermes-verify-jw_55oxm.py, build 0x2b79e0 - See `references/rlcd-initialization.md` for full init + contention audit ## Charging Guard Callback overload as in Launcher/Statusbar avoids clangd false-positive. Must combine with `!isMcpActive`. ## Pitfalls - Early return on `!supportsBacklightDuty()` breaks MCP entirely — always load settings + start timer - Outer `if (supportsBacklight)` gating timeout/wake breaks RLCD — run for all, guard only duty - `lv_display_get_inactive_time() < 100ms` kills MCP if guard missing — guard all auto-stop with `!isMcpActive` - Forgetting `settingsReloadRequested` check races overlay creation - `findDevices` callback signature `bool(const shared_ptr&)` — clangd may false-report; callback form silences it - `backlightOff` after auto-off stays off until wake — charging_blocks can wake but only if `!isMcpActive`