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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
146
src/main.cpp
146
src/main.cpp
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
33
src/time_utils.h
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public:
|
||||
void startAP();
|
||||
void startStation();
|
||||
void setupMDNS(const char* hostname);
|
||||
void syncNTP();
|
||||
|
||||
private:
|
||||
SettingsManager* _settings = nullptr;
|
||||
|
||||
Reference in New Issue
Block a user