- 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>
507 lines
21 KiB
HTML
507 lines
21 KiB
HTML
<!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>
|