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;

View File

@@ -26,6 +26,13 @@ struct RandomAsset {
String dateTime;
};
struct RandomFetchFilter {
std::vector<String> albumIds;
bool favoriteOnly = false;
String takenAfter; // ISO datetime, empty = no filter
String takenBefore; // ISO datetime, empty = no filter
};
class ImmichClient {
public:
void begin(const String& baseUrl, const String& apiKey);
@@ -36,6 +43,7 @@ public:
std::vector<RandomAsset> fetchRandomAssets(int count = 50,
const std::vector<String>& albumIds = {});
std::vector<String> fetchFavoriteAssetIds();
String fetchOneRandomAssetId(const RandomFetchFilter& filter);
AssetInfo fetchAssetInfo(const String& assetId);
bool downloadAsset(const String& assetId, ImageQuality quality,
uint8_t** outBuffer, size_t* outSize);

View File

@@ -2,6 +2,7 @@
#include <M5Unified.h>
#include <ArduinoJson.h>
#include <esp_heap_caps.h>
#include <sys/time.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
@@ -16,6 +17,7 @@
#include "image_pipeline.h"
#include "display_manager.h"
#include "web_server.h"
#include "time_utils.h"
// Global instances
SettingsManager settingsManager;
@@ -353,6 +355,74 @@ void webActionCallback(const String& action) {
}
}
// 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;
}
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle");
@@ -373,10 +443,6 @@ void timerWakeCycle() {
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
displayManager.setTranslucent(s.meta_translucent != 0);
// Fetch assets — respect album selection
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
// Parse selected album IDs from settings
std::vector<String> albumIds;
{
@@ -390,14 +456,49 @@ void timerWakeCycle() {
}
}
std::vector<String> ids = immichClient.fetchRandomAssetIds(10, albumIds);
// Single-fetch with probabilistic filtering and retry
static constexpr int TIMER_WAKE_RETRIES = 5;
bool displayed = false;
bool filterRelaxed = false;
for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) {
AssetInfo info = immichClient.fetchAssetInfo(ids[i]);
for (int attempt = 0; attempt < TIMER_WAKE_RETRIES && !displayed; attempt++) {
RandomFetchFilter filter;
if (filterRelaxed) {
filter.albumIds = albumIds;
} else {
filter = buildTimerFilter(s, albumIds);
}
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;
}
Serial.println("[timer_wake] No assets available");
break;
}
Serial.printf("[timer_wake] 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(ids[i], s.img_quality, &jpegBuf, &jpegSize)) {
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) {
Serial.println("[timer_wake] Download failed, retrying");
continue;
}
@@ -418,6 +519,13 @@ void timerWakeCycle() {
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
// 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");
}
}
@@ -445,6 +553,28 @@ void setup() {
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();

View File

@@ -29,6 +29,7 @@ Settings SettingsManager::get() {
s.show_battery = readU8("show_batt", 1);
s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]");
s.timer_last_date = readString("timer_date", "");
return s;
}
@@ -51,6 +52,7 @@ void SettingsManager::save(const Settings& s) {
writeU8("show_batt", s.show_battery);
writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json);
writeString("timer_date", s.timer_last_date);
Serial.println("[settings] All settings saved");
}

View File

@@ -66,6 +66,9 @@ struct Settings {
uint8_t show_battery; // 1 = show battery indicator on display
uint32_t queue_cursor;
String albums_json;
// Deep sleep chronological cursor (ISO datetime of last shown photo)
String timer_last_date;
};
class SettingsManager {

33
src/time_utils.h Normal file
View File

@@ -0,0 +1,33 @@
#pragma once
#include <Arduino.h>
#include <time.h>
// Returns true if the system clock has been set to a plausible time (year >= 2020).
inline bool hasValidTime() {
time_t now = time(nullptr);
struct tm t;
gmtime_r(&now, &t);
return (t.tm_year + 1900) >= 2020;
}
// Returns the current UTC time as an ISO 8601 string (e.g., "2026-08-04T17:30:00.000Z").
inline String isoNow() {
time_t now = time(nullptr);
struct tm t;
gmtime_r(&now, &t);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
return String(buf);
}
// Returns (now - 30 days) as an ISO 8601 UTC string.
inline String isoNowMinus30d() {
time_t now = time(nullptr);
now -= 30 * 24 * 3600; // Subtract 30 days in seconds
struct tm t;
gmtime_r(&now, &t);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
return String(buf);
}

View File

@@ -1,6 +1,9 @@
#include "wifi_manager.h"
#include <WiFi.h>
#include <ESPmDNS.h>
#include <M5Unified.h>
#include <time.h>
#include <sys/time.h>
#include "config.h"
void WiFiManager::begin(SettingsManager& settings) {
@@ -57,6 +60,7 @@ void WiFiManager::startStation() {
Serial.printf("[wifi] Connected! IP: %s, RSSI: %d\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
WiFi.setSleep(WIFI_PS_MIN_MODEM);
syncNTP();
setupMDNS("papercolor");
return;
}
@@ -88,3 +92,37 @@ bool WiFiManager::attemptConnection(const String& ssid, const String& pass) {
}
return true;
}
void WiFiManager::syncNTP() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
// Wait up to 5 seconds for NTP sync
unsigned long start = millis();
time_t now = 0;
while (now < 1600000000 && (millis() - start) < 5000) {
delay(100);
time(&now);
}
if (now < 1600000000) {
Serial.println("[wifi] NTP sync timed out");
return;
}
// Write synced time to hardware RTC
struct tm t;
gmtime_r(&now, &t);
m5::rtc_datetime_t dt;
dt.date.year = t.tm_year + 1900;
dt.date.month = t.tm_mon + 1;
dt.date.date = t.tm_mday;
dt.time.hours = t.tm_hour;
dt.time.minutes = t.tm_min;
dt.time.seconds = t.tm_sec;
M5.Rtc.setDateTime(dt);
Serial.printf("[wifi] NTP synced: %04d-%02d-%02d %02d:%02d:%02d UTC, written to RTC\n",
dt.date.year, dt.date.month, dt.date.date,
dt.time.hours, dt.time.minutes, dt.time.seconds);
}

View File

@@ -13,6 +13,7 @@ public:
void startAP();
void startStation();
void setupMDNS(const char* hostname);
void syncNTP();
private:
SettingsManager* _settings = nullptr;