- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce network calls and improve battery life (configurable batch size 1-50) - Add wake_log module: persistent CSV log of each photo cycle surviving reboots and deep sleep, with download/clear via web UI - Add browser-based log viewer and log comparison tools (tools/) - Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv, and .DS_Store Co-authored-by: Cursor <cursoragent@cursor.com>
826 lines
30 KiB
C++
826 lines
30 KiB
C++
#include <Arduino.h>
|
|
#include <M5Unified.h>
|
|
#include <ArduinoJson.h>
|
|
#include <WiFi.h>
|
|
#include <esp_heap_caps.h>
|
|
#include <sys/time.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"
|
|
#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<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 (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<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);
|
|
}
|
|
}
|
|
|
|
// 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<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;
|
|
} 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<String>& 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<uint8_t>(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: "<hash>:<id1>,<id2>,..."
|
|
// Returns empty vector if hash doesn't match or string is empty/invalid.
|
|
static std::vector<String> parseCacheIds(const String& raw, uint32_t expectedHash) {
|
|
std::vector<String> 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: "<hash>:<id1>,<id2>,..."
|
|
static String buildCacheString(uint32_t hash, const std::vector<String>& 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<PipelineMode>(s.pipeline_mode));
|
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
|
|
|
// Parse selected album IDs from settings
|
|
std::vector<String> albumIds;
|
|
{
|
|
JsonDocument doc;
|
|
DeserializationError err = deserializeJson(doc, s.albums_json);
|
|
if (!err) {
|
|
JsonArray arr = doc.as<JsonArray>();
|
|
for (JsonVariant v : arr) {
|
|
albumIds.push_back(v.as<String>());
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Batch cache logic ---
|
|
uint32_t cacheHash = timerCacheHash(s);
|
|
String cacheRaw = settingsManager.readField("timer_ids", "");
|
|
std::vector<String> 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);
|
|
}
|