#include #include #include #include #include #include #include #include #include #include "config.h" #include "settings.h" #include "wifi_manager.h" #include "power_manager.h" #include "button_handler.h" #include "immich_client.h" #include "photo_queue.h" #include "image_pipeline.h" #include "display_manager.h" #include "web_server.h" #include "time_utils.h" #include "wake_log.h" // Global instances SettingsManager settingsManager; WiFiManager wifiManager; PowerManager powerManager; ButtonHandler buttonHandler; ImmichClient immichClient; PhotoQueue photoQueue; ImagePipeline imagePipeline; DisplayManager displayManager; AppWebServer webServer; // Pre-rendered photo ready for instant display on next slideshow tick struct PendingPhoto { ProcessedImage img; AssetInfo info; AssetInfo info2; bool isPair; bool ready; uint32_t settingsHash; }; static uint32_t computeSettingsHash(const Settings& s) { return (uint32_t)s.pipeline_mode | ((uint32_t)s.dither_noise << 8) | ((uint32_t)s.img_quality << 16); } // Shared state SemaphoreHandle_t stateMutex; volatile bool slideshowPlaying = true; volatile bool refreshRequested = false; volatile bool randomRequested = false; volatile unsigned long lastRefreshTime = 0; PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0}; // Task handles TaskHandle_t displayTaskHandle = nullptr; // Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer. // Callers provide the image, metadata info, and current settings. Returns true if displayed. static bool displayPhoto(ProcessedImage& img, const AssetInfo& info, const AssetInfo& info2, bool isPair, const Settings& s) { if (!img.valid) return false; if (isPair) { uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2; uint16_t rightX = halfW + PORTRAIT_GAP_PX; displayManager.showImage(img); if (s.meta_flags != 0) { displayManager.showMetadataInRegion( info, s.meta_flags, s.meta_pos, 0, 0, halfW, DISPLAY_HEIGHT, s.date_fmt, s.time_fmt); displayManager.showMetadataInRegion( info2, s.meta_flags, s.meta_pos, rightX, 0, halfW, DISPLAY_HEIGHT, s.date_fmt, s.time_fmt); } } else { displayManager.showImage(img); if (s.meta_flags != 0) { displayManager.showMetadata(info, s.meta_flags, s.meta_pos, s.date_fmt, s.time_fmt); } } if (s.show_battery) { uint8_t battPct = powerManager.getBatteryPercent(); displayManager.showBatteryIndicator(battPct, s.meta_pos); } displayManager.refresh(); imagePipeline.freeImage(img); return true; } // Fetch, download, decode, and dither one photo (or portrait pair) from the queue. // On success, fills out the PendingPhoto fields and returns true. // On failure after MAX_RETRIES attempts, returns false with pending.ready untouched. static bool prepareNextPhoto(PendingPhoto& out, const Settings& s, bool wantsRandom) { static constexpr int MAX_RETRIES = 10; imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { String assetId = wantsRandom ? photoQueue.random() : photoQueue.next(); if (assetId.length() == 0) break; Serial.printf("[prepare] Loading asset: %s (attempt %d)\n", assetId.c_str(), attempt + 1); AssetInfo info = immichClient.fetchAssetInfo(assetId); uint8_t* jpegBuf = nullptr; size_t jpegSize = 0; if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize) || jpegBuf == nullptr) { Serial.println("[prepare] Download failed, trying next"); continue; } Serial.printf("[prepare] JPEG: %u KB, PSRAM free: %u KB\n", (unsigned)(jpegSize / 1024), (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); ProcessedImage img = {nullptr, 0, 0, false}; AssetInfo info2 = {}; bool isPair = false; if (info.isPortrait) { Serial.println("[prepare] Portrait detected, searching for pair"); String pairId = photoQueue.findNextPortrait(); if (pairId.length() > 0) { info2 = immichClient.fetchAssetInfo(pairId); uint8_t* jpeg2Buf = nullptr; size_t jpeg2Size = 0; bool dl2 = immichClient.downloadAsset( pairId, s.img_quality, &jpeg2Buf, &jpeg2Size); if (dl2 && jpeg2Buf != nullptr) { Serial.printf("[prepare] Portrait pair: %u KB + %u KB\n", (unsigned)(jpegSize / 1024), (unsigned)(jpeg2Size / 1024)); img = imagePipeline.processPortraitPair( jpegBuf, jpegSize, jpeg2Buf, jpeg2Size); free(jpeg2Buf); isPair = img.valid; if (!img.valid) { Serial.println("[prepare] Pair processing failed, showing solo"); img = imagePipeline.process(jpegBuf, jpegSize); } } else { Serial.println("[prepare] Pair download failed, showing solo"); img = imagePipeline.process(jpegBuf, jpegSize); } } else { img = imagePipeline.process(jpegBuf, jpegSize); } } else { img = imagePipeline.process(jpegBuf, jpegSize); } free(jpegBuf); Serial.printf("[prepare] Post-process PSRAM free: %u KB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); if (img.valid) { out.img = img; out.info = info; out.info2 = info2; out.isPair = isPair; out.settingsHash = computeSettingsHash(s); out.ready = true; return true; } Serial.println("[prepare] Processing failed, trying next"); } return false; } // Discard a pre-rendered pending photo, freeing its framebuffer. static void discardPending(PendingPhoto& p) { if (p.ready && p.img.valid) { Serial.println("[prerender] Discarding stale pending image"); imagePipeline.freeImage(p.img); } p.ready = false; } void displayTask(void* param) { Serial.println("[display_task] Started on Core 1"); // Initial sync if (wifiManager.isConnected()) { Settings s = settingsManager.get(); immichClient.begin(s.immich_url, s.immich_key); photoQueue.begin(immichClient, settingsManager); if (photoQueue.sync()) { Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size()); if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { refreshRequested = true; xSemaphoreGive(stateMutex); } } else { displayManager.showMessage("No Photos", "Select albums in web UI"); } } while (true) { unsigned long now = millis(); Settings s = settingsManager.get(); displayManager.setTranslucent(s.meta_translucent != 0); unsigned long intervalMs = s.interval_min * 60000UL; bool shouldRefresh = false; bool wantsRandom = false; bool isManual = false; if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { if (refreshRequested) { shouldRefresh = true; isManual = true; refreshRequested = false; } else if (randomRequested) { shouldRefresh = true; wantsRandom = true; isManual = true; randomRequested = false; } else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) { shouldRefresh = true; } xSemaphoreGive(stateMutex); } // Periodic re-sync (includes album change requests) if (photoQueue.needsResync() && wifiManager.isConnected()) { photoQueue.sync(); discardPending(pending); } // --- Phase 1: Display --- if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) { bool displayed = false; uint32_t currentHash = computeSettingsHash(s); if (!isManual && pending.ready && pending.settingsHash == currentHash) { // Use pre-rendered image — instant display Serial.println("[display_task] Using pre-rendered image"); displayed = displayPhoto(pending.img, pending.info, pending.info2, pending.isPair, s); pending.ready = false; } else { // Manual request, stale settings, or no pre-render available — inline pipeline if (pending.ready) { if (isManual) { Serial.println("[display_task] Manual request — discarding pre-render"); } else { Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n", pending.settingsHash, currentHash); } discardPending(pending); } else { Serial.println("[display_task] No pre-render available — inline fallback"); } imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0}; if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) { displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info, inlinePhoto.info2, inlinePhoto.isPair, s); } } // Log display result (normal mode) if (displayed) { wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), 0, photoQueue.current(), "", "n/a", WakeLogResult::Ok, false); } if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { lastRefreshTime = millis(); xSemaphoreGive(stateMutex); } // --- Phase 2: Pre-render next photo --- if (displayed && photoQueue.size() > 0 && wifiManager.isConnected()) { bool playing = false; if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { playing = slideshowPlaying; xSemaphoreGive(stateMutex); } if (playing) { Serial.println("[prerender] Starting pre-render of next photo"); Settings preS = settingsManager.get(); imagePipeline.setPipelineMode(static_cast(preS.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(preS.dither_noise != 0); if (prepareNextPhoto(pending, preS, false)) { Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); } else { Serial.println("[prerender] Pre-render failed — will fall back to inline"); } } } } // --- Phase 2 (idle): Pre-render if we don't have one yet --- if (!pending.ready && photoQueue.size() > 0 && wifiManager.isConnected()) { bool playing = false; if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { playing = slideshowPlaying; xSemaphoreGive(stateMutex); } if (playing) { Serial.println("[prerender] No pending image — pre-rendering now"); Settings preS = settingsManager.get(); if (prepareNextPhoto(pending, preS, false)) { Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); } else { Serial.println("[prerender] Pre-render failed — will retry next loop"); } } } // Yield — check every second vTaskDelay(pdMS_TO_TICKS(1000)); } } void webActionCallback(const String& action) { if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { if (action == "next") { refreshRequested = true; } else if (action == "random") { randomRequested = true; } else if (action == "pause") { slideshowPlaying = false; } else if (action == "play") { slideshowPlaying = true; } else if (action == "albums_changed") { photoQueue.requestSync(); refreshRequested = true; } xSemaphoreGive(stateMutex); } } // Build a RandomFetchFilter based on the current cycle mode and probability. // Returns a filter with appropriate date/favorite constraints. static RandomFetchFilter buildTimerFilter(const Settings& s, const std::vector& albumIds) { RandomFetchFilter filter; filter.albumIds = albumIds; // If system clock isn't valid, weighted/chrono modes can't work — use plain random if (!hasValidTime()) { Serial.println("[timer_wake] No valid time — using unfiltered random"); return filter; } // Probability roll: 0-99 int roll = random(0, 100); switch (s.cycle_mode) { case CycleMode::Random: // No extra filters break; case CycleMode::Chronological: if (s.timer_last_date.length() > 0) { filter.takenAfter = s.timer_last_date; } else { filter.takenAfter = "1970-01-01T00:00:00.000Z"; } break; case CycleMode::ReverseChronological: if (s.timer_last_date.length() > 0) { filter.takenBefore = s.timer_last_date; } else { filter.takenBefore = isoNow(); } break; case CycleMode::FavoritesWeighted: // 66% chance: favorites only, 33% chance: no filter if (roll < 66) { filter.favoriteOnly = true; } break; case CycleMode::WeightedChronological: // 66% chance: recent photos (last 30 days), 33% chance: no filter if (roll < 66) { filter.takenAfter = isoNowMinus30d(); } break; case CycleMode::WeightedReverseChronological: // 66% chance: older photos (before 30 days ago), 33% chance: no filter if (roll < 66) { filter.takenBefore = isoNowMinus30d(); } break; default: { CycleMode unreachable = s.cycle_mode; (void)unreachable; break; } } return filter; } // Derive a human-readable filter name from the cycle mode and filter state. static String filterName(CycleMode mode, bool relaxed) { if (relaxed) return "relaxed"; switch (mode) { case CycleMode::Random: return "random"; case CycleMode::Chronological: return "chrono"; case CycleMode::ReverseChronological: return "rev_chrono"; case CycleMode::FavoritesWeighted: return "fav"; case CycleMode::WeightedChronological: return "recent"; case CycleMode::WeightedReverseChronological: return "oldest"; default: { CycleMode unreachable = mode; (void)unreachable; return "unknown"; } } } // Compute a simple hash of settings that affect the batch cache validity. static uint32_t timerCacheHash(const Settings& s) { uint32_t h = 5381; h = h * 33 + static_cast(s.cycle_mode); h = h * 33 + s.timer_batch_size; for (size_t i = 0; i < s.albums_json.length(); i++) { h = h * 33 + (uint8_t)s.albums_json[i]; } return h; } // Parse NVS cache string: ":,,..." // Returns empty vector if hash doesn't match or string is empty/invalid. static std::vector parseCacheIds(const String& raw, uint32_t expectedHash) { std::vector ids; int colonIdx = raw.indexOf(':'); if (colonIdx < 1) return ids; uint32_t storedHash = (uint32_t)strtoul(raw.substring(0, colonIdx).c_str(), nullptr, 10); if (storedHash != expectedHash) { Serial.println("[timer_wake] Cache hash mismatch — invalidating"); return ids; } String idsPart = raw.substring(colonIdx + 1); if (idsPart.length() == 0) return ids; int start = 0; while (start < (int)idsPart.length()) { int commaIdx = idsPart.indexOf(',', start); if (commaIdx < 0) commaIdx = idsPart.length(); String id = idsPart.substring(start, commaIdx); if (id.length() > 0) ids.push_back(id); start = commaIdx + 1; } return ids; } // Serialize cache IDs back to NVS format: ":,,..." static String buildCacheString(uint32_t hash, const std::vector& ids) { String result = String(hash) + ":"; for (size_t i = 0; i < ids.size(); i++) { if (i > 0) result += ','; result += ids[i]; } return result; } // Minimal boot path for RTC timer wake: fetch one photo, display, sleep again void timerWakeCycle() { Serial.println("[timer_wake] Starting minimal wake cycle"); // Initialize wake log early (mounts LittleFS, increments boot counter) wakeLogBegin(); Settings s = settingsManager.get(); // Connect WiFi (minimal — skip NTP/mDNS to save battery) unsigned long wifiStart = millis(); wifiManager.begin(settingsManager, true); uint32_t wifiMs = millis() - wifiStart; if (!wifiManager.isConnected()) { Serial.println("[timer_wake] WiFi failed — going back to sleep"); wakeLogAppend(powerManager.getBatteryPercent(), 0, wifiMs, "", "", "n/a", WakeLogResult::FailFetch, true); powerManager.enterDeepSleep(s.interval_min); return; } // Initialize Immich client and apply pipeline/display modes immichClient.begin(s.immich_url, s.immich_key); imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); displayManager.setTranslucent(s.meta_translucent != 0); // Parse selected album IDs from settings std::vector albumIds; { JsonDocument doc; DeserializationError err = deserializeJson(doc, s.albums_json); if (!err) { JsonArray arr = doc.as(); for (JsonVariant v : arr) { albumIds.push_back(v.as()); } } } // --- Batch cache logic --- uint32_t cacheHash = timerCacheHash(s); String cacheRaw = settingsManager.readField("timer_ids", ""); std::vector cachedIds = parseCacheIds(cacheRaw, cacheHash); bool cacheWasEmpty = cachedIds.empty(); bool filterRelaxed = false; // If cache is empty, refill it with a batch fetch if (cachedIds.empty()) { Serial.printf("[timer_wake] Cache empty — fetching batch of %d\n", s.timer_batch_size); static constexpr int BATCH_FETCH_RETRIES = 3; for (int attempt = 0; attempt < BATCH_FETCH_RETRIES && cachedIds.empty(); attempt++) { RandomFetchFilter filter; if (filterRelaxed) { filter.albumIds = albumIds; } else { filter = buildTimerFilter(s, albumIds); } cachedIds = immichClient.fetchFilteredBatch(filter, s.timer_batch_size); if (cachedIds.empty()) { if (!filterRelaxed && (filter.favoriteOnly || filter.takenAfter.length() > 0 || filter.takenBefore.length() > 0)) { Serial.println("[timer_wake] Filtered batch empty — relaxing filter"); filterRelaxed = true; if (s.cycle_mode == CycleMode::Chronological || s.cycle_mode == CycleMode::ReverseChronological) { settingsManager.saveField("timer_date", ""); s.timer_last_date = ""; } continue; } Serial.printf("[timer_wake] Batch fetch failed (attempt %d)\n", attempt + 1); } } if (cachedIds.empty()) { // All retries exhausted — log failure and sleep Serial.println("[timer_wake] Batch fetch failed — no assets available"); wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs, "", "", filterName(s.cycle_mode, filterRelaxed), WakeLogResult::FailFetch, true); powerManager.enterDeepSleep(s.interval_min); return; } Serial.printf("[timer_wake] Batch fetched %d IDs\n", (int)cachedIds.size()); } else { Serial.printf("[timer_wake] Cache hit — %d IDs remaining\n", (int)cachedIds.size()); } // Pop the first ID from cache String assetId = cachedIds[0]; cachedIds.erase(cachedIds.begin()); // Save updated cache (remaining IDs) back to NVS if (cachedIds.empty()) { settingsManager.saveField("timer_ids", ""); } else { settingsManager.saveField("timer_ids", buildCacheString(cacheHash, cachedIds).c_str()); } // --- Download and display --- static constexpr int DISPLAY_RETRIES = 3; bool displayed = false; String lastPhotoDate = ""; WakeLogResult lastResult = WakeLogResult::FailDownload; for (int attempt = 0; attempt < DISPLAY_RETRIES && !displayed; attempt++) { AssetInfo info = immichClient.fetchAssetInfo(assetId); lastPhotoDate = info.dateTime; uint8_t* jpegBuf = nullptr; size_t jpegSize = 0; if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) { Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1); lastResult = WakeLogResult::FailDownload; continue; } ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize); free(jpegBuf); if (img.valid) { displayManager.showImage(img); if (s.meta_flags != 0) { displayManager.showMetadata(info, s.meta_flags, s.meta_pos, s.date_fmt, s.time_fmt); } if (s.show_battery) { uint8_t battPct = powerManager.getBatteryPercent(); displayManager.showBatteryIndicator(battPct, s.meta_pos); } displayManager.showSleepIndicator(s.meta_pos); displayManager.refresh(); imagePipeline.freeImage(img); displayed = true; lastResult = WakeLogResult::Ok; // Update chronological cursor with this photo's date if (info.dateTime.length() > 0) { settingsManager.saveField("timer_date", info.dateTime.c_str()); } } else { Serial.println("[timer_wake] Processing failed, retrying"); lastResult = WakeLogResult::FailProcess; } } // Log this wake cycle String logFilter = cacheWasEmpty ? filterName(s.cycle_mode, filterRelaxed) : String("cached"); wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs, assetId, lastPhotoDate, logFilter, lastResult, true); if (!displayed) { Serial.println("[timer_wake] Failed to display photo"); } Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min); powerManager.enterDeepSleep(s.interval_min); } void setup() { auto cfg = M5.config(); cfg.clear_display = false; // Don't clear/refresh e-ink on init M5.begin(cfg); // Prevent any automatic e-ink refreshes until we explicitly call display() M5.Display.setAutoDisplay(false); Serial.begin(115200); Serial.println("[main] Immich Frame v1.0 booting..."); Serial.printf("[main] PSRAM: %u KB free / %u KB total\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024), (unsigned)(heap_caps_get_total_size(MALLOC_CAP_SPIRAM) / 1024)); Serial.printf("[main] Internal RAM: %u KB free\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024)); // Seed system clock from hardware RTC (RX8130CE) for immediate time availability { auto rtcDt = M5.Rtc.getDateTime(); struct tm t = {}; t.tm_year = rtcDt.date.year - 1900; t.tm_mon = rtcDt.date.month - 1; t.tm_mday = rtcDt.date.date; t.tm_hour = rtcDt.time.hours; t.tm_min = rtcDt.time.minutes; t.tm_sec = rtcDt.time.seconds; time_t rtcTime = mktime(&t); if (rtcTime > 1600000000) { // Plausible (post-2020) struct timeval tv = { .tv_sec = rtcTime, .tv_usec = 0 }; settimeofday(&tv, nullptr); Serial.printf("[main] System clock seeded from RTC: %04d-%02d-%02d %02d:%02d:%02d\n", rtcDt.date.year, rtcDt.date.month, rtcDt.date.date, rtcDt.time.hours, rtcDt.time.minutes, rtcDt.time.seconds); } else { Serial.println("[main] RTC has no valid time — will sync from NTP after WiFi"); } } // Initialize core subsystems needed for both wake paths powerManager.begin(); settingsManager.begin(); displayManager.begin(powerManager); // Check if we woke from a timed deep sleep (persisted flag in NVS) bool timerWoke = PowerManager::wasTimedSleepWake(); if (timerWoke) { Serial.println("[main] Woke from timed deep sleep"); // Escape hatch: hold play/pause button during boot to stay awake pinMode(PIN_BTN_DOWN, INPUT_PULLUP); delay(50); bool pauseHeld = (digitalRead(PIN_BTN_DOWN) == LOW); if (!pauseHeld) { // Minimal cycle: show photo and go back to sleep timerWakeCycle(); // timerWakeCycle() calls enterDeepSleep — should not return return; } // Escape hatch triggered — clear the flag and enter full interactive mode PowerManager::clearTimedSleepFlag(); Serial.println("[main] Play/pause held — entering full interactive mode"); } // Full interactive boot path // Create state mutex stateMutex = xSemaphoreCreateMutex(); // Initialize remaining subsystems wifiManager.begin(settingsManager); buttonHandler.begin(powerManager, settingsManager); if (!wifiManager.isAPMode()) { // Station mode — set up Immich and web server Settings s = settingsManager.get(); immichClient.begin(s.immich_url, s.immich_key); photoQueue.begin(immichClient, settingsManager); } // Start web server (works in both AP and station modes) webServer.begin(settingsManager, immichClient, powerManager, wifiManager); webServer.setActionCallback(webActionCallback); // Initialize wake log (LittleFS already mounted by web server) wakeLogBegin(); // Single display update after all init is complete if (wifiManager.isAPMode()) { displayManager.showSetupScreen(); } else { // Skip "Loading photos" screen — the display task will show the first photo // directly, avoiding a redundant 17s e-ink refresh cycle on boot. // Create display task on Core 1 xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1, &displayTaskHandle, 1); // Enable light sleep only in station mode (not supported in AP mode) powerManager.enableLightSleep(); } Serial.println("[main] Boot complete"); } void loop() { M5.update(); powerManager.updateBatteryLED(); // Handle button events ButtonEvent event = buttonHandler.poll(); switch (event) { case ButtonEvent::NextPhoto: if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) { refreshRequested = true; xSemaphoreGive(stateMutex); } break; case ButtonEvent::RandomPhoto: if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) { randomRequested = true; xSemaphoreGive(stateMutex); } break; case ButtonEvent::PlayPause: if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) { slideshowPlaying = !slideshowPlaying; Serial.printf("[main] Slideshow: %s\n", slideshowPlaying ? "playing" : "paused"); xSemaphoreGive(stateMutex); } break; case ButtonEvent::DeepSleep: { Serial.println("[main] Entering timed deep sleep slideshow..."); Settings sleepS = settingsManager.get(); displayManager.showSleepIndicator(sleepS.meta_pos); if (sleepS.show_battery) { uint8_t battPct = powerManager.getBatteryPercent(); displayManager.showBatteryIndicator(battPct, sleepS.meta_pos); } displayManager.refresh(); powerManager.enterDeepSleep(sleepS.interval_min); break; } case ButtonEvent::FactoryReset: break; case ButtonEvent::None: break; default: { ButtonEvent unreachable = event; (void)unreachable; break; } } delay(10); }