feat: add deep sleep batch caching, wake log, and log viewer tools
- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce network calls and improve battery life (configurable batch size 1-50) - Add wake_log module: persistent CSV log of each photo cycle surviving reboots and deep sleep, with download/clear via web UI - Add browser-based log viewer and log comparison tools (tools/) - Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv, and .DS_Store Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,8 @@
|
||||
.pio/
|
||||
.superpowers/
|
||||
|
||||
.cursor/
|
||||
chat-summaries/
|
||||
wake_*.csv
|
||||
|
||||
.DS_Store
|
||||
|
||||
31
data/app.js
31
data/app.js
@@ -110,6 +110,13 @@ async function renderSlideshow(el) {
|
||||
</select>
|
||||
</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>`;
|
||||
el.querySelectorAll('[data-interval]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
@@ -128,7 +135,8 @@ async function renderSlideshow(el) {
|
||||
interval = parseInt(activeBtn?.dataset.interval || '5');
|
||||
}
|
||||
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');
|
||||
};
|
||||
}
|
||||
@@ -241,6 +249,14 @@ async function renderDevice(el) {
|
||||
<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>
|
||||
</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>`;
|
||||
|
||||
document.getElementById('saveImmich').onclick = async () => {
|
||||
@@ -270,6 +286,19 @@ async function renderDevice(el) {
|
||||
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) {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
#define DEFAULT_LED_BRIGHTNESS 50
|
||||
#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_TIMER_BATCH 10 // Deep sleep batch cache size (1-50)
|
||||
#define DEFAULT_IMMICH_URL "https://photos.example.com"
|
||||
|
||||
// --- Timing ---
|
||||
|
||||
@@ -273,6 +273,69 @@ String ImmichClient::fetchOneRandomAssetId(const RandomFetchFilter& filter) {
|
||||
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 info;
|
||||
info.id = assetId;
|
||||
|
||||
@@ -44,6 +44,7 @@ public:
|
||||
const std::vector<String>& albumIds = {});
|
||||
std::vector<String> fetchFavoriteAssetIds();
|
||||
String fetchOneRandomAssetId(const RandomFetchFilter& filter);
|
||||
std::vector<String> fetchFilteredBatch(const RandomFetchFilter& filter, int count);
|
||||
AssetInfo fetchAssetInfo(const String& assetId);
|
||||
bool downloadAsset(const String& assetId, ImageQuality quality,
|
||||
uint8_t** outBuffer, size_t* outSize);
|
||||
|
||||
162
src/main.cpp
162
src/main.cpp
@@ -1,6 +1,7 @@
|
||||
#include <Arduino.h>
|
||||
#include <M5Unified.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <sys/time.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "display_manager.h"
|
||||
#include "web_server.h"
|
||||
#include "time_utils.h"
|
||||
#include "wake_log.h"
|
||||
|
||||
// Global instances
|
||||
SettingsManager settingsManager;
|
||||
@@ -282,6 +284,13 @@ void displayTask(void* param) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
lastRefreshTime = millis();
|
||||
xSemaphoreGive(stateMutex);
|
||||
@@ -423,16 +432,90 @@ static RandomFetchFilter buildTimerFilter(const Settings& s,
|
||||
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
|
||||
void timerWakeCycle() {
|
||||
Serial.println("[timer_wake] Starting minimal wake cycle");
|
||||
|
||||
// Initialize wake log early (mounts LittleFS, increments boot counter)
|
||||
wakeLogBegin();
|
||||
|
||||
Settings s = settingsManager.get();
|
||||
|
||||
// Connect WiFi (minimal — skip NTP/mDNS to save battery)
|
||||
unsigned long wifiStart = millis();
|
||||
wifiManager.begin(settingsManager, true);
|
||||
uint32_t wifiMs = millis() - wifiStart;
|
||||
|
||||
if (!wifiManager.isConnected()) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -456,12 +539,19 @@ void timerWakeCycle() {
|
||||
}
|
||||
}
|
||||
|
||||
// Single-fetch with probabilistic filtering and retry
|
||||
static constexpr int TIMER_WAKE_RETRIES = 5;
|
||||
bool displayed = false;
|
||||
// --- Batch cache logic ---
|
||||
uint32_t cacheHash = timerCacheHash(s);
|
||||
String cacheRaw = settingsManager.readField("timer_ids", "");
|
||||
std::vector<String> cachedIds = parseCacheIds(cacheRaw, cacheHash);
|
||||
bool cacheWasEmpty = cachedIds.empty();
|
||||
bool filterRelaxed = false;
|
||||
|
||||
for (int attempt = 0; attempt < TIMER_WAKE_RETRIES && !displayed; attempt++) {
|
||||
// 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;
|
||||
@@ -469,16 +559,14 @@ void timerWakeCycle() {
|
||||
filter = buildTimerFilter(s, albumIds);
|
||||
}
|
||||
|
||||
String assetId = immichClient.fetchOneRandomAssetId(filter);
|
||||
cachedIds = immichClient.fetchFilteredBatch(filter, s.timer_batch_size);
|
||||
|
||||
if (assetId.length() == 0) {
|
||||
if (cachedIds.empty()) {
|
||||
if (!filterRelaxed && (filter.favoriteOnly ||
|
||||
filter.takenAfter.length() > 0 || filter.takenBefore.length() > 0)) {
|
||||
// Filter returned no results — relax and retry
|
||||
Serial.println("[timer_wake] Filtered request empty — relaxing filter");
|
||||
Serial.println("[timer_wake] Filtered batch empty — relaxing filter");
|
||||
filterRelaxed = true;
|
||||
|
||||
// For chronological modes, reset the cursor (wrapped around)
|
||||
if (s.cycle_mode == CycleMode::Chronological ||
|
||||
s.cycle_mode == CycleMode::ReverseChronological) {
|
||||
settingsManager.saveField("timer_date", "");
|
||||
@@ -486,19 +574,51 @@ void timerWakeCycle() {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Serial.println("[timer_wake] No assets available");
|
||||
break;
|
||||
Serial.printf("[timer_wake] Batch fetch failed (attempt %d)\n", attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[timer_wake] Asset: %s (attempt %d)\n",
|
||||
assetId.c_str(), 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;
|
||||
size_t jpegSize = 0;
|
||||
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) {
|
||||
Serial.println("[timer_wake] Download failed, retrying");
|
||||
Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1);
|
||||
lastResult = WakeLogResult::FailDownload;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -519,6 +639,7 @@ void timerWakeCycle() {
|
||||
displayManager.refresh();
|
||||
imagePipeline.freeImage(img);
|
||||
displayed = true;
|
||||
lastResult = WakeLogResult::Ok;
|
||||
|
||||
// Update chronological cursor with this photo's date
|
||||
if (info.dateTime.length() > 0) {
|
||||
@@ -526,11 +647,19 @@ void timerWakeCycle() {
|
||||
}
|
||||
} 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) {
|
||||
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);
|
||||
@@ -622,6 +751,9 @@ void setup() {
|
||||
webServer.begin(settingsManager, immichClient, powerManager, wifiManager);
|
||||
webServer.setActionCallback(webActionCallback);
|
||||
|
||||
// Initialize wake log (LittleFS already mounted by web server)
|
||||
wakeLogBegin();
|
||||
|
||||
// Single display update after all init is complete
|
||||
if (wifiManager.isAPMode()) {
|
||||
displayManager.showSetupScreen();
|
||||
|
||||
@@ -30,6 +30,7 @@ Settings SettingsManager::get() {
|
||||
s.queue_cursor = readU32("queue_cursor", 0);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ void SettingsManager::save(const Settings& s) {
|
||||
writeU32("queue_cursor", s.queue_cursor);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -68,6 +70,10 @@ void SettingsManager::saveField(const char* key, const char* value) {
|
||||
writeString(key, String(value));
|
||||
}
|
||||
|
||||
String SettingsManager::readField(const char* key, const char* defaultValue) {
|
||||
return readString(key, defaultValue);
|
||||
}
|
||||
|
||||
void SettingsManager::factoryReset() {
|
||||
prefs.clear();
|
||||
Serial.println("[settings] Factory reset — rebooting");
|
||||
|
||||
@@ -69,6 +69,9 @@ struct Settings {
|
||||
|
||||
// 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 {
|
||||
@@ -79,6 +82,7 @@ public:
|
||||
void saveField(const char* key, uint8_t value);
|
||||
void saveField(const char* key, uint32_t value);
|
||||
void saveField(const char* key, const char* value);
|
||||
String readField(const char* key, const char* defaultValue = "");
|
||||
void factoryReset();
|
||||
bool isProvisioned();
|
||||
|
||||
|
||||
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);
|
||||
@@ -189,6 +189,22 @@ void AppWebServer::setupAPIRoutes() {
|
||||
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,
|
||||
[](AsyncWebServerRequest* req) {
|
||||
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
|
||||
@@ -300,6 +316,7 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
|
||||
doc["immich_url"] = s.immich_url;
|
||||
doc["immich_key"] = s.immich_key;
|
||||
doc["albums_json"] = s.albums_json;
|
||||
doc["timer_batch_size"] = s.timer_batch_size;
|
||||
|
||||
String response;
|
||||
serializeJson(doc, response);
|
||||
@@ -334,6 +351,10 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
|
||||
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_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);
|
||||
request->send(200, "application/json", "{\"ok\":true}");
|
||||
|
||||
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