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:
196
src/main.cpp
196
src/main.cpp
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user