feat: add deep sleep batch caching, wake log, and log viewer tools

- 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>
This commit is contained in:
2026-08-31 12:59:03 -04:00
parent 531906ca2c
commit 6fd71f332c
15 changed files with 1465 additions and 33 deletions

View File

@@ -1,6 +1,7 @@
#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>
@@ -18,6 +19,7 @@
#include "display_manager.h"
#include "web_server.h"
#include "time_utils.h"
#include "wake_log.h"
// Global instances
SettingsManager settingsManager;
@@ -282,6 +284,13 @@ void displayTask(void* param) {
}
}
// 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);
@@ -423,16 +432,90 @@ static RandomFetchFilter buildTimerFilter(const Settings& s,
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;
}
@@ -456,49 +539,86 @@ void timerWakeCycle() {
}
}
// Single-fetch with probabilistic filtering and retry
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
// --- 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;
for (int attempt = 0; attempt < TIMER_WAKE_RETRIES && !displayed; attempt++) {
RandomFetchFilter filter;
if (filterRelaxed) {
filter.albumIds = albumIds;
} else {
filter = buildTimerFilter(s, albumIds);
}
// 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);
String assetId = immichClient.fetchOneRandomAssetId(filter);
if (assetId.length() == 0) {
if (!filterRelaxed && (filter.favoriteOnly ||
filter.takenAfter.length() > 0 || filter.takenBefore.length() > 0)) {
// Filter returned no results — relax and retry
Serial.println("[timer_wake] Filtered request empty — relaxing filter");
filterRelaxed = true;
// For chronological modes, reset the cursor (wrapped around)
if (s.cycle_mode == CycleMode::Chronological ||
s.cycle_mode == CycleMode::ReverseChronological) {
settingsManager.saveField("timer_date", "");
s.timer_last_date = "";
}
continue;
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);
}
Serial.println("[timer_wake] No assets available");
break;
}
Serial.printf("[timer_wake] Asset: %s (attempt %d)\n",
assetId.c_str(), 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.println("[timer_wake] Download failed, retrying");
Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1);
lastResult = WakeLogResult::FailDownload;
continue;
}
@@ -519,6 +639,7 @@ void timerWakeCycle() {
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
lastResult = WakeLogResult::Ok;
// Update chronological cursor with this photo's date
if (info.dateTime.length() > 0) {
@@ -526,11 +647,19 @@ void timerWakeCycle() {
}
} 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 any photo");
Serial.println("[timer_wake] Failed to display photo");
}
Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
@@ -622,6 +751,9 @@ void setup() {
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();