Compare commits
10 Commits
8cab0c063e
...
1ba6e9fdf6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ba6e9fdf6 | |||
| 8ca9a0ae96 | |||
| 39e240dd35 | |||
| 3051122742 | |||
| 6810f2c7c6 | |||
| 73b8dde686 | |||
| 74e063d958 | |||
| 0d1cd3392d | |||
| 7d260a452a | |||
| a0f1b9917e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,8 @@
|
|||||||
.pio/
|
.pio/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
.cursor/
|
||||||
|
chat-summaries/
|
||||||
|
wake_*.csv
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|||||||
58
data/app.js
58
data/app.js
@@ -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) {
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
638
src/main.cpp
638
src/main.cpp
@@ -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,16 +32,171 @@ 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;
|
||||||
|
|
||||||
|
// Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer.
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (isPair) {
|
||||||
|
uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
|
||||||
|
uint16_t rightX = halfW + PORTRAIT_GAP_PX;
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadataInRegion(
|
||||||
|
info, s.meta_flags, s.meta_pos,
|
||||||
|
0, 0, halfW, DISPLAY_HEIGHT,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
displayManager.showMetadataInRegion(
|
||||||
|
info2, s.meta_flags, s.meta_pos,
|
||||||
|
rightX, 0, halfW, DISPLAY_HEIGHT,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
||||||
|
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);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
void displayTask(void* param) {
|
||||||
Serial.println("[display_task] Started on Core 1");
|
Serial.println("[display_task] Started on Core 1");
|
||||||
|
|
||||||
@@ -48,7 +208,6 @@ void displayTask(void* param) {
|
|||||||
|
|
||||||
if (photoQueue.sync()) {
|
if (photoQueue.sync()) {
|
||||||
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
|
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
|
||||||
// Trigger first photo immediately
|
|
||||||
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
refreshRequested = true;
|
refreshRequested = true;
|
||||||
xSemaphoreGive(stateMutex);
|
xSemaphoreGive(stateMutex);
|
||||||
@@ -61,21 +220,22 @@ void displayTask(void* param) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
Settings s = settingsManager.get();
|
Settings s = settingsManager.get();
|
||||||
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
|
||||||
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
|
||||||
displayManager.setTranslucent(s.meta_translucent != 0);
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
unsigned long intervalMs = s.interval_min * 60000UL;
|
unsigned long intervalMs = s.interval_min * 60000UL;
|
||||||
|
|
||||||
bool shouldRefresh = false;
|
bool shouldRefresh = false;
|
||||||
bool wantsRandom = false;
|
bool wantsRandom = false;
|
||||||
|
bool isManual = false;
|
||||||
|
|
||||||
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
if (refreshRequested) {
|
if (refreshRequested) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
|
isManual = true;
|
||||||
refreshRequested = false;
|
refreshRequested = false;
|
||||||
} else if (randomRequested) {
|
} else if (randomRequested) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
wantsRandom = true;
|
wantsRandom = true;
|
||||||
|
isManual = true;
|
||||||
randomRequested = false;
|
randomRequested = false;
|
||||||
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
|
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
@@ -83,134 +243,102 @@ void displayTask(void* param) {
|
|||||||
xSemaphoreGive(stateMutex);
|
xSemaphoreGive(stateMutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic re-sync
|
// Periodic re-sync (includes album change requests)
|
||||||
if (photoQueue.needsResync() && wifiManager.isConnected()) {
|
if (photoQueue.needsResync() && wifiManager.isConnected()) {
|
||||||
photoQueue.sync();
|
photoQueue.sync();
|
||||||
|
discardPending(pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 1: Display ---
|
||||||
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
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;
|
bool displayed = false;
|
||||||
|
uint32_t currentHash = computeSettingsHash(s);
|
||||||
|
|
||||||
for (int attempt = 0; attempt < MAX_RETRIES && !displayed; attempt++) {
|
if (!isManual && pending.ready && pending.settingsHash == currentHash) {
|
||||||
String assetId;
|
// Use pre-rendered image — instant display
|
||||||
if (wantsRandom) {
|
Serial.println("[display_task] Using pre-rendered image");
|
||||||
assetId = photoQueue.random();
|
displayed = displayPhoto(pending.img, pending.info, pending.info2,
|
||||||
} else {
|
pending.isPair, s);
|
||||||
assetId = photoQueue.next();
|
pending.ready = false;
|
||||||
}
|
} else {
|
||||||
|
// Manual request, stale settings, or no pre-render available — inline pipeline
|
||||||
if (assetId.length() == 0) break;
|
if (pending.ready) {
|
||||||
|
if (isManual) {
|
||||||
Serial.printf("[display_task] Loading asset: %s (attempt %d)\n",
|
Serial.println("[display_task] Manual request — discarding pre-render");
|
||||||
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 rightX = halfW + PORTRAIT_GAP_PX;
|
|
||||||
displayManager.showImage(img);
|
|
||||||
if (s.meta_flags != 0) {
|
|
||||||
displayManager.showMetadataInRegion(
|
|
||||||
info, s.meta_flags, s.meta_pos,
|
|
||||||
0, 0, halfW, DISPLAY_HEIGHT,
|
|
||||||
s.date_fmt, s.time_fmt);
|
|
||||||
displayManager.showMetadataInRegion(
|
|
||||||
info2, s.meta_flags, s.meta_pos,
|
|
||||||
rightX, 0, halfW, DISPLAY_HEIGHT,
|
|
||||||
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 {
|
|
||||||
Serial.println("[display_task] Pair download failed, showing solo");
|
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No pair found — show solo portrait (will get L/R letterbox)
|
Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n",
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
pending.settingsHash, currentHash);
|
||||||
}
|
}
|
||||||
|
discardPending(pending);
|
||||||
} else {
|
} else {
|
||||||
// Normal landscape processing
|
Serial.println("[display_task] No pre-render available — inline fallback");
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
free(jpegBuf);
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
|
||||||
Serial.printf("[display_task] Post-process PSRAM free: %u KB\n",
|
PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
|
||||||
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) {
|
||||||
|
displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info,
|
||||||
if (img.valid && !displayed) {
|
inlinePhoto.info2, inlinePhoto.isPair, s);
|
||||||
displayManager.showImage(img);
|
|
||||||
if (s.meta_flags != 0) {
|
|
||||||
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
|
||||||
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 if (!img.valid && !displayed) {
|
|
||||||
Serial.println("[display_task] Processing failed, trying next");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|||||||
@@ -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,58 +116,67 @@ 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()) {
|
||||||
Serial.println("[queue] Queue exhausted, fetching new batch");
|
if (_albumMode && !_queue.empty()) {
|
||||||
sync();
|
// Album mode: reshuffle existing queue instead of re-fetching
|
||||||
_cursor = 0;
|
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");
|
||||||
|
sync();
|
||||||
|
_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());
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
33
src/time_utils.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
// Returns true if the system clock has been set to a plausible time (year >= 2020).
|
||||||
|
inline bool hasValidTime() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
return (t.tm_year + 1900) >= 2020;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the current UTC time as an ISO 8601 string (e.g., "2026-08-04T17:30:00.000Z").
|
||||||
|
inline String isoNow() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
char buf[32];
|
||||||
|
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns (now - 30 days) as an ISO 8601 UTC string.
|
||||||
|
inline String isoNowMinus30d() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
now -= 30 * 24 * 3600; // Subtract 30 days in seconds
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
char buf[32];
|
||||||
|
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
183
src/wake_log.cpp
Normal file
183
src/wake_log.cpp
Normal 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
21
src/wake_log.h
Normal 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);
|
||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
506
tools/log-compare.html
Normal 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
432
tools/log-viewer.html
Normal 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
20
tools/vendor/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
tools/vendor/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
7
tools/vendor/chartjs-adapter-date-fns.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user