#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" // Global instances SettingsManager settingsManager; WiFiManager wifiManager; PowerManager powerManager; ButtonHandler buttonHandler; ImmichClient immichClient; PhotoQueue photoQueue; ImagePipeline imagePipeline; DisplayManager displayManager; AppWebServer webServer; // Shared state SemaphoreHandle_t stateMutex; volatile bool slideshowPlaying = true; volatile bool refreshRequested = false; volatile bool randomRequested = false; volatile unsigned long lastRefreshTime = 0; // Task handles TaskHandle_t displayTaskHandle = nullptr; 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()); // Trigger first photo immediately 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(); unsigned long intervalMs = s.interval_min * 60000UL; bool shouldRefresh = false; bool wantsRandom = false; if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { if (refreshRequested) { shouldRefresh = true; refreshRequested = false; } else if (randomRequested) { shouldRefresh = true; wantsRandom = true; randomRequested = false; } else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) { shouldRefresh = true; } xSemaphoreGive(stateMutex); } // Periodic re-sync if (photoQueue.needsResync() && wifiManager.isConnected()) { photoQueue.sync(); } if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) { // Try up to 3 assets in case some fail to decode static constexpr int MAX_RETRIES = 10; bool displayed = false; for (int attempt = 0; attempt < MAX_RETRIES && !displayed; attempt++) { String assetId; if (wantsRandom) { assetId = photoQueue.random(); } else { assetId = photoQueue.next(); } if (assetId.length() == 0) break; Serial.printf("[display_task] Loading asset: %s (attempt %d)\n", assetId.c_str(), attempt + 1); // Fetch asset info for portrait detection and metadata AssetInfo info = immichClient.fetchAssetInfo(assetId); // Download photo uint8_t* jpegBuf = nullptr; size_t jpegSize = 0; bool downloaded = immichClient.downloadAsset( assetId, s.img_quality, &jpegBuf, &jpegSize); if (!downloaded || jpegBuf == nullptr) { Serial.println("[display_task] Download failed, trying next"); continue; } Serial.printf("[display_task] JPEG: %u KB, PSRAM free: %u KB\n", (unsigned)(jpegSize / 1024), (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize); free(jpegBuf); Serial.printf("[display_task] Post-process PSRAM free: %u KB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); if (img.valid) { displayManager.showImage(img); if (s.meta_flags != 0) { displayManager.showMetadata(info, s.meta_flags, s.meta_pos); } imagePipeline.freeImage(img); displayed = true; } else { Serial.println("[display_task] Processing failed, trying next"); } } if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { lastRefreshTime = millis(); xSemaphoreGive(stateMutex); } } // 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; } xSemaphoreGive(stateMutex); } } 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)); // Create state mutex stateMutex = xSemaphoreCreateMutex(); // Initialize subsystems powerManager.begin(); settingsManager.begin(); wifiManager.begin(settingsManager); buttonHandler.begin(powerManager, settingsManager); displayManager.begin(powerManager); 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); // Single display update after all init is complete if (wifiManager.isAPMode()) { displayManager.showSetupScreen(); } else { displayManager.showMessage("Immich Frame", "Loading photos..."); // 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: case ButtonEvent::FactoryReset: break; case ButtonEvent::None: break; default: { ButtonEvent unreachable = event; (void)unreachable; break; } } delay(10); }