Compare commits

..

10 Commits

Author SHA1 Message Date
1ba6e9fdf6 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>
2026-08-31 12:59:03 -04:00
8ca9a0ae96 fix: call configTime() in minimal WiFi path to initialize DNS resolver
Without configTime(), the ESP32 DNS subsystem isn't properly
initialized after WiFi connect, causing HTTPS hostname resolution
to fail silently in the deep sleep wake path. The fix calls
configTime() fire-and-forget (non-blocking, <1ms) which initializes
DNS without the 5-second NTP wait.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 01:39:17 -04:00
39e240dd35 feat: add Sync Time button to Device page in web UI
Adds a 'Sync Time' button in the Device Actions section that calls
POST /api/action/sync-time to trigger NTP sync and RTC write.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:57:53 -04:00
3051122742 feat: add POST /api/action/sync-time endpoint for manual NTP/RTC sync
Allows triggering NTP sync + hardware RTC write from the web UI,
useful for periodic manual time correction (e.g., every 30 days
or when charging).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:42:25 -04:00
6810f2c7c6 fix: skip NTP sync and mDNS in deep sleep wake path to reduce battery drain
The syncNTP() call blocks up to 5 seconds waiting for NTP response on
every deep sleep wake cycle (every 10 minutes). Combined with mDNS
startup, this added significant active WiFi time per cycle.

Fix: Add 'minimal' flag to WiFiManager::begin()/startStation(). In
the deep sleep wake path, skip NTP sync and mDNS entirely. The system
clock is already seeded from the hardware RTC at boot, which is
accurate enough for 30-day date window calculations.

Normal interactive boot still does full NTP sync + mDNS as before.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:28:00 -04:00
73b8dde686 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>
2026-08-04 20:57:59 -04:00
74e063d958 docs: add design spec for deep sleep single-fetch with probabilistic weighting
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 20:52:48 -04:00
0d1cd3392d feat: wire up album filtering to photo queue
Album selection now controls which photos are displayed.
Uses POST /api/search/random with albumIds filter (Immich v3
compatible) instead of the removed GET /api/albums/{id} assets.

- Add albumIds parameter to fetchRandomAssets/fetchRandomAssetIds
- syncFromAlbums: fetch random assets filtered by selected albums
- syncFromAllPhotos: preserve existing global random behavior
- Album mode reshuffles on exhaustion instead of re-fetching
- 24h resync checks album assetCounts for changes before re-fetch
- requestSync + albums_changed action triggers queue rebuild on save
- timerWakeCycle respects album selection
- Discard stale pre-renders on album change

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 22:46:17 -04:00
7d260a452a feat: pre-render next photo for instant slideshow transitions
Refactor displayTask() into two phases: display and pre-render.
After showing a photo, the next one is immediately downloaded,
decoded, and dithered into a pending framebuffer (~240KB PSRAM).
When the slideshow timer fires, the EPD refresh starts instantly
without the previous 2-5s pipeline delay.

- Add PendingPhoto struct with settings hash for staleness guard
- Extract displayPhoto() and prepareNextPhoto() helpers
- Manual next/random discards pre-render and goes inline
- Falls back to inline pipeline if pre-render unavailable
- No changes to ImagePipeline, DisplayManager, or other modules

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:45:17 -04:00
a0f1b9917e feat: add 2m, 10m presets and custom duration for slideshow interval
Presets are now [1, 2, 5, 10, 15, 30, 60] minutes. A 'Custom' button
reveals a number input clamped to 1–60 minutes. Backend also clamps
the received value with constrain(v, 1, 60) for safety.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 21:14:19 -04:00
21 changed files with 2308 additions and 170 deletions

6
.gitignore vendored
View File

@@ -1,2 +1,8 @@
.pio/ .pio/
.superpowers/ .superpowers/
.cursor/
chat-summaries/
wake_*.csv
.DS_Store

View File

@@ -86,14 +86,20 @@ async function renderAlbums(el) {
async function renderSlideshow(el) { async function renderSlideshow(el) {
const settings = await api('/api/settings'); const settings = await api('/api/settings');
const intervals = [1, 5, 15, 30, 60]; const presets = [1, 2, 5, 10, 15, 30, 60];
const isCustom = !presets.includes(settings.interval_min);
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted', const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted',
'Weighted Recent', 'Weighted Oldest']; 'Weighted Recent', 'Weighted Oldest'];
el.innerHTML = ` el.innerHTML = `
<div class="card"> <div class="card">
<h2>Interval</h2> <h2>Interval</h2>
<div class="preset-btns"> <div class="preset-btns">
${intervals.map(i => `<button class="${settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')} ${presets.map(i => `<button class="${!isCustom && settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
<button class="${isCustom?'active':''}" data-interval="custom">Custom</button>
</div>
<div class="field" id="customIntervalRow" style="margin-top:8px;display:${isCustom?'flex':'none'}">
<label>Minutes (1–60):</label>
<input type="number" id="customInterval" min="1" max="60" value="${isCustom?settings.interval_min:5}" style="width:60px;margin-left:8px">
</div> </div>
</div> </div>
<div class="card"> <div class="card">
@@ -104,17 +110,33 @@ async function renderSlideshow(el) {
</select> </select>
</div> </div>
</div> </div>
<div class="card">
<h2>Deep Sleep Batch Size</h2>
<p style="opacity:0.7;font-size:13px;margin-bottom:8px">Photos to pre-fetch per API call in deep sleep mode. Higher = fewer network calls, better battery life.</p>
<div class="field">
<input type="number" id="timerBatch" min="1" max="50" value="${settings.timer_batch_size || 10}" style="width:80px">
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveSlideshow">Save</button></div>`; <div class="btn-group"><button class="btn btn-primary" id="saveSlideshow">Save</button></div>`;
el.querySelectorAll('[data-interval]').forEach(btn => { el.querySelectorAll('[data-interval]').forEach(btn => {
btn.onclick = () => { btn.onclick = () => {
el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active')); el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
document.getElementById('customIntervalRow').style.display =
btn.dataset.interval === 'custom' ? 'flex' : 'none';
}; };
}); });
document.getElementById('saveSlideshow').onclick = async () => { document.getElementById('saveSlideshow').onclick = async () => {
const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5'); const activeBtn = el.querySelector('[data-interval].active');
let interval;
if (activeBtn && activeBtn.dataset.interval === 'custom') {
interval = Math.min(60, Math.max(1, parseInt(document.getElementById('customInterval').value) || 5));
} else {
interval = parseInt(activeBtn?.dataset.interval || '5');
}
const mode = parseInt(document.getElementById('cycleMode').value); const mode = parseInt(document.getElementById('cycleMode').value);
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) }); const batch = Math.min(50, Math.max(1, parseInt(document.getElementById('timerBatch').value) || 10));
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode, timer_batch_size: batch }) });
toast('Slideshow settings saved'); toast('Slideshow settings saved');
}; };
} }
@@ -223,9 +245,18 @@ async function renderDevice(el) {
<div class="card"> <div class="card">
<h2>Device Actions</h2> <h2>Device Actions</h2>
<div class="btn-group"> <div class="btn-group">
<button class="btn" id="syncTime">Sync Time</button>
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button> <button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
<button class="btn btn-danger" onclick="if(confirm('Reboot device?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button> <button class="btn btn-danger" onclick="if(confirm('Reboot device?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
</div> </div>
</div>
<div class="card">
<h2>Wake Log</h2>
<p style="opacity:0.7;margin-bottom:8px">Persistent CSV log of each photo cycle (survives reboots/sleep).</p>
<div class="btn-group">
<button class="btn btn-primary" id="downloadLog">Download Log</button>
<button class="btn btn-danger" id="clearLog">Clear Log</button>
</div>
</div>`; </div>`;
document.getElementById('saveImmich').onclick = async () => { document.getElementById('saveImmich').onclick = async () => {
@@ -249,6 +280,25 @@ async function renderDevice(el) {
status.style.color = '#c62828'; status.style.color = '#c62828';
} }
}; };
document.getElementById('syncTime').onclick = async () => {
toast('Syncing...');
const result = await api('/api/action/sync-time', { method: 'POST' });
toast(result.msg || 'Time synced');
};
document.getElementById('downloadLog').onclick = () => {
const a = document.createElement('a');
a.href = '/api/log';
a.download = 'wake_log.csv';
a.click();
};
document.getElementById('clearLog').onclick = async () => {
if (!confirm('Clear the wake log?')) return;
const result = await api('/api/log/clear', { method: 'POST' });
toast(result.msg || 'Log cleared');
};
} }
async function renderFirmware(el) { async function renderFirmware(el) {

View File

@@ -0,0 +1,113 @@
# Deep Sleep Single-Fetch with Probabilistic Weighting
**Date**: 2026-08-04
**Status**: Approved
## Overview
Refactor the deep sleep wake path (`timerWakeCycle`) to fetch a single random photo per wake using probabilistic server-side filtering based on `cycle_mode`. Replaces the current 10-asset batch fetch with a leaner single-call approach that honors weighting settings while minimizing API calls and battery consumption.
## Motivation
In deep sleep slideshow mode, buttons don't respond and there's no photo queue. The device simply wakes, shows one photo, and sleeps again. Fetching 10 assets and picking one wastes HTTP round-trips, bandwidth, and battery. A single `POST /api/search/random` call with `size=1` and appropriate filters is sufficient.
## Immich API Capabilities
The `POST /api/search/random` endpoint (Immich v3) supports these filters relevant to our use case:
- `size` — number of results
- `type` — media type (IMAGE)
- `albumIds` — array of album UUIDs
- `isFavorite` — boolean filter
- `takenAfter` — ISO 8601 datetime string
- `takenBefore` — ISO 8601 datetime string
These server-side filters allow meaningful weighting without fetching large batches.
## Design
### Filter Selection per Cycle Mode
| Mode | Filter applied | Probability | Fallback |
|------|---------------|-------------|----------|
| Random | No extra filter | 100% | — |
| Chronological | `takenAfter=last_shown_date` | 100% | Reset date to epoch |
| ReverseChronological | `takenBefore=last_shown_date` | 100% | Reset date to "now" |
| FavoritesWeighted | `isFavorite=true` | 66% | Drop `isFavorite` |
| FavoritesWeighted | No extra filter | 33% | — |
| WeightedChronological | `takenAfter=(now - 30 days)` | 66% | Drop date filter |
| WeightedChronological | No extra filter | 33% | — |
| WeightedReverseChronological | `takenBefore=(now - 30 days)` | 66% | Drop date filter |
| WeightedReverseChronological | No extra filter | 33% | — |
Album IDs from settings are always included alongside any other filter.
### Retry Logic
```
attempt = 0
while (!displayed && attempt < MAX_RETRIES):
1. Choose filter based on cycle_mode + probability roll
2. POST /api/search/random (size=1, filter + albumIds)
3. If empty response:
- If using a filter: retry without filter (relax)
- If already unfiltered: give up (no photos available)
4. Download + process the asset
5. If download/process fails: increment attempt, loop
6. If success: display, update timer_last_date in NVS, break
```
MAX_RETRIES = 5 (matches current `TIMER_WAKE_RETRIES`).
### NVS Persistence
New Settings field: `timer_last_date` (String, ISO datetime).
- Updated after each successful display in deep sleep mode
- Used as the cursor for Chronological and ReverseChronological modes
- Empty string means "no cursor" (start from beginning/end)
### RTC Initialization (Firmware-Wide)
The PaperColor has a hardware RX8130CE Real-Time Clock (I2C 0x32) that persists across deep sleep. It is not currently used in the firmware.
**At boot (both paths), before WiFi:**
- Seed ESP32 system clock from hardware RTC via `M5.Rtc.getDateTime()` + `settimeofday()`
**After WiFi connects (both paths):**
- Sync system clock from NTP via `configTime(0, 0, "pool.ntp.org")`
- Write corrected time back to hardware RTC via `M5.Rtc.setDateTime()`
**Benefits:**
- Deep sleep date filters are reliable
- Normal mode metadata overlays (strftime) display correct time
- Future time-dependent features become trivial
### Date Helpers
New `src/time_utils.h`:
- `String isoNow()` — current time as ISO 8601 (e.g., `"2026-08-04T17:30:00.000Z"`)
- `String isoNowMinus30d()` — 30 days ago as ISO 8601
- `bool hasValidTime()` — returns false if year < 2020 (RTC never synced)
## Files Modified
- `src/immich_client.h` — add `RandomFetchFilter` struct + `fetchOneRandomAssetId()` declaration
- `src/immich_client.cpp` — implement `fetchOneRandomAssetId()`
- `src/settings.h` — add `timer_last_date` field
- `src/settings.cpp` — add NVS read/write for `timer_date`
- `src/main.cpp` — refactor `timerWakeCycle()`, seed system clock from RTC at boot
- `src/wifi_manager.cpp` — add NTP sync after WiFi connect, write back to hardware RTC
- New: `src/time_utils.h` — ISO date helpers
## Normal Mode Impact
- **Slideshow logic**: Untouched. Normal mode still uses `PhotoQueue` with `fetchRandomAssets(50)`.
- **RTC/NTP sync**: Applied to both modes as a general firmware improvement.
## Edge Cases
- No photos match filter: fallback to unfiltered, then give up
- RTC never synced (first boot or battery drained): detect year < 2020, fall back to random for weighted modes
- Chronological cursor exhausted: when `takenAfter=cursor` returns empty, reset cursor to "" (wraps around)
- Album has no favorites: `isFavorite=true` + albumIds returns empty, fallback drops `isFavorite`

View File

@@ -40,6 +40,7 @@
#define DEFAULT_LED_BRIGHTNESS 50 #define DEFAULT_LED_BRIGHTNESS 50
#define DEFAULT_PIPELINE_MODE 0 // 0=dynamic, 1=balanced, 2=none #define DEFAULT_PIPELINE_MODE 0 // 0=dynamic, 1=balanced, 2=none
#define DEFAULT_DITHER_NOISE 0 // 1=error scatter ON, 0=OFF (driver dither fix makes this unnecessary) #define DEFAULT_DITHER_NOISE 0 // 1=error scatter ON, 0=OFF (driver dither fix makes this unnecessary)
#define DEFAULT_TIMER_BATCH 10 // Deep sleep batch cache size (1-50)
#define DEFAULT_IMMICH_URL "https://photos.example.com" #define DEFAULT_IMMICH_URL "https://photos.example.com"
// --- Timing --- // --- Timing ---

View File

@@ -87,14 +87,18 @@ std::vector<String> ImmichClient::fetchAlbumAssetIds(const String& albumId) {
return ids; return ids;
} }
std::vector<String> ImmichClient::fetchRandomAssetIds(int count) { std::vector<String> ImmichClient::fetchRandomAssetIds(int count,
const std::vector<String>& albumIds) {
std::vector<String> ids; std::vector<String> ids;
String url = buildUrl("/api/search/random"); String url = buildUrl("/api/search/random");
// Build POST body — filter to images only (excludes videos, audio)
JsonDocument reqDoc; JsonDocument reqDoc;
reqDoc["size"] = count; reqDoc["size"] = count;
reqDoc["type"] = "IMAGE"; reqDoc["type"] = "IMAGE";
if (!albumIds.empty()) {
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
for (auto& id : albumIds) arr.add(id);
}
String body; String body;
serializeJson(reqDoc, body); serializeJson(reqDoc, body);
@@ -118,7 +122,6 @@ std::vector<String> ImmichClient::fetchRandomAssetIds(int count) {
String id = asset["id"].as<String>(); String id = asset["id"].as<String>();
if (id.length() == 0) continue; if (id.length() == 0) continue;
// Filter out RAW/DNG files — their previews work but we prefer actual photos
String filename = asset["originalFileName"] | ""; String filename = asset["originalFileName"] | "";
filename.toLowerCase(); filename.toLowerCase();
if (filename.endsWith(".dng") || filename.endsWith(".raw") || if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
@@ -133,18 +136,23 @@ std::vector<String> ImmichClient::fetchRandomAssetIds(int count) {
ids.push_back(id); ids.push_back(id);
} }
Serial.printf("[immich] Fetched %d random assets (skipped %d RAW)\n", Serial.printf("[immich] Fetched %d random assets%s (skipped %d RAW)\n",
ids.size(), skippedRaw); ids.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
return ids; return ids;
} }
std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count) { std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count,
const std::vector<String>& albumIds) {
std::vector<RandomAsset> assets; std::vector<RandomAsset> assets;
String url = buildUrl("/api/search/random"); String url = buildUrl("/api/search/random");
JsonDocument reqDoc; JsonDocument reqDoc;
reqDoc["size"] = count; reqDoc["size"] = count;
reqDoc["type"] = "IMAGE"; reqDoc["type"] = "IMAGE";
if (!albumIds.empty()) {
JsonArray albumArr = reqDoc["albumIds"].to<JsonArray>();
for (auto& aid : albumIds) albumArr.add(aid);
}
String body; String body;
serializeJson(reqDoc, body); serializeJson(reqDoc, body);
@@ -178,8 +186,8 @@ std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count) {
assets.push_back(ra); assets.push_back(ra);
} }
Serial.printf("[immich] Fetched %d random assets with dates (skipped %d RAW)\n", Serial.printf("[immich] Fetched %d random assets with dates%s (skipped %d RAW)\n",
assets.size(), skippedRaw); assets.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
return assets; return assets;
} }
@@ -203,6 +211,131 @@ std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
return ids; 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 "";
}
std::vector<String> ImmichClient::fetchFilteredBatch(const RandomFetchFilter& filter, int count) {
std::vector<String> results;
String url = buildUrl("/api/search/random");
JsonDocument reqDoc;
reqDoc["size"] = count;
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] fetchBatch(%d): %s\n", count, body.c_str());
String response = httpPost(url, body);
if (response.isEmpty()) {
Serial.println("[immich] fetchBatch: empty response");
return results;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, response);
if (err) {
Serial.printf("[immich] fetchBatch JSON error: %s\n", err.c_str());
return results;
}
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;
}
results.push_back(id);
}
Serial.printf("[immich] fetchBatch: got %d usable assets\n", (int)results.size());
return results;
}
AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) { AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) {
AssetInfo info; AssetInfo info;
info.id = assetId; info.id = assetId;

View File

@@ -26,14 +26,25 @@ struct RandomAsset {
String dateTime; 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 { class ImmichClient {
public: public:
void begin(const String& baseUrl, const String& apiKey); void begin(const String& baseUrl, const String& apiKey);
std::vector<AlbumInfo> fetchAlbums(); std::vector<AlbumInfo> fetchAlbums();
std::vector<String> fetchAlbumAssetIds(const String& albumId); std::vector<String> fetchAlbumAssetIds(const String& albumId);
std::vector<String> fetchRandomAssetIds(int count = 50); std::vector<String> fetchRandomAssetIds(int count = 50,
std::vector<RandomAsset> fetchRandomAssets(int count = 50); const std::vector<String>& albumIds = {});
std::vector<RandomAsset> fetchRandomAssets(int count = 50,
const std::vector<String>& albumIds = {});
std::vector<String> fetchFavoriteAssetIds(); std::vector<String> fetchFavoriteAssetIds();
String fetchOneRandomAssetId(const RandomFetchFilter& filter);
std::vector<String> fetchFilteredBatch(const RandomFetchFilter& filter, int count);
AssetInfo fetchAssetInfo(const String& assetId); AssetInfo fetchAssetInfo(const String& assetId);
bool downloadAsset(const String& assetId, ImageQuality quality, bool downloadAsset(const String& assetId, ImageQuality quality,
uint8_t** outBuffer, size_t* outSize); uint8_t** outBuffer, size_t* outSize);

View File

@@ -1,6 +1,9 @@
#include <Arduino.h> #include <Arduino.h>
#include <M5Unified.h> #include <M5Unified.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#include <sys/time.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
@@ -15,6 +18,8 @@
#include "image_pipeline.h" #include "image_pipeline.h"
#include "display_manager.h" #include "display_manager.h"
#include "web_server.h" #include "web_server.h"
#include "time_utils.h"
#include "wake_log.h"
// Global instances // Global instances
SettingsManager settingsManager; SettingsManager settingsManager;
@@ -27,129 +32,40 @@ ImagePipeline imagePipeline;
DisplayManager displayManager; DisplayManager displayManager;
AppWebServer webServer; AppWebServer webServer;
// Pre-rendered photo ready for instant display on next slideshow tick
struct PendingPhoto {
ProcessedImage img;
AssetInfo info;
AssetInfo info2;
bool isPair;
bool ready;
uint32_t settingsHash;
};
static uint32_t computeSettingsHash(const Settings& s) {
return (uint32_t)s.pipeline_mode
| ((uint32_t)s.dither_noise << 8)
| ((uint32_t)s.img_quality << 16);
}
// Shared state // Shared state
SemaphoreHandle_t stateMutex; SemaphoreHandle_t stateMutex;
volatile bool slideshowPlaying = true; volatile bool slideshowPlaying = true;
volatile bool refreshRequested = false; volatile bool refreshRequested = false;
volatile bool randomRequested = false; volatile bool randomRequested = false;
volatile unsigned long lastRefreshTime = 0; volatile unsigned long lastRefreshTime = 0;
PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
// Task handles // Task handles
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
void displayTask(void* param) { // Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer.
Serial.println("[display_task] Started on Core 1"); // Callers provide the image, metadata info, and current settings. Returns true if displayed.
static bool displayPhoto(ProcessedImage& img, const AssetInfo& info, const AssetInfo& info2,
bool isPair, const Settings& s) {
if (!img.valid) return false;
// Initial sync if (isPair) {
if (wifiManager.isConnected()) {
Settings s = settingsManager.get();
immichClient.begin(s.immich_url, s.immich_key);
photoQueue.begin(immichClient, settingsManager);
if (photoQueue.sync()) {
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
// Trigger first photo immediately
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
refreshRequested = true;
xSemaphoreGive(stateMutex);
}
} else {
displayManager.showMessage("No Photos", "Select albums in web UI");
}
}
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
displayManager.setTranslucent(s.meta_translucent != 0);
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
bool wantsRandom = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (refreshRequested) {
shouldRefresh = true;
refreshRequested = false;
} else if (randomRequested) {
shouldRefresh = true;
wantsRandom = true;
randomRequested = false;
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
shouldRefresh = true;
}
xSemaphoreGive(stateMutex);
}
// Periodic re-sync
if (photoQueue.needsResync() && wifiManager.isConnected()) {
photoQueue.sync();
}
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
// Try up to 3 assets in case some fail to decode
static constexpr int MAX_RETRIES = 10;
bool displayed = false;
for (int attempt = 0; attempt < MAX_RETRIES && !displayed; attempt++) {
String assetId;
if (wantsRandom) {
assetId = photoQueue.random();
} else {
assetId = photoQueue.next();
}
if (assetId.length() == 0) break;
Serial.printf("[display_task] Loading asset: %s (attempt %d)\n",
assetId.c_str(), attempt + 1);
// Fetch asset info for portrait detection and metadata
AssetInfo info = immichClient.fetchAssetInfo(assetId);
// Download primary photo
uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0;
bool downloaded = immichClient.downloadAsset(
assetId, s.img_quality, &jpegBuf, &jpegSize);
if (!downloaded || jpegBuf == nullptr) {
Serial.println("[display_task] Download failed, trying next");
continue;
}
Serial.printf("[display_task] JPEG: %u KB, PSRAM free: %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
ProcessedImage img = {nullptr, 0, 0, false};
if (info.isPortrait) {
// Try to find a second portrait for side-by-side display
Serial.println("[display_task] Portrait detected, searching for pair");
String pairId = photoQueue.findNextPortrait();
if (pairId.length() > 0) {
// Fetch info for second portrait (for metadata)
AssetInfo info2 = immichClient.fetchAssetInfo(pairId);
// Download the second portrait
uint8_t* jpeg2Buf = nullptr;
size_t jpeg2Size = 0;
bool dl2 = immichClient.downloadAsset(
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
if (dl2 && jpeg2Buf != nullptr) {
Serial.printf("[display_task] Portrait pair: %u KB + %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(jpeg2Size / 1024));
img = imagePipeline.processPortraitPair(
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
free(jpeg2Buf);
if (img.valid) {
uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2; uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
uint16_t rightX = halfW + PORTRAIT_GAP_PX; uint16_t rightX = halfW + PORTRAIT_GAP_PX;
displayManager.showImage(img); displayManager.showImage(img);
@@ -163,54 +79,266 @@ void displayTask(void* param) {
rightX, 0, halfW, DISPLAY_HEIGHT, rightX, 0, halfW, DISPLAY_HEIGHT,
s.date_fmt, s.time_fmt); s.date_fmt, s.time_fmt);
} }
if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos);
}
displayManager.refresh();
imagePipeline.freeImage(img);
displayed = true;
}
} else { } else {
Serial.println("[display_task] Pair download failed, showing solo");
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
// No pair found — show solo portrait (will get L/R letterbox)
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
// Normal landscape processing
img = imagePipeline.process(jpegBuf, jpegSize);
}
free(jpegBuf);
Serial.printf("[display_task] Post-process PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
if (img.valid && !displayed) {
displayManager.showImage(img); displayManager.showImage(img);
if (s.meta_flags != 0) { if (s.meta_flags != 0) {
displayManager.showMetadata(info, s.meta_flags, s.meta_pos, displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
s.date_fmt, s.time_fmt); s.date_fmt, s.time_fmt);
} }
}
if (s.show_battery) { if (s.show_battery) {
uint8_t battPct = powerManager.getBatteryPercent(); uint8_t battPct = powerManager.getBatteryPercent();
displayManager.showBatteryIndicator(battPct, s.meta_pos); displayManager.showBatteryIndicator(battPct, s.meta_pos);
} }
displayManager.refresh(); displayManager.refresh();
imagePipeline.freeImage(img); imagePipeline.freeImage(img);
displayed = true; return true;
} else if (!img.valid && !displayed) {
Serial.println("[display_task] Processing failed, trying next");
} }
// Fetch, download, decode, and dither one photo (or portrait pair) from the queue.
// On success, fills out the PendingPhoto fields and returns true.
// On failure after MAX_RETRIES attempts, returns false with pending.ready untouched.
static bool prepareNextPhoto(PendingPhoto& out, const Settings& s, bool wantsRandom) {
static constexpr int MAX_RETRIES = 10;
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
String assetId = wantsRandom ? photoQueue.random() : photoQueue.next();
if (assetId.length() == 0) break;
Serial.printf("[prepare] Loading 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(assetId, s.img_quality, &jpegBuf, &jpegSize)
|| jpegBuf == nullptr) {
Serial.println("[prepare] Download failed, trying next");
continue;
}
Serial.printf("[prepare] JPEG: %u KB, PSRAM free: %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
ProcessedImage img = {nullptr, 0, 0, false};
AssetInfo info2 = {};
bool isPair = false;
if (info.isPortrait) {
Serial.println("[prepare] Portrait detected, searching for pair");
String pairId = photoQueue.findNextPortrait();
if (pairId.length() > 0) {
info2 = immichClient.fetchAssetInfo(pairId);
uint8_t* jpeg2Buf = nullptr;
size_t jpeg2Size = 0;
bool dl2 = immichClient.downloadAsset(
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
if (dl2 && jpeg2Buf != nullptr) {
Serial.printf("[prepare] Portrait pair: %u KB + %u KB\n",
(unsigned)(jpegSize / 1024),
(unsigned)(jpeg2Size / 1024));
img = imagePipeline.processPortraitPair(
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
free(jpeg2Buf);
isPair = img.valid;
if (!img.valid) {
Serial.println("[prepare] Pair processing failed, showing solo");
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
Serial.println("[prepare] Pair download failed, showing solo");
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
} else {
img = imagePipeline.process(jpegBuf, jpegSize);
}
free(jpegBuf);
Serial.printf("[prepare] Post-process PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
if (img.valid) {
out.img = img;
out.info = info;
out.info2 = info2;
out.isPair = isPair;
out.settingsHash = computeSettingsHash(s);
out.ready = true;
return true;
}
Serial.println("[prepare] Processing failed, trying next");
}
return false;
}
// Discard a pre-rendered pending photo, freeing its framebuffer.
static void discardPending(PendingPhoto& p) {
if (p.ready && p.img.valid) {
Serial.println("[prerender] Discarding stale pending image");
imagePipeline.freeImage(p.img);
}
p.ready = false;
}
void displayTask(void* param) {
Serial.println("[display_task] Started on Core 1");
// Initial sync
if (wifiManager.isConnected()) {
Settings s = settingsManager.get();
immichClient.begin(s.immich_url, s.immich_key);
photoQueue.begin(immichClient, settingsManager);
if (photoQueue.sync()) {
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
refreshRequested = true;
xSemaphoreGive(stateMutex);
}
} else {
displayManager.showMessage("No Photos", "Select albums in web UI");
}
}
while (true) {
unsigned long now = millis();
Settings s = settingsManager.get();
displayManager.setTranslucent(s.meta_translucent != 0);
unsigned long intervalMs = s.interval_min * 60000UL;
bool shouldRefresh = false;
bool wantsRandom = false;
bool isManual = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
if (refreshRequested) {
shouldRefresh = true;
isManual = true;
refreshRequested = false;
} else if (randomRequested) {
shouldRefresh = true;
wantsRandom = true;
isManual = true;
randomRequested = false;
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
shouldRefresh = true;
}
xSemaphoreGive(stateMutex);
}
// Periodic re-sync (includes album change requests)
if (photoQueue.needsResync() && wifiManager.isConnected()) {
photoQueue.sync();
discardPending(pending);
}
// --- Phase 1: Display ---
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool displayed = false;
uint32_t currentHash = computeSettingsHash(s);
if (!isManual && pending.ready && pending.settingsHash == currentHash) {
// Use pre-rendered image — instant display
Serial.println("[display_task] Using pre-rendered image");
displayed = displayPhoto(pending.img, pending.info, pending.info2,
pending.isPair, s);
pending.ready = false;
} else {
// Manual request, stale settings, or no pre-render available — inline pipeline
if (pending.ready) {
if (isManual) {
Serial.println("[display_task] Manual request — discarding pre-render");
} else {
Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n",
pending.settingsHash, currentHash);
}
discardPending(pending);
} else {
Serial.println("[display_task] No pre-render available — inline fallback");
}
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) {
displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info,
inlinePhoto.info2, inlinePhoto.isPair, s);
}
}
// 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) { if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
lastRefreshTime = millis(); lastRefreshTime = millis();
xSemaphoreGive(stateMutex); xSemaphoreGive(stateMutex);
} }
// --- Phase 2: Pre-render next photo ---
if (displayed && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool playing = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
playing = slideshowPlaying;
xSemaphoreGive(stateMutex);
}
if (playing) {
Serial.println("[prerender] Starting pre-render of next photo");
Settings preS = settingsManager.get();
imagePipeline.setPipelineMode(static_cast<PipelineMode>(preS.pipeline_mode));
imagePipeline.setBlueNoiseEnabled(preS.dither_noise != 0);
if (prepareNextPhoto(pending, preS, false)) {
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
} else {
Serial.println("[prerender] Pre-render failed — will fall back to inline");
}
}
}
}
// --- Phase 2 (idle): Pre-render if we don't have one yet ---
if (!pending.ready && photoQueue.size() > 0 && wifiManager.isConnected()) {
bool playing = false;
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
playing = slideshowPlaying;
xSemaphoreGive(stateMutex);
}
if (playing) {
Serial.println("[prerender] No pending image — pre-rendering now");
Settings preS = settingsManager.get();
if (prepareNextPhoto(pending, preS, false)) {
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
} else {
Serial.println("[prerender] Pre-render failed — will retry next loop");
}
}
} }
// Yield — check every second // Yield — check every second
@@ -228,21 +356,166 @@ void webActionCallback(const String& action) {
slideshowPlaying = false; slideshowPlaying = false;
} else if (action == "play") { } else if (action == "play") {
slideshowPlaying = true; slideshowPlaying = true;
} else if (action == "albums_changed") {
photoQueue.requestSync();
refreshRequested = true;
} }
xSemaphoreGive(stateMutex); xSemaphoreGive(stateMutex);
} }
} }
// 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;
}
// 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 // Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
void timerWakeCycle() { void timerWakeCycle() {
Serial.println("[timer_wake] Starting minimal wake cycle"); Serial.println("[timer_wake] Starting minimal wake cycle");
// Initialize wake log early (mounts LittleFS, increments boot counter)
wakeLogBegin();
Settings s = settingsManager.get(); Settings s = settingsManager.get();
// Connect WiFi (blocking) // Connect WiFi (minimal — skip NTP/mDNS to save battery)
wifiManager.begin(settingsManager); unsigned long wifiStart = millis();
wifiManager.begin(settingsManager, true);
uint32_t wifiMs = millis() - wifiStart;
if (!wifiManager.isConnected()) { if (!wifiManager.isConnected()) {
Serial.println("[timer_wake] WiFi failed — going back to sleep"); 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); powerManager.enterDeepSleep(s.interval_min);
return; return;
} }
@@ -253,17 +526,99 @@ void timerWakeCycle() {
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
displayManager.setTranslucent(s.meta_translucent != 0); displayManager.setTranslucent(s.meta_translucent != 0);
// Fetch a small batch of random assets // Parse selected album IDs from settings
static constexpr int TIMER_WAKE_RETRIES = 5; std::vector<String> albumIds;
bool displayed = false; {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, s.albums_json);
if (!err) {
JsonArray arr = doc.as<JsonArray>();
for (JsonVariant v : arr) {
albumIds.push_back(v.as<String>());
}
}
}
std::vector<String> ids = immichClient.fetchRandomAssetIds(10); // --- Batch cache logic ---
for (int i = 0; i < (int)ids.size() && i < TIMER_WAKE_RETRIES && !displayed; i++) { uint32_t cacheHash = timerCacheHash(s);
AssetInfo info = immichClient.fetchAssetInfo(ids[i]); String cacheRaw = settingsManager.readField("timer_ids", "");
std::vector<String> cachedIds = parseCacheIds(cacheRaw, cacheHash);
bool cacheWasEmpty = cachedIds.empty();
bool filterRelaxed = false;
// 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);
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);
}
}
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; uint8_t* jpegBuf = nullptr;
size_t jpegSize = 0; size_t jpegSize = 0;
if (!immichClient.downloadAsset(ids[i], s.img_quality, &jpegBuf, &jpegSize)) { if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) {
Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1);
lastResult = WakeLogResult::FailDownload;
continue; continue;
} }
@@ -284,11 +639,27 @@ void timerWakeCycle() {
displayManager.refresh(); displayManager.refresh();
imagePipeline.freeImage(img); imagePipeline.freeImage(img);
displayed = true; displayed = true;
lastResult = WakeLogResult::Ok;
// 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");
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) { 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); Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
@@ -311,6 +682,28 @@ void setup() {
Serial.printf("[main] Internal RAM: %u KB free\n", Serial.printf("[main] Internal RAM: %u KB free\n",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024)); (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 // Initialize core subsystems needed for both wake paths
powerManager.begin(); powerManager.begin();
settingsManager.begin(); settingsManager.begin();
@@ -358,6 +751,9 @@ void setup() {
webServer.begin(settingsManager, immichClient, powerManager, wifiManager); webServer.begin(settingsManager, immichClient, powerManager, wifiManager);
webServer.setActionCallback(webActionCallback); webServer.setActionCallback(webActionCallback);
// Initialize wake log (LittleFS already mounted by web server)
wakeLogBegin();
// Single display update after all init is complete // Single display update after all init is complete
if (wifiManager.isAPMode()) { if (wifiManager.isAPMode()) {
displayManager.showSetupScreen(); displayManager.showSetupScreen();

View File

@@ -16,11 +16,79 @@ bool PhotoQueue::sync() {
if (_client == nullptr || _settings == nullptr) return false; if (_client == nullptr || _settings == nullptr) return false;
_lastSyncAttempt = millis(); _lastSyncAttempt = millis();
_syncRequested = false;
Settings s = _settings->get(); Settings s = _settings->get();
auto albumIds = getSelectedAlbumIds();
bool success;
if (!albumIds.empty()) {
success = syncFromAlbums(albumIds, s);
} else {
success = syncFromAllPhotos(s);
}
if (!success) {
_syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX);
Serial.printf("[queue] Sync failed, retry in %lus\n", _syncRetryDelay / 1000);
return false;
}
_cursor = 0;
_lastSyncTime = millis();
_syncRetryDelay = SYNC_RETRY_MIN;
Serial.printf("[queue] Synced: %d assets, album_mode=%d, cursor at %d\n",
_queue.size(), _albumMode, _cursor);
return true;
}
bool PhotoQueue::syncFromAlbums(const std::vector<String>& albumIds, const Settings& s) {
Serial.printf("[queue] Syncing from %d selected album(s)\n", albumIds.size());
// Get current album counts for 24h resync change detection
auto albums = _client->fetchAlbums();
std::map<String, int> albumCounts;
for (auto& selId : albumIds) {
for (auto& album : albums) {
if (album.id == selId) {
albumCounts[selId] = album.assetCount;
break;
}
}
}
// Use POST /api/search/random with albumIds filter (Immich v3 compatible).
// The server returns up to `size` random assets from the specified albums.
// For small albums (< TARGET_QUEUE_SIZE), all assets are returned.
auto batch = _client->fetchRandomAssets(
static_cast<int>(TARGET_QUEUE_SIZE), albumIds);
if (batch.empty()) {
Serial.println("[queue] No assets found in selected albums");
return false;
}
std::vector<String> newIds;
std::vector<String> newDates;
for (auto& asset : batch) {
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
newIds.push_back(asset.id);
newDates.push_back(asset.dateTime);
}
Serial.printf("[queue] Album sync: %d assets from server\n", newIds.size());
_queue = newIds;
_albumMode = true;
_lastAlbumCounts = albumCounts;
applyCycleMode(s, newDates);
return true;
}
bool PhotoQueue::syncFromAllPhotos(const Settings& s) {
Serial.printf("[queue] Syncing from all photos (global random)\n");
// Fetch in batches until we have TARGET_QUEUE_SIZE usable, non-duplicate IDs.
// Use fetchRandomAssets() to capture dates alongside IDs.
std::vector<String> newIds; std::vector<String> newIds;
std::vector<String> newDates; std::vector<String> newDates;
int rounds = 0; int rounds = 0;
@@ -37,10 +105,8 @@ bool PhotoQueue::sync() {
for (auto& asset : batch) { for (auto& asset : batch) {
if (newIds.size() >= TARGET_QUEUE_SIZE) break; if (newIds.size() >= TARGET_QUEUE_SIZE) break;
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue; if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue; if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue;
newIds.push_back(asset.id); newIds.push_back(asset.id);
newDates.push_back(asset.dateTime); newDates.push_back(asset.dateTime);
} }
@@ -50,59 +116,68 @@ bool PhotoQueue::sync() {
rounds, newIds.size(), TARGET_QUEUE_SIZE); rounds, newIds.size(), TARGET_QUEUE_SIZE);
} }
if (newIds.empty()) { if (newIds.empty()) return false;
_syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX);
Serial.printf("[queue] Sync failed after %d rounds, retry in %lus\n",
rounds, _syncRetryDelay / 1000);
return false;
}
_queue = newIds; _queue = newIds;
_albumMode = false;
_lastAlbumCounts.clear();
// Apply cycling mode applyCycleMode(s, newDates);
Serial.printf("[queue] All-photos sync: %d assets in %d rounds\n",
_queue.size(), rounds);
return true;
}
void PhotoQueue::applyCycleMode(const Settings& s, const std::vector<String>& dates) {
switch (s.cycle_mode) { switch (s.cycle_mode) {
case CycleMode::Random: case CycleMode::Random:
applyFavoritesWeighting(); applyFavoritesWeighting();
shuffle(); shuffle();
break; break;
case CycleMode::Chronological: case CycleMode::Chronological:
sortChronological(newDates, false); sortChronological(dates, false);
break; break;
case CycleMode::ReverseChronological: case CycleMode::ReverseChronological:
sortChronological(newDates, true); sortChronological(dates, true);
break; break;
case CycleMode::FavoritesWeighted: case CycleMode::FavoritesWeighted:
applyFavoritesWeighting(); applyFavoritesWeighting();
shuffle(); shuffle();
break; break;
case CycleMode::WeightedChronological: case CycleMode::WeightedChronological:
applyRecencyWeighting(newDates, true); applyRecencyWeighting(dates, true);
shuffle(); shuffle();
break; break;
case CycleMode::WeightedReverseChronological: case CycleMode::WeightedReverseChronological:
applyRecencyWeighting(newDates, false); applyRecencyWeighting(dates, false);
shuffle(); shuffle();
break; break;
// No default — compiler warns on unhandled CycleMode via -Wswitch // No default — compiler warns on unhandled CycleMode via -Wswitch
} }
}
// Reset cursor for fresh queue void PhotoQueue::requestSync() {
_cursor = 0; _syncRequested = true;
_lastSyncTime = millis();
_syncRetryDelay = SYNC_RETRY_MIN;
Serial.printf("[queue] Synced: %d assets in %d rounds, cursor at %d\n",
_queue.size(), rounds, _cursor);
return true;
} }
String PhotoQueue::next() { String PhotoQueue::next() {
// Auto-refill when queue is exhausted // Auto-refill when queue is exhausted
if (_queue.empty() || _cursor >= _queue.size()) { if (_queue.empty() || _cursor >= _queue.size()) {
if (_albumMode && !_queue.empty()) {
// Album mode: reshuffle existing queue instead of re-fetching
Serial.println("[queue] Album queue exhausted, reshuffling in place");
Settings s = _settings->get();
// Clear shown history since we're replaying the same set
_shown.clear();
applyCycleMode(s, {});
_cursor = 0;
} else {
// All-photos mode: fetch a new batch
Serial.println("[queue] Queue exhausted, fetching new batch"); Serial.println("[queue] Queue exhausted, fetching new batch");
sync(); sync();
_cursor = 0; _cursor = 0;
} }
}
if (_queue.empty()) return ""; if (_queue.empty()) return "";
@@ -137,6 +212,8 @@ size_t PhotoQueue::size() {
} }
bool PhotoQueue::needsResync() { bool PhotoQueue::needsResync() {
if (_syncRequested) return true;
unsigned long now = millis(); unsigned long now = millis();
// If never synced successfully, use retry backoff // If never synced successfully, use retry backoff
@@ -147,7 +224,44 @@ bool PhotoQueue::needsResync() {
// Normal resync interval // Normal resync interval
unsigned long elapsed = now - _lastSyncTime; unsigned long elapsed = now - _lastSyncTime;
return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL); if (elapsed < (QUEUE_RESYNC_HOURS * 3600000UL)) return false;
// In album mode, check if album sizes actually changed before re-fetching
if (_albumMode && !_lastAlbumCounts.empty()) {
auto albums = _client->fetchAlbums();
auto selectedIds = getSelectedAlbumIds();
bool changed = false;
for (auto& selId : selectedIds) {
int currentCount = 0;
for (auto& album : albums) {
if (album.id == selId) {
currentCount = album.assetCount;
break;
}
}
auto it = _lastAlbumCounts.find(selId);
int lastCount = (it != _lastAlbumCounts.end()) ? it->second : -1;
if (currentCount != lastCount) {
Serial.printf("[queue] Album %s count changed: %d -> %d\n",
selId.c_str(), lastCount, currentCount);
changed = true;
break;
}
}
if (!changed) {
Serial.println("[queue] Album counts unchanged, reshuffling instead of re-fetching");
Settings s = _settings->get();
_shown.clear();
applyCycleMode(s, {});
_cursor = 0;
_lastSyncTime = millis();
return false;
}
}
return true;
} }
String PhotoQueue::findNextPortrait() { String PhotoQueue::findNextPortrait() {
@@ -177,6 +291,11 @@ void PhotoQueue::shuffle() {
void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) { void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) {
if (dates.size() != _queue.size()) { if (dates.size() != _queue.size()) {
// Dates unavailable (e.g. reshuffle after exhaustion) — fall back to shuffle
if (dates.empty()) {
shuffle();
return;
}
Serial.println("[queue] sortChronological: date/queue size mismatch, skipping"); Serial.println("[queue] sortChronological: date/queue size mismatch, skipping");
return; return;
} }
@@ -200,7 +319,11 @@ void PhotoQueue::sortChronological(const std::vector<String>& dates, bool revers
} }
void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) { void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) {
if (dates.size() != _queue.size() || _queue.size() < 3) return; if (dates.size() != _queue.size() || _queue.size() < 3) {
// Dates unavailable — fall back to shuffle
if (dates.empty()) return;
return;
}
// Sort queue by date (oldest first) // Sort queue by date (oldest first)
std::vector<size_t> indices(dates.size()); std::vector<size_t> indices(dates.size());

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include <map>
#include <vector> #include <vector>
#include "settings.h" #include "settings.h"
@@ -10,6 +11,7 @@ class PhotoQueue {
public: public:
void begin(ImmichClient& client, SettingsManager& settings); void begin(ImmichClient& client, SettingsManager& settings);
bool sync(); bool sync();
void requestSync();
String next(); String next();
String random(); String random();
String current(); String current();
@@ -27,6 +29,9 @@ private:
unsigned long _lastSyncTime = 0; unsigned long _lastSyncTime = 0;
unsigned long _lastSyncAttempt = 0; unsigned long _lastSyncAttempt = 0;
unsigned long _syncRetryDelay = 10000; unsigned long _syncRetryDelay = 10000;
volatile bool _syncRequested = false;
bool _albumMode = false;
std::map<String, int> _lastAlbumCounts;
static constexpr unsigned long SYNC_RETRY_MIN = 10000; // 10 seconds static constexpr unsigned long SYNC_RETRY_MIN = 10000; // 10 seconds
static constexpr unsigned long SYNC_RETRY_MAX = 300000; // 5 minutes static constexpr unsigned long SYNC_RETRY_MAX = 300000; // 5 minutes
@@ -34,6 +39,9 @@ private:
static constexpr int MAX_FETCH_ROUNDS = 5; static constexpr int MAX_FETCH_ROUNDS = 5;
static constexpr size_t MAX_SHOWN_HISTORY = 200; static constexpr size_t MAX_SHOWN_HISTORY = 200;
bool syncFromAlbums(const std::vector<String>& albumIds, const Settings& s);
bool syncFromAllPhotos(const Settings& s);
void applyCycleMode(const Settings& s, const std::vector<String>& dates);
void shuffle(); void shuffle();
void sortChronological(const std::vector<String>& dates, bool reverse); void sortChronological(const std::vector<String>& dates, bool reverse);
void applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent); void applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent);

View File

@@ -29,6 +29,8 @@ Settings SettingsManager::get() {
s.show_battery = readU8("show_batt", 1); s.show_battery = readU8("show_batt", 1);
s.queue_cursor = readU32("queue_cursor", 0); s.queue_cursor = readU32("queue_cursor", 0);
s.albums_json = readString("albums_json", "[]"); s.albums_json = readString("albums_json", "[]");
s.timer_last_date = readString("timer_date", "");
s.timer_batch_size = readU8("timer_batch", DEFAULT_TIMER_BATCH);
return s; return s;
} }
@@ -51,6 +53,8 @@ void SettingsManager::save(const Settings& s) {
writeU8("show_batt", s.show_battery); writeU8("show_batt", s.show_battery);
writeU32("queue_cursor", s.queue_cursor); writeU32("queue_cursor", s.queue_cursor);
writeString("albums_json", s.albums_json); writeString("albums_json", s.albums_json);
writeString("timer_date", s.timer_last_date);
writeU8("timer_batch", s.timer_batch_size);
Serial.println("[settings] All settings saved"); Serial.println("[settings] All settings saved");
} }
@@ -66,6 +70,10 @@ void SettingsManager::saveField(const char* key, const char* value) {
writeString(key, String(value)); writeString(key, String(value));
} }
String SettingsManager::readField(const char* key, const char* defaultValue) {
return readString(key, defaultValue);
}
void SettingsManager::factoryReset() { void SettingsManager::factoryReset() {
prefs.clear(); prefs.clear();
Serial.println("[settings] Factory reset — rebooting"); Serial.println("[settings] Factory reset — rebooting");

View File

@@ -66,6 +66,12 @@ struct Settings {
uint8_t show_battery; // 1 = show battery indicator on display uint8_t show_battery; // 1 = show battery indicator on display
uint32_t queue_cursor; uint32_t queue_cursor;
String albums_json; String albums_json;
// Deep sleep chronological cursor (ISO datetime of last shown photo)
String timer_last_date;
// Deep sleep batch cache size
uint8_t timer_batch_size;
}; };
class SettingsManager { class SettingsManager {
@@ -76,6 +82,7 @@ public:
void saveField(const char* key, uint8_t value); void saveField(const char* key, uint8_t value);
void saveField(const char* key, uint32_t value); void saveField(const char* key, uint32_t value);
void saveField(const char* key, const char* value); void saveField(const char* key, const char* value);
String readField(const char* key, const char* defaultValue = "");
void factoryReset(); void factoryReset();
bool isProvisioned(); bool isProvisioned();

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);
}

183
src/wake_log.cpp Normal file
View File

@@ -0,0 +1,183 @@
#include "wake_log.h"
#include <LittleFS.h>
#include <Preferences.h>
#include <esp_heap_caps.h>
#include "time_utils.h"
static const char* LOG_PATH = "/wake_log.csv";
static const char* NVS_LOG_NS = "wake_log";
static constexpr size_t MAX_LOG_SIZE = 200 * 1024; // 200KB cap
static constexpr size_t KEEP_SIZE = 100 * 1024; // Keep last 100KB on truncation
static bool _logReady = false;
static uint32_t _bootCount = 0;
static bool _rtcValidAtBoot = false;
static const char* resultStr(WakeLogResult r) {
switch (r) {
case WakeLogResult::Ok: return "ok";
case WakeLogResult::FailFetch: return "fail_fetch";
case WakeLogResult::FailDownload: return "fail_download";
case WakeLogResult::FailProcess: return "fail_process";
default: {
WakeLogResult unreachable = r;
(void)unreachable;
return "unknown";
}
}
}
void wakeLogBegin() {
// Record RTC validity before anything else might change the clock
_rtcValidAtBoot = hasValidTime();
// Mount LittleFS (idempotent — safe to call if already mounted by web server)
if (!LittleFS.begin(true)) {
Serial.println("[wake_log] LittleFS mount failed — logging disabled");
_logReady = false;
return;
}
// Increment boot counter in NVS (fire-and-forget)
Preferences prefs;
if (prefs.begin(NVS_LOG_NS, false)) {
_bootCount = prefs.getUInt("boot_cnt", 0) + 1;
prefs.putUInt("boot_cnt", _bootCount);
prefs.end();
} else {
Serial.println("[wake_log] NVS open failed — using boot_count=0");
_bootCount = 0;
}
_logReady = true;
Serial.printf("[wake_log] Ready (boot #%u, rtc_valid=%d)\n",
_bootCount, _rtcValidAtBoot ? 1 : 0);
}
// Truncate log file to keep only the last KEEP_SIZE bytes.
// Uses PSRAM buffer. Skips silently on failure.
static void truncateIfNeeded() {
File f = LittleFS.open(LOG_PATH, "r");
if (!f) return;
size_t fileSize = f.size();
if (fileSize <= MAX_LOG_SIZE) {
f.close();
return;
}
Serial.printf("[wake_log] Log at %uKB — truncating to last %uKB\n",
(unsigned)(fileSize / 1024), (unsigned)(KEEP_SIZE / 1024));
// Allocate buffer in PSRAM
uint8_t* buf = (uint8_t*)ps_malloc(KEEP_SIZE);
if (!buf) {
Serial.println("[wake_log] PSRAM alloc failed — skipping truncation");
f.close();
return;
}
// Seek to (fileSize - KEEP_SIZE) and read the tail
f.seek(fileSize - KEEP_SIZE);
size_t bytesRead = f.read(buf, KEEP_SIZE);
f.close();
if (bytesRead == 0) {
free(buf);
return;
}
// Find first newline to avoid a partial line at the start
size_t start = 0;
for (size_t i = 0; i < bytesRead; i++) {
if (buf[i] == '\n') {
start = i + 1;
break;
}
}
// Rewrite the file with just the tail
File out = LittleFS.open(LOG_PATH, "w");
if (!out) {
Serial.println("[wake_log] File rewrite failed — skipping truncation");
free(buf);
return;
}
out.write(buf + start, bytesRead - start);
out.close();
free(buf);
Serial.printf("[wake_log] Truncated to %uKB\n",
(unsigned)((bytesRead - start) / 1024));
}
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
const String& assetId, const String& photoDate,
const String& filter, WakeLogResult result, bool isDeepSleep) {
if (!_logReady) return;
// Read error tracking from NVS (defaults on failure)
uint32_t errorsTotal = 0;
String lastErrorTs = "";
Preferences prefs;
if (prefs.begin(NVS_LOG_NS, false)) {
errorsTotal = prefs.getUInt("err_total", 0);
lastErrorTs = prefs.getString("err_last_ts", "");
// Update error counters if this is a failure
if (result != WakeLogResult::Ok) {
errorsTotal++;
lastErrorTs = hasValidTime() ? isoNow() : "unknown";
prefs.putUInt("err_total", errorsTotal);
prefs.putString("err_last_ts", lastErrorTs);
}
prefs.end();
} else {
Serial.println("[wake_log] NVS read failed — using defaults");
}
// Build CSV line
uint32_t cycleMs = millis();
uint32_t freePsramKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024);
uint32_t freeHeapKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024);
String timestamp = hasValidTime() ? isoNow() : "no_clock";
char line[512];
snprintf(line, sizeof(line),
"%s,%u,%u,%u,%d,%u,%u,%d,%u,%s,%s,%s,%s,%s,%s,%u\n",
timestamp.c_str(),
(unsigned)battPct,
(unsigned)freePsramKb,
(unsigned)freeHeapKb,
wifiRssi,
(unsigned)wifiMs,
(unsigned)cycleMs,
_rtcValidAtBoot ? 1 : 0,
(unsigned)_bootCount,
filter.c_str(),
assetId.c_str(),
photoDate.c_str(),
resultStr(result),
isDeepSleep ? "sleep" : "normal",
lastErrorTs.c_str(),
(unsigned)errorsTotal);
// Append to log file
File f = LittleFS.open(LOG_PATH, "a");
if (!f) {
Serial.println("[wake_log] File open failed — skipping log entry");
return;
}
f.print(line);
f.close();
Serial.printf("[wake_log] Logged: %s %s (%s)\n",
resultStr(result), assetId.c_str(),
isDeepSleep ? "sleep" : "normal");
// Check if truncation is needed
truncateIfNeeded();
}

21
src/wake_log.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
enum class WakeLogResult : uint8_t {
Ok,
FailFetch,
FailDownload,
FailProcess
};
// Initialize the wake log subsystem. Call once early in boot.
// Mounts LittleFS, increments boot counter, records RTC validity.
// Fail-safe: if anything fails, logging becomes a no-op.
void wakeLogBegin();
// Append one CSV line to the persistent log.
// Fail-safe: if filesystem or NVS is unavailable, silently returns.
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
const String& assetId, const String& photoDate,
const String& filter, WakeLogResult result, bool isDeepSleep);

View File

@@ -183,6 +183,28 @@ void AppWebServer::setupAPIRoutes() {
req->send(200, "application/json", response); req->send(200, "application/json", response);
}); });
_server.on("/api/action/sync-time", HTTP_POST,
[this](AsyncWebServerRequest* req) {
_wifi->syncNTP();
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"RTC synced\"}");
});
// Wake log endpoints
_server.on("/api/log", HTTP_GET,
[](AsyncWebServerRequest* req) {
if (LittleFS.exists("/wake_log.csv")) {
req->send(LittleFS, "/wake_log.csv", "text/csv");
} else {
req->send(200, "text/csv", "");
}
});
_server.on("/api/log/clear", HTTP_POST,
[](AsyncWebServerRequest* req) {
LittleFS.remove("/wake_log.csv");
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Log cleared\"}");
});
_server.on("/api/action/reboot", HTTP_POST, _server.on("/api/action/reboot", HTTP_POST,
[](AsyncWebServerRequest* req) { [](AsyncWebServerRequest* req) {
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}"); req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
@@ -272,6 +294,7 @@ void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request,
String albumsJson; String albumsJson;
serializeJson(doc["album_ids"], albumsJson); serializeJson(doc["album_ids"], albumsJson);
_settings->saveField("albums_json", albumsJson.c_str()); _settings->saveField("albums_json", albumsJson.c_str());
if (_actionCb) _actionCb("albums_changed");
request->send(200, "application/json", "{\"ok\":true}"); request->send(200, "application/json", "{\"ok\":true}");
} }
@@ -293,6 +316,7 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
doc["immich_url"] = s.immich_url; doc["immich_url"] = s.immich_url;
doc["immich_key"] = s.immich_key; doc["immich_key"] = s.immich_key;
doc["albums_json"] = s.albums_json; doc["albums_json"] = s.albums_json;
doc["timer_batch_size"] = s.timer_batch_size;
String response; String response;
serializeJson(doc, response); serializeJson(doc, response);
@@ -310,7 +334,10 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
Settings s = _settings->get(); Settings s = _settings->get();
if (!doc["interval_min"].isNull()) s.interval_min = doc["interval_min"]; if (!doc["interval_min"].isNull()) {
uint8_t v = doc["interval_min"];
s.interval_min = constrain(v, 1, 60);
}
if (!doc["cycle_mode"].isNull()) s.cycle_mode = static_cast<CycleMode>((uint8_t)doc["cycle_mode"]); if (!doc["cycle_mode"].isNull()) s.cycle_mode = static_cast<CycleMode>((uint8_t)doc["cycle_mode"]);
if (!doc["img_quality"].isNull()) s.img_quality = static_cast<ImageQuality>((uint8_t)doc["img_quality"]); if (!doc["img_quality"].isNull()) s.img_quality = static_cast<ImageQuality>((uint8_t)doc["img_quality"]);
if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"]; if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"];
@@ -324,6 +351,10 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"]; if (!doc["show_battery"].isNull()) s.show_battery = doc["show_battery"];
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>(); if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as<String>(); if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as<String>();
if (!doc["timer_batch_size"].isNull()) {
uint8_t v = doc["timer_batch_size"];
s.timer_batch_size = constrain(v, 1, 50);
}
_settings->save(s); _settings->save(s);
request->send(200, "application/json", "{\"ok\":true}"); request->send(200, "application/json", "{\"ok\":true}");

View File

@@ -1,9 +1,12 @@
#include "wifi_manager.h" #include "wifi_manager.h"
#include <WiFi.h> #include <WiFi.h>
#include <ESPmDNS.h> #include <ESPmDNS.h>
#include <M5Unified.h>
#include <time.h>
#include <sys/time.h>
#include "config.h" #include "config.h"
void WiFiManager::begin(SettingsManager& settings) { void WiFiManager::begin(SettingsManager& settings, bool minimal) {
_settings = &settings; _settings = &settings;
if (!_settings->isProvisioned()) { if (!_settings->isProvisioned()) {
@@ -12,7 +15,7 @@ void WiFiManager::begin(SettingsManager& settings) {
return; return;
} }
startStation(); startStation(minimal);
} }
bool WiFiManager::isConnected() { bool WiFiManager::isConnected() {
@@ -44,19 +47,30 @@ void WiFiManager::startAP() {
WiFi.softAPIP().toString().c_str()); WiFi.softAPIP().toString().c_str());
} }
void WiFiManager::startStation() { void WiFiManager::startStation(bool minimal) {
Settings s = _settings->get(); Settings s = _settings->get();
WiFi.disconnect(true); WiFi.disconnect(true);
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
_apMode = false; _apMode = false;
Serial.printf("[wifi] Connecting to: %s\n", s.wifi_ssid.c_str()); Serial.printf("[wifi] Connecting to: %s%s\n", s.wifi_ssid.c_str(),
minimal ? " (minimal)" : "");
for (_retryCount = 0; _retryCount < WIFI_MAX_RETRIES; _retryCount++) { for (_retryCount = 0; _retryCount < WIFI_MAX_RETRIES; _retryCount++) {
if (attemptConnection(s.wifi_ssid, s.wifi_pass)) { if (attemptConnection(s.wifi_ssid, s.wifi_pass)) {
Serial.printf("[wifi] Connected! IP: %s, RSSI: %d\n", Serial.printf("[wifi] Connected! IP: %s, RSSI: %d\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI()); WiFi.localIP().toString().c_str(), WiFi.RSSI());
if (minimal) {
// Deep sleep wake path: skip blocking NTP wait and mDNS to
// minimize active time. Still call configTime() to initialize
// the DNS resolver (required for HTTPS hostname resolution).
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
return;
}
WiFi.setSleep(WIFI_PS_MIN_MODEM); WiFi.setSleep(WIFI_PS_MIN_MODEM);
syncNTP();
setupMDNS("papercolor"); setupMDNS("papercolor");
return; return;
} }
@@ -88,3 +102,37 @@ bool WiFiManager::attemptConnection(const String& ssid, const String& pass) {
} }
return true; 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

@@ -5,14 +5,15 @@
class WiFiManager { class WiFiManager {
public: public:
void begin(SettingsManager& settings); void begin(SettingsManager& settings, bool minimal = false);
bool isConnected(); bool isConnected();
bool isAPMode(); bool isAPMode();
String getIP(); String getIP();
int getRSSI(); int getRSSI();
void startAP(); void startAP();
void startStation(); void startStation(bool minimal = false);
void setupMDNS(const char* hostname); void setupMDNS(const char* hostname);
void syncNTP();
private: private:
SettingsManager* _settings = nullptr; SettingsManager* _settings = nullptr;

506
tools/log-compare.html Normal file
View File

@@ -0,0 +1,506 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Battery Comparison</title>
<script src="./vendor/chart.umd.min.js"></script>
<script src="./vendor/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--accent2: #d97706;
--success: #16a34a;
--danger: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
--accent2: #fbbf24;
--success: #4ade80;
--danger: #f87171;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; color: var(--muted); margin-bottom: 20px; }
h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.drop-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 700px) { .drop-row { grid-template-columns: 1fr; } }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.drop-zone.loaded {
border-color: var(--success);
border-style: solid;
}
.drop-zone input { display: none; }
.drop-zone p { color: var(--muted); font-size: 14px; }
.drop-zone p strong { color: var(--text); }
.drop-zone .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; margin-bottom: 8px; }
.drop-zone .label-a { color: var(--accent); }
.drop-zone .label-b { color: var(--accent2); }
.drop-zone .meta { font-size: 12px; color: var(--muted); margin-top: 8px; }
.hidden { display: none !important; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .values { display: flex; gap: 16px; margin-top: 4px; align-items: baseline; }
.stat-card .val-a { font-size: 18px; font-weight: 600; color: var(--accent); }
.stat-card .val-b { font-size: 18px; font-weight: 600; color: var(--accent2); }
.stat-card .val-delta { font-size: 14px; font-weight: 500; }
.stat-card .better { color: var(--success); }
.stat-card .worse { color: var(--danger); }
.controls {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.controls label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 6px; cursor: pointer; }
.controls input[type="checkbox"] { width: 16px; height: 16px; }
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.chart-card canvas { width: 100% !important; height: 280px !important; }
.chart-card.small canvas { height: 180px !important; }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) { .chart-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>PaperColor Battery Comparison</h1>
<h2>Compare battery drain between two wake log captures</h2>
<div class="drop-row">
<div class="drop-zone" id="dropA">
<div class="label label-a">Log A (Baseline)</div>
<p><strong>Drop CSV here</strong> or click to browse</p>
<input type="file" id="fileA" accept=".csv,text/csv">
<div class="meta" id="metaA"></div>
</div>
<div class="drop-zone" id="dropB">
<div class="label label-b">Log B (Comparison)</div>
<p><strong>Drop CSV here</strong> or click to browse</p>
<input type="file" id="fileB" accept=".csv,text/csv">
<div class="meta" id="metaB"></div>
</div>
</div>
<div id="viewer" class="hidden">
<div class="summary" id="summary"></div>
<div class="controls">
<label><input type="checkbox" id="sleepOnly" checked> Sleep mode only (exclude normal/charging)</label>
<label><input type="checkbox" id="showFailures" checked> Show failure events</label>
</div>
<div class="chart-card">
<h3>Battery % over Elapsed Time</h3>
<canvas id="chartBattery"></canvas>
</div>
<div class="chart-grid">
<div class="chart-card small">
<h3>Drain Rate (%/hr, rolling 10-entry window)</h3>
<canvas id="chartDrainRate"></canvas>
</div>
<div class="chart-card small">
<h3>Cumulative Failures</h3>
<canvas id="chartFailures"></canvas>
</div>
</div>
</div>
<script>
let logA = null, logB = null;
let nameA = 'Log A', nameB = 'Log B';
let charts = {};
// --- CSV Parsing ---
function parseCSV(text) {
const lines = text.trim().split('\n').filter(l => l.trim());
return lines.map(line => {
const p = line.split(',');
return {
timestamp: p[0] || '',
battery_pct: parseInt(p[1]) || 0,
free_psram_kb: parseInt(p[2]) || 0,
free_heap_kb: parseInt(p[3]) || 0,
wifi_rssi: parseInt(p[4]) || 0,
wifi_ms: parseInt(p[5]) || 0,
cycle_ms: parseInt(p[6]) || 0,
rtc_valid: p[7] === '1',
boot_count: parseInt(p[8]) || 0,
filter: p[9] || '',
asset_id: p[10] || '',
photo_date: p[11] || '',
result: p[12] || '',
wake_type: p[13] || '',
last_error_ts: p[14] || '',
errors_total: parseInt(p[15]) || 0,
};
}).filter(r => r.timestamp && r.timestamp !== 'no_clock');
}
function elapsedHours(rows) {
if (rows.length === 0) return [];
const t0 = new Date(rows[0].timestamp).getTime();
return rows.map(r => (new Date(r.timestamp).getTime() - t0) / 3600000);
}
function computeStats(rows) {
if (rows.length === 0) return null;
const hours = elapsedHours(rows);
const totalDuration = hours[hours.length - 1];
const startBatt = rows[0].battery_pct;
// Find the minimum battery point (end of actual drain, before any charging tail)
let minBatt = startBatt;
let minIdx = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].battery_pct <= minBatt) {
minBatt = rows[i].battery_pct;
minIdx = i;
}
}
const drainDuration = hours[minIdx];
const drainRate = drainDuration > 0 ? (startBatt - minBatt) / drainDuration : 0;
const failures = rows.slice(0, minIdx + 1).filter(r => r.result !== 'ok').length;
const failRate = (minIdx + 1) > 0 ? (failures / (minIdx + 1) * 100) : 0;
const first = rows[0].timestamp;
const last = rows[rows.length - 1].timestamp;
return { totalDuration, drainDuration, startBatt, minBatt, drainRate, failures, failRate, first, last, count: rows.length, drainEntries: minIdx + 1 };
}
// --- File Input ---
function setupDrop(zoneId, inputId, metaId, slot) {
const zone = document.getElementById(zoneId);
const input = document.getElementById(inputId);
const meta = document.getElementById(metaId);
zone.addEventListener('click', () => input.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length) loadLog(e.dataTransfer.files[0], slot, zone, meta);
});
input.addEventListener('change', e => {
if (e.target.files.length) loadLog(e.target.files[0], slot, zone, meta);
});
}
function loadLog(file, slot, zone, metaEl) {
const reader = new FileReader();
reader.onload = e => {
const rows = parseCSV(e.target.result);
if (rows.length === 0) return;
if (slot === 'A') { logA = rows; nameA = file.name; }
else { logB = rows; nameB = file.name; }
zone.classList.add('loaded');
const stats = computeStats(rows);
metaEl.textContent = `${file.name} | ${rows.length} entries | ${new Date(stats.first).toLocaleDateString()} - ${new Date(stats.last).toLocaleDateString()}`;
if (logA && logB) {
document.getElementById('viewer').classList.remove('hidden');
render();
}
};
reader.readAsText(file);
}
setupDrop('dropA', 'fileA', 'metaA', 'A');
setupDrop('dropB', 'fileB', 'metaB', 'B');
// --- Render ---
document.getElementById('showFailures').addEventListener('change', render);
document.getElementById('sleepOnly').addEventListener('change', render);
function getFilteredLogs() {
const sleepOnly = document.getElementById('sleepOnly').checked;
const a = sleepOnly ? logA.filter(r => r.wake_type === 'sleep') : logA;
const b = sleepOnly ? logB.filter(r => r.wake_type === 'sleep') : logB;
return { a, b };
}
function render() {
if (!logA || !logB) return;
renderSummary();
renderCharts();
}
function renderSummary() {
const { a: filtA, b: filtB } = getFilteredLogs();
const a = computeStats(filtA);
const b = computeStats(filtB);
if (!a || !b) return;
const drainDelta = b.drainRate - a.drainRate;
const drainBetter = drainDelta < 0;
const failDelta = b.failRate - a.failRate;
const failBetter = failDelta < 0;
document.getElementById('summary').innerHTML = `
<div class="stat-card">
<div class="label">Drain Duration (to min battery)</div>
<div class="values">
<span class="val-a">${a.drainDuration.toFixed(1)}h</span>
<span class="val-b">${b.drainDuration.toFixed(1)}h</span>
</div>
</div>
<div class="stat-card">
<div class="label">Battery (start / min)</div>
<div class="values">
<span class="val-a">${a.startBatt}% / ${a.minBatt}%</span>
<span class="val-b">${b.startBatt}% / ${b.minBatt}%</span>
</div>
</div>
<div class="stat-card">
<div class="label">Drain Rate (to min)</div>
<div class="values">
<span class="val-a">${a.drainRate.toFixed(2)}%/hr</span>
<span class="val-b">${b.drainRate.toFixed(2)}%/hr</span>
<span class="val-delta ${drainBetter ? 'better' : 'worse'}">${drainDelta > 0 ? '+' : ''}${drainDelta.toFixed(2)}</span>
</div>
</div>
<div class="stat-card">
<div class="label">Failure Rate (drain period)</div>
<div class="values">
<span class="val-a">${a.failRate.toFixed(1)}%</span>
<span class="val-b">${b.failRate.toFixed(1)}%</span>
<span class="val-delta ${failBetter ? 'better' : 'worse'}">${failDelta > 0 ? '+' : ''}${failDelta.toFixed(1)}%</span>
</div>
</div>
<div class="stat-card">
<div class="label">Failures (drain period)</div>
<div class="values">
<span class="val-a">${a.failures}</span>
<span class="val-b">${b.failures}</span>
</div>
</div>
<div class="stat-card">
<div class="label">Entries (total / drain)</div>
<div class="values">
<span class="val-a">${a.count} / ${a.drainEntries}</span>
<span class="val-b">${b.count} / ${b.drainEntries}</span>
</div>
</div>
`;
}
function renderCharts() {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const { a: filtA, b: filtB } = getFilteredLogs();
const showFail = document.getElementById('showFailures').checked;
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
const accentA = isDark ? '#60a5fa' : '#2563eb';
const accentB = isDark ? '#fbbf24' : '#d97706';
const failColorA = isDark ? '#f87171' : '#dc2626';
const failColorB = isDark ? '#fb923c' : '#ea580c';
const hoursA = elapsedHours(filtA);
const hoursB = elapsedHours(filtB);
// Battery chart datasets
const battA = filtA.map((r, i) => ({ x: hoursA[i], y: r.battery_pct }));
const battB = filtB.map((r, i) => ({ x: hoursB[i], y: r.battery_pct }));
const datasets = [
{ label: nameA, data: battA, borderColor: accentA, backgroundColor: accentA + '44', pointRadius: 1.5, showLine: true, tension: 0.3, borderWidth: 2 },
{ label: nameB, data: battB, borderColor: accentB, backgroundColor: accentB + '44', pointRadius: 1.5, showLine: true, tension: 0.3, borderWidth: 2 },
];
if (showFail) {
const failPtsA = filtA.filter(r => r.result !== 'ok').map(r => {
const idx = filtA.indexOf(r);
return { x: hoursA[idx], y: r.battery_pct };
});
const failPtsB = filtB.filter(r => r.result !== 'ok').map(r => {
const idx = filtB.indexOf(r);
return { x: hoursB[idx], y: r.battery_pct };
});
if (failPtsA.length > 0) {
datasets.push({
label: nameA + ' failures',
data: failPtsA,
borderColor: failColorA,
backgroundColor: failColorA + '88',
pointRadius: 4,
pointStyle: 'crossRot',
showLine: false,
});
}
if (failPtsB.length > 0) {
datasets.push({
label: nameB + ' failures',
data: failPtsB,
borderColor: failColorB,
backgroundColor: failColorB + '88',
pointRadius: 4,
pointStyle: 'crossRot',
showLine: false,
});
}
}
charts.battery = new Chart(document.getElementById('chartBattery'), {
type: 'scatter',
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: { legend: { labels: { color: textColor, font: { size: 12 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor } },
y: { title: { display: true, text: 'Battery %', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor }, min: 0, max: 105 }
}
}
});
// Truncate rows to drain period only (up to min battery, before charging tail)
function drainOnly(rows) {
if (rows.length === 0) return rows;
let minBatt = rows[0].battery_pct;
let minIdx = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].battery_pct <= minBatt) {
minBatt = rows[i].battery_pct;
minIdx = i;
}
}
return rows.slice(0, minIdx + 1);
}
const drainA = drainOnly(filtA);
const drainB = drainOnly(filtB);
const drainHoursA = elapsedHours(drainA);
const drainHoursB = elapsedHours(drainB);
// Drain rate chart (rolling window, drain period only)
const windowSize = 10;
function drainRateSeries(rows, hours) {
const pts = [];
for (let i = windowSize; i < rows.length; i++) {
const dt = hours[i] - hours[i - windowSize];
if (dt <= 0) continue;
const dBatt = rows[i - windowSize].battery_pct - rows[i].battery_pct;
pts.push({ x: hours[i], y: dBatt / dt });
}
return pts;
}
charts.drainRate = new Chart(document.getElementById('chartDrainRate'), {
type: 'scatter',
data: { datasets: [
{ label: nameA, data: drainRateSeries(drainA, drainHoursA), borderColor: accentA, backgroundColor: accentA + '44', pointRadius: 1, showLine: true, tension: 0.4, borderWidth: 1.5 },
{ label: nameB, data: drainRateSeries(drainB, drainHoursB), borderColor: accentB, backgroundColor: accentB + '44', pointRadius: 1, showLine: true, tension: 0.4, borderWidth: 1.5 },
]},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textColor, font: { size: 11 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: '%/hr', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } }
}
}
});
// Cumulative failures chart (drain period only)
function cumulFailSeries(rows, hours) {
const pts = [];
let count = 0;
for (let i = 0; i < rows.length; i++) {
if (rows[i].result !== 'ok') count++;
pts.push({ x: hours[i], y: count });
}
return pts;
}
charts.failures = new Chart(document.getElementById('chartFailures'), {
type: 'scatter',
data: { datasets: [
{ label: nameA, data: cumulFailSeries(drainA, drainHoursA), borderColor: accentA, backgroundColor: accentA + '22', pointRadius: 0, showLine: true, stepped: 'before', borderWidth: 2, fill: true },
{ label: nameB, data: cumulFailSeries(drainB, drainHoursB), borderColor: accentB, backgroundColor: accentB + '22', pointRadius: 0, showLine: true, stepped: 'before', borderWidth: 2, fill: true },
]},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textColor, font: { size: 11 } } } },
scales: {
x: { type: 'linear', title: { display: true, text: 'Elapsed Hours', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: 'Failures', color: textColor }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } }, beginAtZero: true }
}
}
});
}
</script>
</body>
</html>

432
tools/log-viewer.html Normal file
View File

@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Wake Log Viewer</title>
<script src="./vendor/chart.umd.min.js"></script>
<script src="./vendor/chartjs-adapter-date-fns.bundle.min.js"></script>
<style>
:root {
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--accent: #2563eb;
--success: #16a34a;
--danger: #dc2626;
--warn: #d97706;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--card: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--border: #334155;
--accent: #60a5fa;
--success: #4ade80;
--danger: #f87171;
--warn: #fbbf24;
}
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 4px; }
h2 { font-size: 16px; color: var(--muted); margin-bottom: 16px; }
h3 { font-size: 14px; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.drop-zone {
border: 2px dashed var(--border);
border-radius: 12px;
padding: 48px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
margin-bottom: 24px;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.drop-zone input { display: none; }
.drop-zone p { color: var(--muted); font-size: 15px; }
.drop-zone p strong { color: var(--text); }
.hidden { display: none !important; }
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 20px; font-weight: 600; margin-top: 2px; }
.filters {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filters label { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 4px; cursor: pointer; }
.filters input[type="checkbox"] { width: 16px; height: 16px; }
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) { .chart-grid { grid-template-columns: 1fr; } }
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.chart-card canvas { width: 100% !important; height: 200px !important; }
.table-wrap {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
overflow-x: auto;
margin-bottom: 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
white-space: nowrap;
}
th, td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
th {
background: var(--bg);
position: sticky;
top: 0;
cursor: pointer;
user-select: none;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
}
th:hover { color: var(--text); }
th .sort-arrow { margin-left: 4px; font-size: 10px; }
.result-ok { color: var(--success); font-weight: 600; }
.result-fail { color: var(--danger); font-weight: 600; }
.wake-sleep { color: var(--warn); }
.wake-normal { color: var(--accent); }
.id-cell { max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<h1>PaperColor Wake Log Viewer</h1>
<h2>Drop or select a wake_log.csv file to visualize</h2>
<div class="drop-zone" id="dropZone">
<p><strong>Drop wake_log.csv here</strong> or click to browse</p>
<input type="file" id="fileInput" accept=".csv,text/csv">
</div>
<div id="viewer" class="hidden">
<div class="summary" id="summary"></div>
<div class="filters" id="filters">
<label><input type="checkbox" id="filterNormal" checked> Normal</label>
<label><input type="checkbox" id="filterSleep" checked> Sleep</label>
<label><input type="checkbox" id="filterOk" checked> OK</label>
<label><input type="checkbox" id="filterFail" checked> Failures</label>
</div>
<div class="chart-grid" id="charts">
<div class="chart-card"><h3>Battery %</h3><canvas id="chartBattery"></canvas></div>
<div class="chart-card"><h3>WiFi RSSI (dBm)</h3><canvas id="chartRssi"></canvas></div>
<div class="chart-card"><h3>Free Memory (KB)</h3><canvas id="chartMemory"></canvas></div>
<div class="chart-card"><h3>Cycle Duration (ms)</h3><canvas id="chartCycle"></canvas></div>
</div>
<div class="table-wrap">
<table>
<thead id="tableHead"></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
<script>
const COLUMNS = [
'timestamp','battery_pct','free_psram_kb','free_heap_kb','wifi_rssi',
'wifi_ms','cycle_ms','rtc_valid','boot_count','filter',
'asset_id','photo_date','result','wake_type','last_error_ts','errors_total'
];
let allRows = [];
let sortCol = 0;
let sortAsc = false;
let charts = {};
// --- File input ---
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) loadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', e => { if (e.target.files.length) loadFile(e.target.files[0]); });
function loadFile(file) {
const reader = new FileReader();
reader.onload = e => {
const text = e.target.result.trim();
if (!text) return;
parseCSV(text);
document.getElementById('viewer').classList.remove('hidden');
dropZone.classList.add('hidden');
render();
};
reader.readAsText(file);
}
function parseCSV(text) {
const lines = text.split('\n').filter(l => l.trim());
allRows = lines.map(line => {
const parts = line.split(',');
return {
timestamp: parts[0] || '',
battery_pct: parseInt(parts[1]) || 0,
free_psram_kb: parseInt(parts[2]) || 0,
free_heap_kb: parseInt(parts[3]) || 0,
wifi_rssi: parseInt(parts[4]) || 0,
wifi_ms: parseInt(parts[5]) || 0,
cycle_ms: parseInt(parts[6]) || 0,
rtc_valid: parts[7] === '1',
boot_count: parseInt(parts[8]) || 0,
filter: parts[9] || '',
asset_id: parts[10] || '',
photo_date: parts[11] || '',
result: parts[12] || '',
wake_type: parts[13] || '',
last_error_ts: parts[14] || '',
errors_total: parseInt(parts[15]) || 0,
};
});
}
function getFiltered() {
const showNormal = document.getElementById('filterNormal').checked;
const showSleep = document.getElementById('filterSleep').checked;
const showOk = document.getElementById('filterOk').checked;
const showFail = document.getElementById('filterFail').checked;
return allRows.filter(r => {
if (r.wake_type === 'normal' && !showNormal) return false;
if (r.wake_type === 'sleep' && !showSleep) return false;
if (r.result === 'ok' && !showOk) return false;
if (r.result !== 'ok' && !showFail) return false;
return true;
});
}
// --- Filters ---
document.querySelectorAll('.filters input').forEach(el => {
el.addEventListener('change', render);
});
// --- Render ---
function render() {
const rows = getFiltered();
renderSummary(rows);
renderCharts(rows);
renderTable(rows);
}
function renderSummary(rows) {
if (rows.length === 0) {
document.getElementById('summary').innerHTML = '<div class="stat-card"><div class="value">No data</div></div>';
return;
}
const first = rows[0].timestamp;
const last = rows[rows.length - 1].timestamp;
const okCount = rows.filter(r => r.result === 'ok').length;
const successRate = ((okCount / rows.length) * 100).toFixed(1);
const avgBatt = (rows.reduce((s, r) => s + r.battery_pct, 0) / rows.length).toFixed(0);
const rssiVals = rows.map(r => r.wifi_rssi).filter(v => v !== 0);
const minRssi = rssiVals.length ? Math.min(...rssiVals) : 'N/A';
const maxRssi = rssiVals.length ? Math.max(...rssiVals) : 'N/A';
const bootMin = Math.min(...rows.map(r => r.boot_count));
const bootMax = Math.max(...rows.map(r => r.boot_count));
const totalErrors = rows.length > 0 ? rows[rows.length - 1].errors_total : 0;
const formatTs = ts => {
if (!ts || ts === 'no_clock') return ts;
try { return new Date(ts).toLocaleString(); } catch { return ts; }
};
document.getElementById('summary').innerHTML = `
<div class="stat-card"><div class="label">Entries</div><div class="value">${rows.length}</div></div>
<div class="stat-card"><div class="label">Success Rate</div><div class="value">${successRate}%</div></div>
<div class="stat-card"><div class="label">Avg Battery</div><div class="value">${avgBatt}%</div></div>
<div class="stat-card"><div class="label">RSSI Range</div><div class="value">${maxRssi} / ${minRssi} dBm</div></div>
<div class="stat-card"><div class="label">Boot Count</div><div class="value">${bootMin}–${bootMax}</div></div>
<div class="stat-card"><div class="label">Total Errors</div><div class="value">${totalErrors}</div></div>
<div class="stat-card"><div class="label">First Entry</div><div class="value" style="font-size:13px">${formatTs(first)}</div></div>
<div class="stat-card"><div class="label">Last Entry</div><div class="value" style="font-size:13px">${formatTs(last)}</div></div>
`;
}
function makeDatasets(rows, key, label, color) {
const normalPts = [];
const sleepPts = [];
const failPts = [];
rows.forEach(r => {
const pt = { x: new Date(r.timestamp), y: r[key] };
if (r.result !== 'ok') failPts.push(pt);
else if (r.wake_type === 'sleep') sleepPts.push(pt);
else normalPts.push(pt);
});
return [
{ label: label + ' (normal)', data: normalPts, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (sleep)', data: sleepPts, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: label + ' (fail)', data: failPts, borderColor: '#dc2626', backgroundColor: '#dc262688', pointRadius: 5, pointStyle: 'crossRot', showLine: false },
];
}
function chartOpts(yLabel) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: { legend: { display: true, labels: { boxWidth: 10, font: { size: 11 }, color: textColor } } },
scales: {
x: { type: 'time', time: { tooltipFormat: 'MMM d, HH:mm' }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } },
y: { title: { display: true, text: yLabel, color: textColor, font: { size: 11 } }, grid: { color: gridColor }, ticks: { color: textColor, font: { size: 10 } } }
}
};
}
function renderCharts(rows) {
Object.values(charts).forEach(c => c.destroy());
charts = {};
const validRows = rows.filter(r => r.timestamp && r.timestamp !== 'no_clock');
charts.battery = new Chart(document.getElementById('chartBattery'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'battery_pct', 'Battery', '#2563eb') },
options: chartOpts('Battery %')
});
charts.rssi = new Chart(document.getElementById('chartRssi'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'wifi_rssi', 'RSSI', '#2563eb') },
options: chartOpts('dBm')
});
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const gridColor = isDark ? '#334155' : '#e2e8f0';
const textColor = isDark ? '#94a3b8' : '#64748b';
const memNormal = [], memSleep = [];
const heapNormal = [], heapSleep = [];
validRows.forEach(r => {
const pt1 = { x: new Date(r.timestamp), y: r.free_psram_kb };
const pt2 = { x: new Date(r.timestamp), y: r.free_heap_kb };
if (r.wake_type === 'sleep') { memSleep.push(pt1); heapSleep.push(pt2); }
else { memNormal.push(pt1); heapNormal.push(pt2); }
});
charts.memory = new Chart(document.getElementById('chartMemory'), {
type: 'scatter',
data: { datasets: [
{ label: 'PSRAM (normal)', data: memNormal, borderColor: '#2563eb', backgroundColor: '#2563eb88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'PSRAM (sleep)', data: memSleep, borderColor: '#d97706', backgroundColor: '#d9770688', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (normal)', data: heapNormal, borderColor: '#16a34a', backgroundColor: '#16a34a88', pointRadius: 3, showLine: true, tension: 0.3 },
{ label: 'Heap (sleep)', data: heapSleep, borderColor: '#9333ea', backgroundColor: '#9333ea88', pointRadius: 3, showLine: true, tension: 0.3 },
]},
options: chartOpts('KB')
});
charts.cycle = new Chart(document.getElementById('chartCycle'), {
type: 'scatter',
data: { datasets: makeDatasets(validRows, 'cycle_ms', 'Cycle', '#2563eb') },
options: chartOpts('ms')
});
}
function renderTable(rows) {
const thead = document.getElementById('tableHead');
const tbody = document.getElementById('tableBody');
thead.innerHTML = '<tr>' + COLUMNS.map((col, i) =>
`<th onclick="sortTable(${i})">${col.replace(/_/g, ' ')}<span class="sort-arrow">${sortCol === i ? (sortAsc ? '▲' : '▼') : ''}</span></th>`
).join('') + '</tr>';
const sorted = [...rows].sort((a, b) => {
const key = COLUMNS[sortCol];
let av = a[key], bv = b[key];
if (typeof av === 'number') return sortAsc ? av - bv : bv - av;
av = String(av); bv = String(bv);
return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
});
tbody.innerHTML = sorted.map(r => {
const resultClass = r.result === 'ok' ? 'result-ok' : 'result-fail';
const wakeClass = r.wake_type === 'sleep' ? 'wake-sleep' : 'wake-normal';
const shortTs = r.timestamp && r.timestamp !== 'no_clock'
? new Date(r.timestamp).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' })
: r.timestamp;
const shortId = r.asset_id ? r.asset_id.substring(0, 8) + '...' : '';
const shortPhotoDate = r.photo_date && r.photo_date !== ''
? r.photo_date.substring(0, 10)
: '';
return `<tr>
<td>${shortTs}</td>
<td>${r.battery_pct}%</td>
<td>${r.free_psram_kb}</td>
<td>${r.free_heap_kb}</td>
<td>${r.wifi_rssi}</td>
<td>${r.wifi_ms}</td>
<td>${r.cycle_ms}</td>
<td>${r.rtc_valid ? '✓' : '✗'}</td>
<td>${r.boot_count}</td>
<td>${r.filter}</td>
<td class="id-cell" title="${r.asset_id}">${shortId}</td>
<td>${shortPhotoDate}</td>
<td class="${resultClass}">${r.result}</td>
<td class="${wakeClass}">${r.wake_type}</td>
<td>${r.last_error_ts}</td>
<td>${r.errors_total}</td>
</tr>`;
}).join('');
}
window.sortTable = function(col) {
if (sortCol === col) sortAsc = !sortAsc;
else { sortCol = col; sortAsc = true; }
render();
};
</script>
</body>
</html>

20
tools/vendor/chart.umd.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long