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:
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>
|
||||
Reference in New Issue
Block a user