diff --git a/src/immich_client.cpp b/src/immich_client.cpp index d598a8b..12db8c0 100644 --- a/src/immich_client.cpp +++ b/src/immich_client.cpp @@ -211,6 +211,68 @@ std::vector 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(); + 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(); + for (JsonObject asset : arr) { + String id = asset["id"].as(); + 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; diff --git a/src/immich_client.h b/src/immich_client.h index b34b2d1..9add4c1 100644 --- a/src/immich_client.h +++ b/src/immich_client.h @@ -26,6 +26,13 @@ struct RandomAsset { String dateTime; }; +struct RandomFetchFilter { + std::vector 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 fetchRandomAssets(int count = 50, const std::vector& albumIds = {}); std::vector fetchFavoriteAssetIds(); + String fetchOneRandomAssetId(const RandomFetchFilter& filter); AssetInfo fetchAssetInfo(const String& assetId); bool downloadAsset(const String& assetId, ImageQuality quality, uint8_t** outBuffer, size_t* outSize); diff --git a/src/main.cpp b/src/main.cpp index 5469df1..2cea962 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -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& 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 albumIds; { @@ -390,14 +456,49 @@ void timerWakeCycle() { } } - std::vector 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(); diff --git a/src/settings.cpp b/src/settings.cpp index 8ee21ec..0bcda68 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -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"); } diff --git a/src/settings.h b/src/settings.h index 8cc6162..7427706 100644 --- a/src/settings.h +++ b/src/settings.h @@ -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 { diff --git a/src/time_utils.h b/src/time_utils.h new file mode 100644 index 0000000..888d51b --- /dev/null +++ b/src/time_utils.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +// 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); +} diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp index 7a6a464..56f6cee 100644 --- a/src/wifi_manager.cpp +++ b/src/wifi_manager.cpp @@ -1,6 +1,9 @@ #include "wifi_manager.h" #include #include +#include +#include +#include #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); +} diff --git a/src/wifi_manager.h b/src/wifi_manager.h index 403f0b7..5c43920 100644 --- a/src/wifi_manager.h +++ b/src/wifi_manager.h @@ -13,6 +13,7 @@ public: void startAP(); void startStation(); void setupMDNS(const char* hostname); + void syncNTP(); private: SettingsManager* _settings = nullptr;