Files
immich-frame/src/main.cpp

545 lines
20 KiB
C++
Raw Normal View History

#include <Arduino.h>
#include <M5Unified.h>
#include <esp_heap_caps.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#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;
// 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<PipelineMode>(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
if (photoQueue.needsResync() && wifiManager.isConnected()) {
photoQueue.sync();
}
// --- 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<PipelineMode>(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);
}
}
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<PipelineMode>(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;
}
xSemaphoreGive(stateMutex);
}
}
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle");
Settings s = settingsManager.get();
// Connect WiFi (blocking)
wifiManager.begin(settingsManager);
if (!wifiManager.isConnected()) {
Serial.println("[timer_wake] WiFi failed — going back to sleep");
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<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
displayManager.setTranslucent(s.meta_translucent != 0);
// Fetch a small batch of random assets
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
std::vector<String> ids = immichClient.fetchRandomAssetIds(10);
for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) {
AssetInfo info = immichClient.fetchAssetInfo(ids[i]);
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
if (!immichClient.downloadAsset(ids[i], s.img_quality, &jpegBuf, &jpegSize)) {
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;
}
}
if (!displayed) {
Serial.println("[timer_wake] Failed to display any 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));
// 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);
// 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);
}