- Letterbox: extend edge pixels outward with blur + fade to solid color - Portrait: detect and pair portraits side-by-side, fallback to solo - Metadata: add overlay mode (corner badge), time field, custom date/time format strings (strftime), font size 1.5x, region-aware for pairs - Buttons: swap G9/G10 to match physical layout, double-press random button for deep sleep, SLP indicator drawn before sleep - Boot: remove redundant 'Loading photos' screen (saves 17s refresh) Co-authored-by: Cursor <cursoragent@cursor.com>
327 lines
12 KiB
C++
327 lines
12 KiB
C++
#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;
|
|
|
|
// 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 primary 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 = {nullptr, 0, 0, false};
|
|
|
|
if (info.isPortrait) {
|
|
// Try to find a second portrait for side-by-side display
|
|
Serial.println("[display_task] Portrait detected, searching for pair");
|
|
String pairId = photoQueue.findNextPortrait();
|
|
|
|
if (pairId.length() > 0) {
|
|
// Fetch info for second portrait (for metadata)
|
|
AssetInfo info2 = immichClient.fetchAssetInfo(pairId);
|
|
|
|
// Download the second portrait
|
|
uint8_t* jpeg2Buf = nullptr;
|
|
size_t jpeg2Size = 0;
|
|
bool dl2 = immichClient.downloadAsset(
|
|
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
|
|
|
|
if (dl2 && jpeg2Buf != nullptr) {
|
|
Serial.printf("[display_task] Portrait pair: %u KB + %u KB\n",
|
|
(unsigned)(jpegSize / 1024),
|
|
(unsigned)(jpeg2Size / 1024));
|
|
img = imagePipeline.processPortraitPair(
|
|
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
|
|
free(jpeg2Buf);
|
|
|
|
if (img.valid) {
|
|
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);
|
|
}
|
|
displayManager.refresh();
|
|
imagePipeline.freeImage(img);
|
|
displayed = true;
|
|
}
|
|
} else {
|
|
Serial.println("[display_task] Pair download failed, showing solo");
|
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
}
|
|
} else {
|
|
// No pair found — show solo portrait (will get L/R letterbox)
|
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
}
|
|
} else {
|
|
// Normal landscape processing
|
|
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 && !displayed) {
|
|
displayManager.showImage(img);
|
|
if (s.meta_flags != 0) {
|
|
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
|
s.date_fmt, s.time_fmt);
|
|
}
|
|
displayManager.refresh();
|
|
imagePipeline.freeImage(img);
|
|
displayed = true;
|
|
} else if (!img.valid && !displayed) {
|
|
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 {
|
|
// 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 deep sleep...");
|
|
Settings sleepS = settingsManager.get();
|
|
displayManager.showSleepIndicator(sleepS.meta_pos);
|
|
displayManager.refresh();
|
|
powerManager.enterDeepSleep();
|
|
break;
|
|
}
|
|
case ButtonEvent::FactoryReset:
|
|
break;
|
|
case ButtonEvent::None:
|
|
break;
|
|
default: {
|
|
ButtonEvent unreachable = event;
|
|
(void)unreachable;
|
|
break;
|
|
}
|
|
}
|
|
|
|
delay(10);
|
|
}
|