feat: deep sleep single-fetch with probabilistic weighting

Refactor timerWakeCycle() to fetch a single random photo per wake
using cycle-mode-aware server-side filtering via Immich's
POST /api/search/random endpoint.

Changes:
- Add fetchOneRandomAssetId() to ImmichClient with support for
  isFavorite, takenAfter, takenBefore filter parameters
- Add timer_last_date NVS field for chronological cursor persistence
- Implement probabilistic filter selection per cycle mode:
  - Random: unfiltered size=1
  - Chronological/ReverseChronological: date cursor in NVS
  - FavoritesWeighted: 66% favorites, 33% unfiltered
  - WeightedChronological: 66% recent (30d), 33% unfiltered
  - WeightedReverseChronological: 66% older (30d), 33% unfiltered
- Add retry loop with filter relaxation on empty results
- Initialize hardware RTC (RX8130CE) firmware-wide:
  - Seed system clock from RTC at boot (immediate time)
  - NTP sync after WiFi connect, write back to RTC
- Add time_utils.h with isoNow(), isoNowMinus30d(), hasValidTime()

Normal mode (PhotoQueue + displayTask) is completely untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-04 20:57:59 -04:00
parent 679e9e1721
commit aedb489c2e
8 changed files with 285 additions and 8 deletions

View File

@@ -211,6 +211,68 @@ std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
return ids;
}
String ImmichClient::fetchOneRandomAssetId(const RandomFetchFilter& filter) {
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = 1;
reqDoc["type"] = "IMAGE";
if (!filter.albumIds.empty()) {
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
for (auto& id : filter.albumIds) arr.add(id);
}
if (filter.favoriteOnly) {
reqDoc["isFavorite"] = true;
}
if (filter.takenAfter.length() > 0) {
reqDoc["takenAfter"] = filter.takenAfter;
}
if (filter.takenBefore.length() > 0) {
reqDoc["takenBefore"] = filter.takenBefore;
}
String body;
serializeJson(reqDoc, body);
Serial.printf("[immich] fetchOne: %s\n", body.c_str());
String response = httpPost(url, body);
if (response.isEmpty()) {
Serial.println("[immich] fetchOne: empty response");
return "";
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchOne JSON error: %s\n", err.c_str());
return "";
}
JsonArray arr = doc.as<JsonArray>();
for (JsonObject asset : arr) {
String id = asset["id"].as<String>();
if (id.length() == 0) continue;
// Skip RAW files
String filename = asset["originalFileName"] | "";
filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
filename.endsWith(".nef") || filename.endsWith(".arw") ||
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
filename.endsWith(".raf") || filename.endsWith(".srw")) {
continue;
}
return id;
}
Serial.println("[immich] fetchOne: no usable asset returned");
return "";
}
AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) {
AssetInfo info;
info.id = assetId;