Compare commits
33 Commits
eee6930983
...
ef475b493d
| Author | SHA1 | Date | |
|---|---|---|---|
| ef475b493d | |||
| 6fd71f332c | |||
| 531906ca2c | |||
| d95a6db79e | |||
| b2f85d752a | |||
| ac6c6eaf79 | |||
| b9e5044fd2 | |||
| 3483b9bef8 | |||
| 033ab8d33b | |||
| d41ba0bd8a | |||
| d54e468c73 | |||
| 6b8d332107 | |||
| 758fd4e123 | |||
| 5aabfe0e9b | |||
| cda56142a3 | |||
| 51756be080 | |||
| fe20841d9e | |||
| 392061bfa0 | |||
| 127205de2e | |||
| 26e30620c4 | |||
| afcf4cb81e | |||
| b16783294e | |||
| 034a061293 | |||
| 5a91cd1590 | |||
| 1610a4d94c | |||
| 7aaf920658 | |||
| 9166bfe0ce | |||
| 8f85015339 | |||
| 841169b9b9 | |||
| 7f33d06fb0 | |||
| 442303c485 | |||
| 881a20f911 | |||
| 9ba3e89aae |
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.pio/
|
||||||
|
.superpowers/
|
||||||
|
docs/superpowers/
|
||||||
|
|
||||||
|
.cursor/
|
||||||
|
chat-summaries/
|
||||||
|
wake_*.csv
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
333
data/app.js
Normal file
333
data/app.js
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
const API = '';
|
||||||
|
let currentPage = 'dashboard';
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(API + path, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg) {
|
||||||
|
let t = document.querySelector('.toast');
|
||||||
|
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigate(page) {
|
||||||
|
currentPage = page;
|
||||||
|
document.querySelectorAll('#nav-links a').forEach(a => {
|
||||||
|
a.classList.toggle('active', a.dataset.page === page);
|
||||||
|
});
|
||||||
|
renderPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(page) {
|
||||||
|
const el = document.getElementById('content');
|
||||||
|
switch (page) {
|
||||||
|
case 'dashboard': return renderDashboard(el);
|
||||||
|
case 'albums': return renderAlbums(el);
|
||||||
|
case 'slideshow': return renderSlideshow(el);
|
||||||
|
case 'display': return renderDisplay(el);
|
||||||
|
case 'device': return renderDevice(el);
|
||||||
|
case 'firmware': return renderFirmware(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDashboard(el) {
|
||||||
|
const status = await api('/api/status');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Status</h2>
|
||||||
|
<div class="stat"><span>Battery</span><span>${status.battery}%${status.charging ? ' ⚡' : ''}</span></div>
|
||||||
|
<div class="stat"><span>WiFi Signal</span><span>${status.wifi_rssi} dBm</span></div>
|
||||||
|
<div class="stat"><span>IP Address</span><span>${status.ip}</span></div>
|
||||||
|
<div class="stat"><span>Uptime</span><span>${Math.floor(status.uptime/60)}m</span></div>
|
||||||
|
<div class="stat"><span>Free RAM</span><span>${Math.floor(status.free_heap/1024)}KB</span></div>
|
||||||
|
<div class="stat"><span>Free PSRAM</span><span>${Math.floor(status.free_psram/1024)}KB</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Controls</h2>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" onclick="api('/api/action/next',{method:'POST'}).then(()=>toast('Next photo'))">Next</button>
|
||||||
|
<button class="btn btn-primary" onclick="api('/api/action/random',{method:'POST'}).then(()=>toast('Random photo'))">Random</button>
|
||||||
|
<button class="btn btn-primary" onclick="api('/api/action/pause',{method:'POST'}).then(()=>toast('Paused'))">Pause</button>
|
||||||
|
<button class="btn btn-primary" onclick="api('/api/action/play',{method:'POST'}).then(()=>toast('Playing'))">Play</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAlbums(el) {
|
||||||
|
const [albums, settings] = await Promise.all([api('/api/albums'), api('/api/settings')]);
|
||||||
|
const selected = JSON.parse(settings.albums_json || '[]');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Albums</h2>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
${albums.map(a => `
|
||||||
|
<li>
|
||||||
|
<input type="checkbox" value="${a.id}" ${selected.includes(a.id) ? 'checked' : ''}>
|
||||||
|
<span>${a.title} (${a.assetCount})</span>
|
||||||
|
</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" id="saveAlbums">Save Selection</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('saveAlbums').onclick = async () => {
|
||||||
|
const ids = [...el.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
|
||||||
|
await api('/api/albums/select', { method: 'POST', body: JSON.stringify({ album_ids: ids }) });
|
||||||
|
toast('Albums saved');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSlideshow(el) {
|
||||||
|
const settings = await api('/api/settings');
|
||||||
|
const presets = [1, 2, 5, 10, 15, 30, 60];
|
||||||
|
const isCustom = !presets.includes(settings.interval_min);
|
||||||
|
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted',
|
||||||
|
'Weighted Recent', 'Weighted Oldest'];
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Interval</h2>
|
||||||
|
<div class="preset-btns">
|
||||||
|
${presets.map(i => `<button class="${!isCustom && settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
|
||||||
|
<button class="${isCustom?'active':''}" data-interval="custom">Custom</button>
|
||||||
|
</div>
|
||||||
|
<div class="field" id="customIntervalRow" style="margin-top:8px;display:${isCustom?'flex':'none'}">
|
||||||
|
<label>Minutes (1–60):</label>
|
||||||
|
<input type="number" id="customInterval" min="1" max="60" value="${isCustom?settings.interval_min:5}" style="width:60px;margin-left:8px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Cycling Mode</h2>
|
||||||
|
<div class="field">
|
||||||
|
<select id="cycleMode">
|
||||||
|
${modes.map((m, i) => `<option value="${i}" ${settings.cycle_mode===i?'selected':''}>${m}</option>`).join('')}
|
||||||
|
</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 = () => {
|
||||||
|
el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('customIntervalRow').style.display =
|
||||||
|
btn.dataset.interval === 'custom' ? 'flex' : 'none';
|
||||||
|
};
|
||||||
|
});
|
||||||
|
document.getElementById('saveSlideshow').onclick = async () => {
|
||||||
|
const activeBtn = el.querySelector('[data-interval].active');
|
||||||
|
let interval;
|
||||||
|
if (activeBtn && activeBtn.dataset.interval === 'custom') {
|
||||||
|
interval = Math.min(60, Math.max(1, parseInt(document.getElementById('customInterval').value) || 5));
|
||||||
|
} else {
|
||||||
|
interval = parseInt(activeBtn?.dataset.interval || '5');
|
||||||
|
}
|
||||||
|
const mode = parseInt(document.getElementById('cycleMode').value);
|
||||||
|
const 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');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDisplay(el) {
|
||||||
|
const settings = await api('/api/settings');
|
||||||
|
const flags = settings.meta_flags;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Image Quality</h2>
|
||||||
|
<div class="field">
|
||||||
|
<select id="imgQuality">
|
||||||
|
<option value="0" ${settings.img_quality===0?'selected':''}>Preview (faster)</option>
|
||||||
|
<option value="1" ${settings.img_quality===1?'selected':''}>Original (higher quality)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Image Processing</h2>
|
||||||
|
<div class="field">
|
||||||
|
<select id="pipelineMode">
|
||||||
|
<option value="0" ${settings.pipeline_mode===0?'selected':''}>Dynamic (vivid colors)</option>
|
||||||
|
<option value="1" ${settings.pipeline_mode===1?'selected':''}>Balanced (preserve tones)</option>
|
||||||
|
<option value="2" ${settings.pipeline_mode===2?'selected':''}>None (no processing)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Metadata Overlay</h2>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
<li><input type="checkbox" data-flag="1" ${flags&1?'checked':''}><span>Date</span></li>
|
||||||
|
<li><input type="checkbox" data-flag="32" ${flags&32?'checked':''}><span>Time</span></li>
|
||||||
|
<li><input type="checkbox" data-flag="2" ${flags&2?'checked':''}><span>Location</span></li>
|
||||||
|
<li><input type="checkbox" data-flag="4" ${flags&4?'checked':''}><span>People</span></li>
|
||||||
|
<li><input type="checkbox" data-flag="8" ${flags&8?'checked':''}><span>Album</span></li>
|
||||||
|
<li><input type="checkbox" data-flag="16" ${flags&16?'checked':''}><span>Camera</span></li>
|
||||||
|
</ul>
|
||||||
|
<div class="field">
|
||||||
|
<label>Date Format</label>
|
||||||
|
<input type="text" id="dateFmt" value="${settings.date_fmt || '%Y-%m-%d'}" placeholder="%Y-%m-%d">
|
||||||
|
<small>strftime: %Y=year %m=month %d=day %b=Mon %B=Month</small>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Time Format</label>
|
||||||
|
<input type="text" id="timeFmt" value="${settings.time_fmt || '%H:%M:%S'}" placeholder="%H:%M:%S">
|
||||||
|
<small>strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM</small>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Style & Position</label>
|
||||||
|
<select id="metaPos">
|
||||||
|
<option value="0" ${settings.meta_pos===0?'selected':''}>Caption - Bottom</option>
|
||||||
|
<option value="1" ${settings.meta_pos===1?'selected':''}>Caption - Top</option>
|
||||||
|
<option value="2" ${settings.meta_pos===2?'selected':''}>Overlay - Top Left</option>
|
||||||
|
<option value="3" ${settings.meta_pos===3?'selected':''}>Overlay - Top Right</option>
|
||||||
|
<option value="4" ${settings.meta_pos===4?'selected':''}>Overlay - Bottom Left</option>
|
||||||
|
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
<li><input type="checkbox" id="metaTranslucent" ${settings.meta_translucent?'checked':''}><span>Translucent background?</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Battery Indicator</h2>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
<li><input type="checkbox" id="showBattery" ${settings.show_battery?'checked':''}><span>Show battery level on display</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group"><button class="btn btn-primary" id="saveDisplay">Save</button></div>`;
|
||||||
|
document.getElementById('saveDisplay').onclick = async () => {
|
||||||
|
let flags = 0;
|
||||||
|
el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
|
||||||
|
await api('/api/settings', { method: 'POST', body: JSON.stringify({
|
||||||
|
img_quality: parseInt(document.getElementById('imgQuality').value),
|
||||||
|
pipeline_mode: parseInt(document.getElementById('pipelineMode').value),
|
||||||
|
meta_flags: flags,
|
||||||
|
meta_pos: parseInt(document.getElementById('metaPos').value),
|
||||||
|
meta_translucent: document.getElementById('metaTranslucent').checked ? 1 : 0,
|
||||||
|
date_fmt: document.getElementById('dateFmt').value,
|
||||||
|
time_fmt: document.getElementById('timeFmt').value,
|
||||||
|
show_battery: document.getElementById('showBattery').checked ? 1 : 0
|
||||||
|
})});
|
||||||
|
toast('Display settings saved');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDevice(el) {
|
||||||
|
const settings = await api('/api/settings');
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Immich Connection</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Immich URL</label>
|
||||||
|
<input type="url" id="immichUrl" value="${settings.immich_url || ''}" placeholder="https://photos.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="password" id="immichKey" value="${settings.immich_key || ''}" placeholder="Your Immich API key">
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" id="saveImmich">Save</button>
|
||||||
|
<button class="btn" id="testConn">Test Connection</button>
|
||||||
|
</div>
|
||||||
|
<div id="connStatus" style="margin-top:12px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Device Actions</h2>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn" id="syncTime">Sync Time</button>
|
||||||
|
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
|
||||||
|
<button class="btn btn-danger" onclick="if(confirm('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 () => {
|
||||||
|
const url = document.getElementById('immichUrl').value.trim();
|
||||||
|
const key = document.getElementById('immichKey').value.trim();
|
||||||
|
if (!url || !key) return toast('URL and API key are required');
|
||||||
|
await api('/api/settings', { method: 'POST', body: JSON.stringify({ immich_url: url, immich_key: key }) });
|
||||||
|
toast('Immich settings saved. Reboot to apply.');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('testConn').onclick = async () => {
|
||||||
|
const status = document.getElementById('connStatus');
|
||||||
|
status.textContent = 'Testing...';
|
||||||
|
status.style.color = '';
|
||||||
|
try {
|
||||||
|
const result = await api('/api/test-connection', { method: 'POST' });
|
||||||
|
status.textContent = result.msg;
|
||||||
|
status.style.color = result.ok ? '#2e7d32' : '#c62828';
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = 'Request failed: ' + e.message;
|
||||||
|
status.style.color = '#c62828';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('syncTime').onclick = async () => {
|
||||||
|
toast('Syncing...');
|
||||||
|
const result = await api('/api/action/sync-time', { method: 'POST' });
|
||||||
|
toast(result.msg || 'Time synced');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('downloadLog').onclick = () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/log';
|
||||||
|
a.download = 'wake_log.csv';
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('clearLog').onclick = async () => {
|
||||||
|
if (!confirm('Clear the wake log?')) return;
|
||||||
|
const result = await api('/api/log/clear', { method: 'POST' });
|
||||||
|
toast(result.msg || 'Log cleared');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFirmware(el) {
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<h2>Firmware Update</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Upload .bin file</label>
|
||||||
|
<input type="file" id="fwFile" accept=".bin">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" id="uploadFw">Upload & Flash</button>
|
||||||
|
<div id="fwStatus" style="margin-top:12px"></div>
|
||||||
|
</div>`;
|
||||||
|
document.getElementById('uploadFw').onclick = async () => {
|
||||||
|
const file = document.getElementById('fwFile').files[0];
|
||||||
|
if (!file) return toast('Select a file first');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('firmware', file);
|
||||||
|
document.getElementById('fwStatus').textContent = 'Uploading...';
|
||||||
|
const res = await fetch('/api/firmware', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('fwStatus').textContent = data.msg || (data.ok ? 'Success!' : 'Failed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
document.querySelectorAll('#nav-links a').forEach(a => {
|
||||||
|
a.addEventListener('click', (e) => { e.preventDefault(); navigate(a.dataset.page); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
navigate('dashboard');
|
||||||
24
data/index.html
Normal file
24
data/index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PaperColor</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<h1>PaperColor</h1>
|
||||||
|
<div id="nav-links">
|
||||||
|
<a href="#" data-page="dashboard" class="active">Dashboard</a>
|
||||||
|
<a href="#" data-page="albums">Albums</a>
|
||||||
|
<a href="#" data-page="slideshow">Slideshow</a>
|
||||||
|
<a href="#" data-page="display">Display</a>
|
||||||
|
<a href="#" data-page="device">Device</a>
|
||||||
|
<a href="#" data-page="firmware">Firmware</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main id="content"></main>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
data/setup.html
Normal file
100
data/setup.html
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PaperColor Setup</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, sans-serif; max-width: 400px; margin: 40px auto; padding: 20px; background: #f5f5f5; }
|
||||||
|
h1 { margin-bottom: 20px; color: #333; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 4px; color: #555; }
|
||||||
|
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
|
||||||
|
button { width: 100%; padding: 12px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 10px; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
.status { margin-top: 16px; padding: 10px; border-radius: 6px; display: none; }
|
||||||
|
.status.error { display: block; background: #fee2e2; color: #991b1b; }
|
||||||
|
.status.success { display: block; background: #d1fae5; color: #065f46; }
|
||||||
|
#networks { margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>PaperColor Setup</h1>
|
||||||
|
<form id="setupForm">
|
||||||
|
<div class="field">
|
||||||
|
<label>WiFi Network</label>
|
||||||
|
<select id="wifiSsid"><option value="">Scanning...</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>WiFi Password</label>
|
||||||
|
<input type="password" id="wifiPass" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Immich URL</label>
|
||||||
|
<input type="url" id="immichUrl" value="https://photos.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Immich API Key</label>
|
||||||
|
<input type="text" id="immichKey" required placeholder="Your API key">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save & Connect</button>
|
||||||
|
</form>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function scanNetworks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wifi/scan');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.scanning) {
|
||||||
|
// Scan in progress — retry after 2s
|
||||||
|
setTimeout(scanNetworks, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const select = document.getElementById('wifiSsid');
|
||||||
|
if (data.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">No networks found</option>';
|
||||||
|
setTimeout(scanNetworks, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
select.innerHTML = data.map(n =>
|
||||||
|
`<option value="${n.ssid}">${n.ssid} (${n.rssi}dBm)</option>`
|
||||||
|
).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Scan failed:', e);
|
||||||
|
setTimeout(scanNetworks, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
wifi_ssid: document.getElementById('wifiSsid').value,
|
||||||
|
wifi_pass: document.getElementById('wifiPass').value,
|
||||||
|
immich_url: document.getElementById('immichUrl').value,
|
||||||
|
immich_key: document.getElementById('immichKey').value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
status.className = 'status success';
|
||||||
|
status.textContent = 'Saved! Device is rebooting...';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Unknown error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
status.className = 'status error';
|
||||||
|
status.textContent = 'Error: ' + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scanNetworks();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
data/style.css
Normal file
29
data/style.css
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #1e293b; }
|
||||||
|
nav { background: #1e293b; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
|
||||||
|
nav h1 { font-size: 18px; white-space: nowrap; }
|
||||||
|
#nav-links { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
#nav-links a { color: #94a3b8; text-decoration: none; padding: 4px 8px; border-radius: 4px; font-size: 14px; }
|
||||||
|
#nav-links a.active { color: white; background: #334155; }
|
||||||
|
main { max-width: 800px; margin: 24px auto; padding: 0 16px; }
|
||||||
|
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||||
|
.card h2 { font-size: 16px; margin-bottom: 12px; color: #475569; }
|
||||||
|
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.stat:last-child { border-bottom: none; }
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; color: #64748b; }
|
||||||
|
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
|
||||||
|
.btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||||
|
.btn-primary { background: #2563eb; color: white; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-danger { background: #dc2626; color: white; }
|
||||||
|
.btn-danger:hover { background: #b91c1c; }
|
||||||
|
.btn-group { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.checkbox-list { list-style: none; }
|
||||||
|
.checkbox-list li { padding: 8px 0; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.checkbox-list input[type="checkbox"] { width: 18px; height: 18px; }
|
||||||
|
.preset-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.preset-btns button { padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; }
|
||||||
|
.preset-btns button.active { background: #2563eb; color: white; border-color: #2563eb; }
|
||||||
|
.toast { position: fixed; bottom: 20px; right: 20px; background: #065f46; color: white; padding: 12px 20px; border-radius: 6px; display: none; }
|
||||||
|
.toast.show { display: block; }
|
||||||
@@ -3,5 +3,5 @@ nvs, data, nvs, 0x9000, 0x5000,
|
|||||||
otadata, data, ota, 0xe000, 0x2000,
|
otadata, data, ota, 0xe000, 0x2000,
|
||||||
app0, app, ota_0, 0x10000, 0x680000,
|
app0, app, ota_0, 0x10000, 0x680000,
|
||||||
app1, app, ota_1, 0x690000, 0x680000,
|
app1, app, ota_1, 0x690000, 0x680000,
|
||||||
littlefs, data, spiffs, 0xD10000, 0x80000,
|
spiffs, data, spiffs, 0xD10000, 0x80000,
|
||||||
coredump, data, coredump, 0xD90000, 0x10000,
|
coredump, data, coredump, 0xD90000, 0x10000,
|
||||||
|
|||||||
|
@@ -16,12 +16,14 @@ build_flags =
|
|||||||
-DCORE_DEBUG_LEVEL=3
|
-DCORE_DEBUG_LEVEL=3
|
||||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
-DARDUINO_USB_MODE=1
|
-DARDUINO_USB_MODE=1
|
||||||
|
-DARDUINO_LOOP_STACK_SIZE=32768
|
||||||
lib_deps =
|
lib_deps =
|
||||||
m5stack/M5Unified @ ^0.2.2
|
m5stack/M5Unified @ ^0.2.2
|
||||||
m5stack/M5GFX @ ^0.2.5
|
m5stack/M5GFX @ ^0.2.5
|
||||||
https://github.com/m5stack/M5PM1.git
|
https://github.com/m5stack/M5PM1.git
|
||||||
mathieucarbou/ESP Async WebServer @ ^3.0.6
|
mathieucarbou/ESP Async WebServer @ ^3.0.6
|
||||||
bblanchon/ArduinoJson @ ^7.0.0
|
bblanchon/ArduinoJson @ ^7.0.0
|
||||||
|
ricmoo/QRCode @ ^0.0.1
|
||||||
lib_ignore =
|
lib_ignore =
|
||||||
WebServer
|
WebServer
|
||||||
|
|
||||||
|
|||||||
83
src/blue_noise.h
Normal file
83
src/blue_noise.h
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#ifdef ARDUINO
|
||||||
|
#include <Arduino.h>
|
||||||
|
#else
|
||||||
|
#ifndef PROGMEM
|
||||||
|
#define PROGMEM
|
||||||
|
#endif
|
||||||
|
#ifndef pgm_read_byte
|
||||||
|
#define pgm_read_byte(addr) (*(const uint8_t*)(addr))
|
||||||
|
#endif
|
||||||
|
#include <cstdint>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// 64x64 blue noise texture generated via void-and-cluster algorithm.
|
||||||
|
// Used for dither threshold modulation to break up Floyd-Steinberg patterns.
|
||||||
|
// Values 0-255, spatially uniform frequency distribution.
|
||||||
|
static const uint8_t PROGMEM BLUE_NOISE_64[64 * 64] = {
|
||||||
|
0x8A, 0xF1, 0x42, 0xE3, 0x6C, 0x4E, 0xBA, 0xF1, 0x47, 0xCA, 0xB7, 0x32, 0x8C, 0x69, 0x09, 0xB0, 0x89, 0x0E, 0xF5, 0x21, 0x30, 0xC7, 0x6E, 0x10, 0xEA, 0xBC, 0x08, 0x9E, 0xF0, 0x29, 0x6B, 0xC6, 0x51, 0x16, 0xB5, 0x5B, 0x44, 0xA7, 0x68, 0x8D, 0x4E, 0xBF, 0x95, 0x10, 0x18, 0xEA, 0x8A, 0xD6, 0x27, 0x38, 0x93, 0xC9, 0x14, 0x8D, 0xF0, 0xB7, 0xD1, 0x53, 0x22, 0xDD, 0xC8, 0xA2, 0x61, 0xB4,
|
||||||
|
0x0A, 0x9E, 0x26, 0x84, 0x9B, 0xD0, 0x16, 0x88, 0x9A, 0x0B, 0x59, 0xD9, 0x20, 0xEE, 0x4E, 0xCC, 0x73, 0xD6, 0x54, 0xBE, 0x9B, 0xED, 0x41, 0xA2, 0x56, 0x7E, 0xDD, 0x34, 0x61, 0x13, 0xD7, 0x7F, 0x05, 0xED, 0x94, 0x0D, 0x7D, 0xCB, 0x3B, 0xE8, 0xB0, 0x20, 0xE5, 0x85, 0xCC, 0x01, 0x3D, 0x5D, 0xAD, 0xE2, 0x4F, 0xF6, 0xA9, 0x2F, 0x4C, 0x65, 0x17, 0x85, 0xB1, 0x32, 0x5A, 0x1D, 0x7D, 0xDF,
|
||||||
|
0x6D, 0xD1, 0x5E, 0xB8, 0x0C, 0xFD, 0x64, 0x34, 0xE5, 0x6D, 0xAA, 0x3C, 0x7D, 0xB8, 0x98, 0x38, 0x13, 0xA9, 0x3C, 0x80, 0x60, 0x17, 0x78, 0xD1, 0x2E, 0xAE, 0x4B, 0x73, 0xC2, 0x96, 0x39, 0xA0, 0xB9, 0x62, 0xD3, 0x33, 0xF1, 0x99, 0x0A, 0x73, 0x2D, 0x62, 0x46, 0x6C, 0xB1, 0x56, 0xA1, 0xFB, 0x6E, 0x0B, 0x7D, 0x18, 0x69, 0xDD, 0x81, 0xA4, 0x0E, 0xEC, 0x6C, 0x99, 0xF5, 0xB9, 0x2C, 0x48,
|
||||||
|
0xC5, 0x35, 0xED, 0x4B, 0x19, 0x7F, 0x44, 0xB5, 0xD3, 0x27, 0xF8, 0x92, 0x62, 0x15, 0xDE, 0x5E, 0xFA, 0x8F, 0xE7, 0x0D, 0xDB, 0xAB, 0x04, 0xFA, 0x93, 0x1A, 0xEC, 0x8C, 0x01, 0xFE, 0x56, 0xE3, 0x27, 0x41, 0x72, 0xB1, 0x54, 0x18, 0xBB, 0xD9, 0x9E, 0xEF, 0xC3, 0x33, 0xF4, 0x7C, 0x2B, 0xC2, 0x15, 0xD2, 0x9A, 0xBA, 0x43, 0xC5, 0x24, 0xE6, 0xBC, 0x47, 0xD5, 0x14, 0x41, 0x8B, 0xE7, 0xA8,
|
||||||
|
0x17, 0x8D, 0x77, 0xAA, 0xE0, 0xC4, 0x98, 0x12, 0x79, 0x50, 0x06, 0xC0, 0xE9, 0x48, 0x01, 0xC1, 0x6D, 0x23, 0x4D, 0xBA, 0x36, 0x87, 0xC5, 0x44, 0x61, 0xBD, 0x27, 0xCF, 0x42, 0xB0, 0x77, 0x1C, 0x8A, 0xF4, 0x9E, 0x14, 0xDF, 0x6B, 0x8A, 0x44, 0x12, 0x7E, 0x06, 0x9A, 0x1F, 0xDB, 0x48, 0x94, 0x83, 0x3A, 0x57, 0xEB, 0x04, 0x90, 0x5C, 0x38, 0x71, 0x8F, 0x01, 0x61, 0xC9, 0x6E, 0x0C, 0x5B,
|
||||||
|
0xF6, 0x02, 0xD9, 0x2C, 0x69, 0x07, 0xE9, 0x60, 0xA4, 0xC7, 0x85, 0x30, 0x9F, 0x77, 0xAA, 0x88, 0x2E, 0xCD, 0x9C, 0x70, 0xF5, 0x57, 0x28, 0x73, 0xDF, 0x83, 0x56, 0xA3, 0x6A, 0x2C, 0xDB, 0xC3, 0x4D, 0xCC, 0x08, 0x81, 0xC0, 0x34, 0xFC, 0x5C, 0xCB, 0xAF, 0x4E, 0xD4, 0x60, 0xB8, 0x08, 0xED, 0x61, 0xE3, 0xA5, 0x2E, 0x71, 0xFE, 0x9E, 0xD0, 0x20, 0xF7, 0xB3, 0x9D, 0xEF, 0x37, 0xBB, 0x98,
|
||||||
|
0x4A, 0xB5, 0x59, 0x9B, 0x3F, 0x86, 0x4D, 0x28, 0xF4, 0x39, 0xDF, 0x5C, 0x1D, 0xD4, 0x3A, 0xF2, 0x58, 0xE5, 0x09, 0xAF, 0x1E, 0x94, 0xE9, 0xA7, 0x0D, 0x39, 0xF7, 0x1D, 0xE3, 0x91, 0x0D, 0xA0, 0x6E, 0x2F, 0x5C, 0xEB, 0x49, 0xA4, 0x1F, 0x92, 0x2A, 0xF4, 0x73, 0x8E, 0x38, 0xA2, 0x75, 0xC8, 0x17, 0xB3, 0x1F, 0x8A, 0xCC, 0x47, 0x0F, 0xAE, 0x57, 0x7F, 0x2E, 0x4D, 0x7C, 0x22, 0xDE, 0x75,
|
||||||
|
0xD6, 0x83, 0x21, 0xC7, 0xF7, 0xA6, 0xD2, 0xBA, 0x8E, 0x0E, 0x72, 0xB0, 0xFE, 0x53, 0xB9, 0x0F, 0x98, 0x79, 0x38, 0x60, 0xD1, 0x48, 0xBE, 0x17, 0xD3, 0x9A, 0xB3, 0x79, 0x4A, 0xBE, 0x60, 0x3C, 0xF9, 0xB7, 0x94, 0xD5, 0x1A, 0x71, 0xDA, 0xBF, 0x68, 0x3E, 0x0D, 0xE3, 0x22, 0xFD, 0x52, 0x10, 0x45, 0x7B, 0xD7, 0x51, 0xAC, 0x15, 0x85, 0xE5, 0x3C, 0xDA, 0xBD, 0x1A, 0xCF, 0xA4, 0x43, 0x0F,
|
||||||
|
0xA9, 0x38, 0xEB, 0x6D, 0x10, 0x30, 0x76, 0x1E, 0x63, 0xD5, 0x49, 0x96, 0x27, 0x86, 0x6F, 0x18, 0xCA, 0x47, 0xBF, 0xF0, 0x7F, 0x30, 0x6C, 0x8B, 0x51, 0x68, 0x04, 0xC8, 0x34, 0xED, 0x87, 0xAE, 0x02, 0x79, 0x25, 0x3C, 0xAB, 0x87, 0x4F, 0x00, 0xAA, 0xDD, 0x99, 0xC4, 0x67, 0x84, 0xAC, 0xDF, 0x98, 0xF8, 0x68, 0x36, 0xEA, 0x61, 0xBE, 0x73, 0x07, 0x91, 0x66, 0xEC, 0x5B, 0x8C, 0xFC, 0x65,
|
||||||
|
0x13, 0xBE, 0x91, 0x47, 0xB1, 0x5A, 0xE3, 0x9F, 0x3E, 0xB6, 0xEB, 0x08, 0xC4, 0x3D, 0xEE, 0xA8, 0xDF, 0x8A, 0x14, 0xA5, 0x03, 0x9C, 0xFD, 0x0F, 0xE1, 0x41, 0xF2, 0x93, 0x5C, 0x12, 0x28, 0xE1, 0x4E, 0xCF, 0xED, 0x69, 0xBB, 0xE4, 0x37, 0xF0, 0x7B, 0x58, 0x29, 0x4C, 0xB4, 0x1A, 0x2F, 0x64, 0xC1, 0x26, 0x00, 0xB8, 0x90, 0x2A, 0xF2, 0x4F, 0xC9, 0x25, 0xA3, 0x3E, 0x05, 0xB6, 0x29, 0x7F,
|
||||||
|
0xD0, 0x55, 0x08, 0xDD, 0x80, 0xCA, 0x00, 0xFB, 0x87, 0x2A, 0x7E, 0x6B, 0xE1, 0x9F, 0x60, 0x06, 0x54, 0x2F, 0x6E, 0xE3, 0x42, 0xD7, 0x58, 0xAB, 0xC0, 0x81, 0x2A, 0xD7, 0xAB, 0x76, 0xC6, 0x64, 0x9A, 0x1C, 0x8B, 0x52, 0x0A, 0x28, 0x65, 0xA0, 0x11, 0xD1, 0x8B, 0xF1, 0x3B, 0xE7, 0xD1, 0x90, 0x3F, 0x82, 0xA1, 0xD4, 0x6E, 0x0B, 0x9B, 0x32, 0xAB, 0xFA, 0x81, 0xE0, 0x73, 0xCB, 0x4F, 0xE8,
|
||||||
|
0x9D, 0x76, 0xF9, 0x27, 0x97, 0x39, 0x6F, 0x51, 0xC2, 0x1A, 0xAA, 0x35, 0x4F, 0x1D, 0xD6, 0x7D, 0xB6, 0xFA, 0x93, 0x5B, 0xB5, 0x26, 0x75, 0x37, 0x1F, 0xA0, 0x6D, 0x10, 0x3F, 0xFC, 0x92, 0x32, 0xB2, 0x43, 0xC5, 0xA2, 0xF8, 0xCC, 0x91, 0xC2, 0x42, 0xB0, 0x17, 0x69, 0x9A, 0x7B, 0x56, 0x09, 0xF4, 0x5A, 0xE2, 0x38, 0x4E, 0xCC, 0x80, 0xDD, 0x60, 0x12, 0x4D, 0x31, 0xA7, 0x1E, 0x8F, 0x36,
|
||||||
|
0x1B, 0xB5, 0x41, 0x66, 0xD7, 0xBC, 0x22, 0x9C, 0xD8, 0x61, 0xF3, 0xCB, 0x8B, 0xBA, 0x2C, 0x97, 0x40, 0x15, 0xCF, 0x0B, 0x83, 0xC6, 0x96, 0xF1, 0xCC, 0x56, 0xEB, 0x8B, 0xBB, 0x51, 0x0A, 0xD9, 0xF2, 0x6B, 0x29, 0x75, 0x39, 0x7E, 0x55, 0x19, 0xFC, 0x71, 0xDD, 0x08, 0xBF, 0x23, 0xA6, 0xC9, 0x14, 0xB1, 0x77, 0x1B, 0xFC, 0xA5, 0x1F, 0x3F, 0x78, 0xC5, 0x97, 0xD0, 0x5E, 0xF5, 0xBB, 0x6B,
|
||||||
|
0x2A, 0xE9, 0x88, 0xA9, 0x19, 0x4D, 0xF0, 0x7C, 0x31, 0x46, 0x94, 0x05, 0x5C, 0xF9, 0x71, 0xE3, 0xC2, 0x65, 0xA8, 0x3B, 0xF5, 0x4E, 0x0E, 0x67, 0x43, 0x01, 0xA8, 0x34, 0xD4, 0x7F, 0x17, 0x5B, 0x84, 0x04, 0xD0, 0xE3, 0x1B, 0xA8, 0xE1, 0x04, 0x87, 0x51, 0xA2, 0x38, 0xF6, 0x4B, 0xE0, 0x6B, 0x42, 0x96, 0x2B, 0xC1, 0x8D, 0x59, 0xBB, 0xF3, 0xAF, 0x02, 0xE8, 0x26, 0x83, 0x08, 0x48, 0xD6,
|
||||||
|
0x5C, 0xC6, 0x03, 0x57, 0xE2, 0x8C, 0xAF, 0x09, 0xB9, 0xE3, 0x75, 0x17, 0xAF, 0x44, 0x21, 0x57, 0x01, 0x8B, 0xDE, 0x72, 0x24, 0xB0, 0xE5, 0x80, 0xDD, 0xB8, 0x6A, 0x21, 0x5F, 0xE8, 0xAF, 0xCB, 0xA2, 0x37, 0x93, 0x56, 0xB6, 0x47, 0x68, 0xD1, 0xB9, 0x12, 0xCB, 0x61, 0x8F, 0x75, 0x30, 0x8B, 0xB7, 0xEB, 0x66, 0xDC, 0x3C, 0x0E, 0x6D, 0x2D, 0x50, 0x8B, 0x6B, 0x43, 0xB2, 0xDC, 0x7B, 0xA3,
|
||||||
|
0x92, 0x3B, 0x77, 0xCC, 0x2F, 0x6A, 0x3E, 0xD0, 0x64, 0x12, 0xA4, 0xCE, 0xE9, 0x83, 0x9C, 0xB2, 0xF5, 0x31, 0x50, 0x9D, 0xCB, 0x62, 0x2B, 0xA2, 0x1D, 0x8E, 0xF7, 0xC8, 0x9D, 0x42, 0x2D, 0x71, 0x4B, 0xBC, 0xF5, 0x23, 0x8A, 0xF0, 0x2C, 0x9B, 0x40, 0x77, 0xED, 0x18, 0xAE, 0xD2, 0x10, 0xF3, 0x21, 0x53, 0x03, 0x9F, 0x7A, 0xEE, 0xD0, 0x9A, 0xE0, 0x1F, 0xBF, 0xFE, 0x9C, 0x39, 0x11, 0xF2,
|
||||||
|
0x23, 0xB3, 0xEC, 0x9E, 0x1E, 0xFD, 0x9A, 0x26, 0x87, 0xF4, 0x51, 0x39, 0x67, 0x1B, 0xDA, 0x3D, 0x7C, 0xC5, 0x16, 0xEF, 0x0C, 0x8F, 0x3D, 0xD7, 0x58, 0x31, 0x4C, 0x78, 0x07, 0x88, 0xFA, 0x0D, 0xE5, 0x13, 0x61, 0x74, 0xCA, 0x0D, 0x7D, 0xEA, 0x15, 0x93, 0x4F, 0xBD, 0x02, 0x47, 0xA1, 0x5D, 0xC7, 0x84, 0xD3, 0x4A, 0xB7, 0x31, 0x86, 0x09, 0x64, 0xA4, 0x2E, 0x57, 0x15, 0x73, 0xCA, 0x4E,
|
||||||
|
0x85, 0x0D, 0x64, 0x46, 0xB7, 0x78, 0x53, 0xDB, 0xA8, 0x2E, 0x7E, 0x99, 0xBA, 0x2C, 0xC8, 0x5C, 0x0F, 0xAA, 0x67, 0x82, 0x49, 0xC0, 0xFE, 0x6C, 0xB4, 0xCE, 0x14, 0xAD, 0xDE, 0xC0, 0x53, 0x9B, 0x7E, 0xB1, 0xD7, 0x42, 0xA3, 0x35, 0xBE, 0x5B, 0xB0, 0xE0, 0x2C, 0x6F, 0xFA, 0x86, 0xDC, 0x79, 0x3A, 0xA6, 0x28, 0xE7, 0x1D, 0x5B, 0xAD, 0x49, 0xC4, 0xE9, 0x76, 0xD2, 0x8F, 0xE4, 0x63, 0xBD,
|
||||||
|
0xF7, 0xD9, 0x33, 0x90, 0xE7, 0x16, 0xC5, 0x07, 0x46, 0xBF, 0xDD, 0x02, 0xFA, 0x4B, 0x77, 0xEF, 0x8D, 0xE6, 0x3A, 0xDA, 0xA4, 0x74, 0x17, 0x08, 0x7E, 0x95, 0xF3, 0x5D, 0x3A, 0x26, 0x69, 0xD5, 0x3D, 0x2C, 0x91, 0x00, 0xFD, 0x6A, 0xDB, 0x46, 0x06, 0x82, 0xCF, 0xA5, 0x59, 0x35, 0x22, 0xB8, 0x0B, 0xF9, 0x62, 0x95, 0x75, 0xCA, 0xFB, 0x23, 0x80, 0x3D, 0x0F, 0xB6, 0x4A, 0x01, 0xA7, 0x29,
|
||||||
|
0x59, 0xA2, 0x72, 0xD2, 0x0E, 0x86, 0x62, 0xF6, 0x7B, 0x5E, 0x16, 0x6B, 0x87, 0xAC, 0x0C, 0xA2, 0x24, 0x52, 0xCC, 0x04, 0x2D, 0xEB, 0xAE, 0x53, 0xE8, 0x41, 0x0E, 0x72, 0x8E, 0xEC, 0xA6, 0x1C, 0xBE, 0x5D, 0xE7, 0xAC, 0x52, 0x8D, 0x25, 0x9D, 0xF7, 0x65, 0x3F, 0x1E, 0xC4, 0x94, 0xE9, 0x4F, 0x6E, 0xCE, 0x43, 0xBE, 0x07, 0x8E, 0x36, 0xDE, 0x97, 0x5D, 0xF3, 0x9E, 0x32, 0xEE, 0x7C, 0x3F,
|
||||||
|
0x09, 0xC7, 0x25, 0x52, 0xA9, 0x40, 0xB5, 0x92, 0x12, 0xEB, 0xB1, 0xD3, 0x34, 0xE3, 0x62, 0xC2, 0x35, 0x70, 0xB3, 0x98, 0x60, 0x40, 0x91, 0xD4, 0x2D, 0xC0, 0xA0, 0xDB, 0xB2, 0x03, 0x7B, 0x46, 0xF8, 0x83, 0x24, 0x75, 0xC7, 0x19, 0xB9, 0x78, 0xC8, 0x10, 0x90, 0xEC, 0x76, 0x0A, 0xD2, 0xAB, 0x8C, 0x1E, 0x9E, 0x32, 0xF1, 0x50, 0x69, 0xA7, 0x05, 0xD4, 0x15, 0x83, 0x66, 0xBA, 0xD1, 0x97,
|
||||||
|
0xE9, 0x81, 0xB4, 0xFA, 0x6E, 0xDE, 0x29, 0xCC, 0x3A, 0x9F, 0x26, 0x55, 0x96, 0x1D, 0x43, 0xD6, 0x92, 0xFC, 0x13, 0x80, 0xE0, 0xBE, 0x14, 0x68, 0x87, 0x5B, 0x22, 0x4E, 0x32, 0xCD, 0x5A, 0xDD, 0x99, 0x0D, 0xCF, 0x47, 0x30, 0xEE, 0x5E, 0x3D, 0x2B, 0xAB, 0xDA, 0x4C, 0xB6, 0x65, 0x3E, 0x2C, 0xF1, 0x5A, 0xDF, 0x70, 0xAE, 0xD0, 0x1C, 0xBE, 0x7A, 0x4C, 0xAC, 0xCB, 0x25, 0x51, 0x1B, 0x6C,
|
||||||
|
0x2E, 0x4D, 0x1B, 0x37, 0x93, 0x00, 0x55, 0x70, 0xE5, 0x83, 0x45, 0xC6, 0x79, 0xF3, 0xB6, 0x7E, 0x07, 0x5B, 0xC9, 0x4A, 0x24, 0x71, 0xF9, 0x00, 0xAA, 0xE6, 0xC6, 0x6E, 0xF7, 0x89, 0x27, 0xAE, 0x35, 0x68, 0xB2, 0xE4, 0x85, 0xA4, 0xD3, 0x97, 0xF2, 0x55, 0x7F, 0x31, 0x21, 0xFE, 0xA1, 0x7C, 0x00, 0xC3, 0x84, 0x10, 0x29, 0x87, 0x3F, 0xEA, 0x2D, 0xF8, 0x6D, 0x39, 0xE0, 0x92, 0xFC, 0xAD,
|
||||||
|
0xDB, 0x9C, 0xE6, 0x5D, 0xC3, 0xF2, 0xA6, 0xBD, 0x0B, 0x62, 0xFD, 0xAA, 0x04, 0x65, 0x2C, 0x4F, 0xE7, 0xA3, 0x2F, 0xF0, 0xAF, 0x9B, 0x50, 0xC9, 0x33, 0x76, 0x0C, 0x92, 0x43, 0xB8, 0x1A, 0x72, 0xF2, 0x52, 0x90, 0x09, 0x63, 0x14, 0x4D, 0x02, 0x70, 0x16, 0xC3, 0x99, 0xDE, 0x85, 0x58, 0xCD, 0x4C, 0xAC, 0x38, 0xEE, 0x54, 0xDC, 0x9F, 0x59, 0x8D, 0x1E, 0x9B, 0xB8, 0x08, 0x7A, 0x3E, 0x5F,
|
||||||
|
0x04, 0x87, 0xB9, 0x79, 0x21, 0x81, 0x3D, 0x17, 0x8E, 0xD8, 0x1F, 0x35, 0xD5, 0xA1, 0xDF, 0x8F, 0x15, 0xC0, 0x67, 0x88, 0x3B, 0x1C, 0xD8, 0x8F, 0x45, 0xEF, 0xA2, 0x19, 0xE1, 0x60, 0xC8, 0x95, 0xD6, 0x16, 0xC1, 0x3B, 0xF5, 0xC4, 0x80, 0xE4, 0xB1, 0xD8, 0x62, 0x43, 0x06, 0xB2, 0x13, 0xE7, 0x26, 0x8F, 0x61, 0xCA, 0x74, 0xB9, 0x03, 0x6A, 0xC9, 0xDC, 0x45, 0x5F, 0xEF, 0xA7, 0x23, 0xC2,
|
||||||
|
0x6A, 0x40, 0x13, 0xD3, 0x49, 0xE8, 0x9D, 0xCD, 0x4D, 0xB1, 0x76, 0x58, 0x89, 0x3F, 0x1F, 0xB3, 0x74, 0x45, 0x11, 0xDD, 0xC3, 0x5E, 0x79, 0x24, 0xBA, 0x59, 0xD6, 0x12, 0xA8, 0x7E, 0x31, 0x4C, 0x03, 0x7F, 0xE0, 0x9D, 0x72, 0xAB, 0x2A, 0x40, 0x8D, 0x0F, 0xA3, 0xF6, 0x70, 0xD1, 0x37, 0x6D, 0xBE, 0xF9, 0x0C, 0x99, 0x41, 0x16, 0xFD, 0x36, 0xB0, 0x0D, 0x80, 0x2A, 0xCE, 0x4D, 0x8C, 0xD6,
|
||||||
|
0xB2, 0xED, 0x58, 0xA7, 0x2B, 0x64, 0x11, 0x6C, 0xF3, 0x2D, 0x9E, 0xE7, 0xC5, 0x6D, 0xFA, 0x54, 0xCA, 0xEE, 0x98, 0x77, 0x06, 0xE8, 0x9F, 0xF6, 0x0A, 0x88, 0x6D, 0x49, 0xFE, 0x23, 0xD2, 0xEE, 0xB0, 0x63, 0x2B, 0x4A, 0x1D, 0x56, 0xCA, 0xF7, 0x5D, 0x34, 0xC7, 0x25, 0x84, 0x52, 0xA8, 0x95, 0x43, 0x7D, 0x19, 0xAF, 0xE4, 0x88, 0xA7, 0x79, 0x4C, 0xE8, 0xBE, 0x98, 0x70, 0x14, 0xEA, 0x2F,
|
||||||
|
0x97, 0x0B, 0x75, 0xFE, 0x92, 0xC0, 0xDF, 0x86, 0x03, 0xC1, 0x47, 0x15, 0x09, 0xAC, 0x30, 0x83, 0x02, 0x18, 0xAB, 0x55, 0xB5, 0x48, 0x31, 0x64, 0xA9, 0x2B, 0xCD, 0x96, 0xBD, 0x5A, 0x70, 0x9B, 0x3D, 0x8C, 0xFB, 0xB7, 0xD7, 0x8C, 0x09, 0xA1, 0x7A, 0xE1, 0x4C, 0x95, 0xE4, 0x0C, 0xF4, 0x1E, 0xDB, 0x56, 0xCD, 0x6B, 0x30, 0x4E, 0xD0, 0x15, 0x91, 0x62, 0x32, 0xFA, 0x01, 0xBA, 0x57, 0x7C,
|
||||||
|
0x25, 0xDE, 0xC4, 0x35, 0x0E, 0x52, 0xAE, 0x37, 0x5C, 0xDA, 0x92, 0x7F, 0xF1, 0x60, 0xD4, 0x96, 0xE2, 0x64, 0x3A, 0xFC, 0x16, 0xCE, 0x91, 0xDE, 0xC2, 0x41, 0xED, 0x03, 0x38, 0x8B, 0x0D, 0xC1, 0x20, 0xCA, 0x0A, 0x7A, 0x32, 0xE7, 0x6D, 0x18, 0xBE, 0x12, 0xB0, 0x68, 0x3A, 0xBC, 0x5F, 0x30, 0xB2, 0x89, 0x05, 0xF6, 0xBB, 0x5F, 0x08, 0xF3, 0xC3, 0x21, 0xAB, 0x50, 0x88, 0xDB, 0xA5, 0x42,
|
||||||
|
0xB7, 0x4D, 0x64, 0x89, 0xD8, 0x78, 0x22, 0xF8, 0xA2, 0x6E, 0x28, 0xB9, 0x4E, 0xA4, 0x23, 0x47, 0xB4, 0x7D, 0xD7, 0x8A, 0x6A, 0x0C, 0x7B, 0x50, 0x1A, 0x82, 0x55, 0x74, 0xA9, 0xD8, 0xEB, 0x53, 0x6B, 0xE5, 0x4F, 0x9E, 0x60, 0xAF, 0x46, 0xD4, 0x54, 0x89, 0xF2, 0x1D, 0xA3, 0x80, 0xCF, 0x75, 0xEE, 0x3D, 0x97, 0x14, 0x80, 0xDD, 0x9C, 0x71, 0x40, 0xE2, 0x6A, 0xCB, 0x39, 0x1F, 0x65, 0xF8,
|
||||||
|
0x84, 0x9D, 0x1E, 0xAC, 0x41, 0xE8, 0x95, 0x46, 0xC9, 0x1B, 0xEC, 0x3A, 0xCD, 0x19, 0x76, 0xF4, 0x33, 0x1B, 0xC3, 0x29, 0xA6, 0xE8, 0xBA, 0x27, 0xFA, 0x9F, 0xB5, 0xE6, 0x21, 0x48, 0x2D, 0x81, 0xA5, 0x34, 0xB5, 0x25, 0xEE, 0x1B, 0x93, 0xFD, 0x01, 0x71, 0xC8, 0x2D, 0xDC, 0x46, 0x03, 0x9B, 0x20, 0x68, 0xD7, 0xA7, 0x2E, 0x45, 0xB6, 0x28, 0x86, 0xA1, 0x0B, 0x7D, 0xED, 0x97, 0xC8, 0x07,
|
||||||
|
0xD2, 0x2F, 0xF4, 0xC6, 0x04, 0x5F, 0xB8, 0x0C, 0x84, 0x58, 0xAB, 0x7C, 0x65, 0xE4, 0x91, 0xC1, 0x5F, 0x9B, 0x4C, 0xEF, 0x58, 0x3D, 0x9D, 0x66, 0xD0, 0x33, 0x10, 0x65, 0xCE, 0x9C, 0xB8, 0xF4, 0x01, 0xD2, 0x6F, 0x8E, 0xCE, 0x3D, 0x7F, 0xB4, 0x32, 0xA9, 0x44, 0x63, 0x8F, 0xF9, 0x55, 0xC7, 0xE2, 0x4B, 0xBD, 0x5B, 0xF1, 0x6D, 0x1C, 0xEB, 0x51, 0xD6, 0x16, 0xB6, 0x48, 0x29, 0x77, 0x3E,
|
||||||
|
0xE7, 0x6D, 0x50, 0x75, 0x8D, 0x2A, 0xD5, 0x69, 0xF0, 0x35, 0xDF, 0x01, 0x9F, 0x2A, 0x54, 0x0B, 0xD5, 0x72, 0xAE, 0x03, 0x79, 0xDA, 0x1B, 0x8D, 0x47, 0x7A, 0xC0, 0x3D, 0x88, 0x56, 0x13, 0x63, 0x95, 0x42, 0xF9, 0x12, 0x51, 0xBF, 0x20, 0x5E, 0xE1, 0x95, 0xED, 0x0B, 0xB9, 0x24, 0xAF, 0x6F, 0x31, 0x91, 0x15, 0x0A, 0x87, 0xCF, 0x94, 0xC1, 0x66, 0x33, 0xF9, 0x5A, 0xA6, 0xE3, 0xBC, 0x5D,
|
||||||
|
0x17, 0xB1, 0x11, 0xD1, 0xA3, 0xFB, 0x3A, 0x9D, 0xB5, 0x22, 0x8D, 0xBD, 0x44, 0xFE, 0xB0, 0x86, 0x26, 0xF7, 0x3B, 0xC8, 0x8A, 0x2F, 0xBE, 0xEF, 0x08, 0xE1, 0x98, 0xF6, 0x06, 0xE9, 0x7B, 0xDA, 0x17, 0xC2, 0x5C, 0x7C, 0xA6, 0xEA, 0x70, 0xD2, 0x48, 0x11, 0x7B, 0xCF, 0x6C, 0x3E, 0x84, 0x1A, 0xA4, 0xFE, 0x76, 0xDB, 0xAA, 0x34, 0x4A, 0x02, 0xAD, 0x8D, 0xC6, 0x6F, 0x05, 0x85, 0x12, 0x95,
|
||||||
|
0xC9, 0x82, 0xEA, 0x43, 0x1F, 0x56, 0x7B, 0x0F, 0x50, 0xD7, 0x70, 0x5C, 0xCE, 0x78, 0x3A, 0xDE, 0x4A, 0x93, 0x13, 0xE2, 0x5E, 0xA8, 0x51, 0x70, 0xAF, 0x5A, 0x22, 0x6B, 0xAD, 0x34, 0xBF, 0x47, 0xA5, 0x87, 0x09, 0xE3, 0x36, 0x0E, 0x9B, 0x2C, 0x89, 0xC2, 0x17, 0x52, 0x9E, 0xDB, 0xBD, 0xEA, 0x60, 0x3C, 0xC1, 0x50, 0x23, 0x65, 0xF4, 0xDD, 0x7A, 0x11, 0x41, 0x98, 0xDA, 0x36, 0xFD, 0x4E,
|
||||||
|
0x3E, 0x00, 0x99, 0x67, 0xC1, 0xAA, 0xE5, 0xC9, 0x94, 0x3C, 0xF5, 0x0F, 0x18, 0x9A, 0x06, 0xC4, 0x62, 0xB8, 0x6F, 0x9F, 0x0D, 0xFD, 0x1F, 0xCD, 0x2C, 0x87, 0xDA, 0x49, 0xCB, 0x92, 0x24, 0x6A, 0xF1, 0x39, 0xD2, 0xB4, 0x8F, 0x66, 0xF1, 0xBB, 0x58, 0xF5, 0xA7, 0xE2, 0x31, 0x08, 0x54, 0x2A, 0xD3, 0x00, 0x89, 0xEB, 0xB4, 0x7D, 0x98, 0x15, 0x58, 0xD0, 0xF2, 0x24, 0x53, 0xAF, 0x69, 0xA3,
|
||||||
|
0x77, 0xD9, 0x28, 0xF6, 0x84, 0x07, 0x33, 0x6C, 0x1F, 0xA9, 0x7F, 0xE2, 0xAE, 0x56, 0xEC, 0x7F, 0x1D, 0xE8, 0x2A, 0x4E, 0xC1, 0x82, 0x42, 0x96, 0xE7, 0x3E, 0xA4, 0x19, 0x81, 0x58, 0xE4, 0xB2, 0x1E, 0x5A, 0x73, 0x29, 0x4F, 0xD8, 0x40, 0x04, 0x73, 0x25, 0x40, 0x8C, 0x77, 0xF8, 0x96, 0x74, 0xB2, 0x9C, 0x68, 0x32, 0x0C, 0xCC, 0x40, 0xB9, 0x30, 0xA1, 0x65, 0xB9, 0x7F, 0xCD, 0x20, 0xE3,
|
||||||
|
0xBB, 0x5D, 0xAD, 0x38, 0x50, 0xDC, 0x8F, 0xF4, 0x5B, 0xBF, 0x2B, 0x4B, 0x6A, 0xD6, 0x2F, 0x97, 0x3D, 0xA5, 0xD5, 0x78, 0x35, 0xDC, 0x68, 0xB9, 0x01, 0x73, 0xC6, 0xFB, 0x2D, 0xD5, 0x07, 0x7E, 0x9A, 0xCA, 0xFE, 0xA3, 0x12, 0xC4, 0x7E, 0xB2, 0x98, 0xD8, 0x67, 0xB7, 0x11, 0x5C, 0xCE, 0x45, 0x17, 0xF6, 0x4A, 0xDC, 0xA3, 0x59, 0xFC, 0x70, 0xE5, 0x88, 0x0D, 0x3B, 0xE9, 0x0A, 0x91, 0x2E,
|
||||||
|
0x43, 0x1C, 0x8A, 0xCD, 0x79, 0x24, 0xB4, 0x44, 0xD3, 0x03, 0x9C, 0xC6, 0x8E, 0x23, 0xB6, 0x6C, 0xFB, 0x56, 0x89, 0x05, 0xF1, 0xA6, 0x1C, 0x50, 0xF0, 0x13, 0x54, 0x98, 0x69, 0xA8, 0x4C, 0xEC, 0x30, 0x46, 0x02, 0x83, 0xE1, 0x63, 0x26, 0xF9, 0x3A, 0xC6, 0x1D, 0xE9, 0xA3, 0xC0, 0x35, 0xE4, 0x82, 0x10, 0xBC, 0x76, 0x13, 0x86, 0x19, 0x06, 0xAC, 0x45, 0xD6, 0x75, 0x9B, 0x4B, 0x65, 0xF7,
|
||||||
|
0xD2, 0xA0, 0xF2, 0x0A, 0xBC, 0x67, 0xA0, 0x14, 0x88, 0x74, 0xF9, 0x3E, 0x0C, 0xF0, 0x45, 0xCB, 0x11, 0x18, 0xC5, 0xB3, 0x5E, 0x27, 0x8F, 0xCD, 0xAA, 0x88, 0xB6, 0x20, 0x3C, 0xC0, 0x11, 0x6F, 0xC3, 0x91, 0xB5, 0x53, 0x37, 0xAB, 0x94, 0x54, 0x0C, 0x82, 0x5A, 0x44, 0x29, 0x85, 0x04, 0xA0, 0x64, 0xD1, 0x92, 0x37, 0xED, 0xC0, 0xD4, 0x93, 0x55, 0xC4, 0x20, 0xFA, 0x2A, 0xC7, 0xAD, 0x84,
|
||||||
|
0x54, 0x72, 0x34, 0x5E, 0xE0, 0x3B, 0xF0, 0x55, 0xE1, 0x33, 0x5E, 0xD8, 0x82, 0xA4, 0x5B, 0x8B, 0xAC, 0xE0, 0x6F, 0x3F, 0x98, 0xD7, 0x7A, 0x36, 0x6B, 0x43, 0xD6, 0xE6, 0x78, 0xF2, 0x89, 0xDC, 0x5D, 0x1B, 0xE6, 0x72, 0xF4, 0x1A, 0xD9, 0x70, 0xEA, 0xAE, 0xDC, 0x94, 0xFB, 0x6B, 0xC7, 0x49, 0xF2, 0x2B, 0x55, 0xAD, 0x6A, 0x41, 0x61, 0x2D, 0xF0, 0x80, 0x67, 0xA9, 0x59, 0xE1, 0x16, 0x05,
|
||||||
|
0xEC, 0x24, 0xB2, 0x93, 0x1E, 0x83, 0xC3, 0x09, 0x97, 0xB6, 0x1A, 0xAD, 0x6F, 0x20, 0xE1, 0x00, 0x78, 0x4F, 0x2B, 0xF8, 0x10, 0x4A, 0xE6, 0x06, 0xFA, 0x28, 0x63, 0x09, 0x95, 0x2E, 0x52, 0x22, 0xAE, 0x3F, 0xD0, 0x29, 0x8E, 0xBA, 0x46, 0x21, 0x9D, 0x2F, 0x67, 0x0A, 0xB5, 0x51, 0xE0, 0x1F, 0x7B, 0xB6, 0x0C, 0xE0, 0x24, 0x99, 0xB5, 0xDB, 0x19, 0x39, 0xBC, 0x07, 0x8F, 0x3C, 0x7C, 0xBA,
|
||||||
|
0x8B, 0x41, 0xCB, 0xEA, 0x4B, 0xAC, 0x2F, 0x71, 0xCC, 0x48, 0xEB, 0x2B, 0xCA, 0x42, 0xB3, 0x31, 0xED, 0x9C, 0xCF, 0x80, 0xA9, 0xC4, 0x5B, 0xB4, 0x9D, 0x83, 0xC9, 0xAC, 0x47, 0xB8, 0xCF, 0xA0, 0xFB, 0x83, 0x66, 0xA5, 0x07, 0x5F, 0xCE, 0x85, 0xC1, 0x4A, 0xD1, 0x23, 0x7E, 0x32, 0x96, 0xAA, 0x3C, 0xCC, 0x8C, 0x73, 0xF3, 0x04, 0x7A, 0x4D, 0xA1, 0x8B, 0xE4, 0x4C, 0xF0, 0xA2, 0x69, 0xD9,
|
||||||
|
0x0F, 0xA5, 0x77, 0x03, 0x6B, 0xDD, 0x59, 0xFC, 0x20, 0x8E, 0x67, 0x9D, 0x54, 0xFB, 0x8F, 0x5E, 0xC1, 0x44, 0x0A, 0x63, 0x15, 0x8E, 0x2C, 0x76, 0x1A, 0xDE, 0x54, 0x1E, 0xF7, 0x66, 0x00, 0x74, 0x36, 0x0D, 0xC5, 0x4A, 0xED, 0x79, 0x39, 0xFC, 0x01, 0x77, 0xEC, 0xA1, 0xC9, 0xF4, 0x13, 0x66, 0xDC, 0x5A, 0x33, 0xA3, 0x48, 0xC5, 0x28, 0xFA, 0x63, 0xD0, 0x26, 0x6E, 0xC3, 0x1E, 0x30, 0x4F,
|
||||||
|
0xC6, 0x60, 0xF3, 0x2A, 0xBC, 0x90, 0x1A, 0xA5, 0x79, 0x35, 0xD6, 0x08, 0x7F, 0x12, 0xDA, 0x73, 0x1C, 0x88, 0xB1, 0xDC, 0xEF, 0x3B, 0xD3, 0xF3, 0x47, 0xBA, 0x35, 0x78, 0x8C, 0x29, 0xE6, 0x93, 0x59, 0xE9, 0x96, 0x2F, 0xD3, 0xAD, 0x25, 0x9A, 0x5C, 0xB0, 0x31, 0x54, 0x6C, 0x42, 0x8D, 0xC0, 0x01, 0xFE, 0x20, 0xBC, 0x5F, 0xD8, 0x88, 0xAA, 0x3F, 0x0B, 0xB4, 0x37, 0x84, 0xD5, 0xAD, 0xFE,
|
||||||
|
0x22, 0x39, 0x89, 0xA1, 0x45, 0xCF, 0x33, 0xE7, 0xBB, 0x57, 0xF5, 0xA9, 0xC2, 0x39, 0xA1, 0x27, 0xF1, 0xCB, 0x33, 0x51, 0x74, 0x9E, 0x5F, 0x22, 0x99, 0x6C, 0xE9, 0xA7, 0xD9, 0xC7, 0x3E, 0xAE, 0xD6, 0x20, 0xB6, 0x6C, 0x1C, 0x86, 0x4D, 0xDA, 0x12, 0xE4, 0x90, 0x1C, 0xB8, 0x0B, 0xE6, 0x4B, 0x72, 0x9B, 0x82, 0xEA, 0x19, 0x38, 0x6E, 0x13, 0xEA, 0x7C, 0x9D, 0xF6, 0x59, 0x01, 0x66, 0x94,
|
||||||
|
0xB5, 0xDB, 0x52, 0xE5, 0x0B, 0x61, 0x84, 0x49, 0x02, 0x96, 0x14, 0x46, 0x6D, 0xD4, 0x5A, 0xB7, 0x48, 0x67, 0xA5, 0x20, 0xC2, 0x09, 0xB2, 0x7F, 0xCE, 0x05, 0x17, 0x5A, 0x0C, 0x4C, 0x6E, 0x0F, 0x7E, 0x50, 0x89, 0xF7, 0x5B, 0xE2, 0xBF, 0x6F, 0xB7, 0x41, 0x7C, 0xF0, 0x9C, 0xD3, 0x80, 0xAF, 0x2B, 0xD2, 0x3C, 0x69, 0xB3, 0x93, 0xE2, 0xBB, 0x57, 0xC8, 0x46, 0x1D, 0x90, 0xE9, 0x43, 0x7C,
|
||||||
|
0x0C, 0x70, 0x1E, 0xBF, 0x78, 0xF5, 0xB3, 0xDB, 0x68, 0xC5, 0x85, 0xE2, 0x2B, 0x91, 0x0F, 0xE4, 0x81, 0x06, 0xFE, 0x90, 0xE2, 0x42, 0xEC, 0x31, 0x51, 0xFC, 0x90, 0xC1, 0x7C, 0x9F, 0xBB, 0xEE, 0x2B, 0xC3, 0x3B, 0x10, 0x9F, 0x37, 0x05, 0x92, 0x2C, 0x62, 0xCE, 0x28, 0x5A, 0x39, 0x1F, 0x61, 0xEE, 0x12, 0xA8, 0x50, 0xD5, 0x2B, 0x4B, 0x09, 0x90, 0x27, 0xDF, 0x75, 0xBF, 0xA5, 0x2C, 0xD2,
|
||||||
|
0x99, 0xF9, 0xA8, 0x37, 0x93, 0x28, 0x1A, 0x9E, 0x2F, 0xEE, 0x24, 0x5B, 0xB3, 0xF0, 0x74, 0x3D, 0x9C, 0xD3, 0x59, 0x7A, 0x2A, 0x69, 0x8A, 0xC5, 0xA3, 0x74, 0x45, 0xE2, 0x24, 0xF8, 0x36, 0x5F, 0x94, 0xE4, 0xA6, 0xCC, 0x7B, 0xEC, 0x49, 0xD1, 0xF9, 0xA5, 0x08, 0xBC, 0x76, 0xFB, 0x96, 0xC3, 0x41, 0x8B, 0xC7, 0x05, 0x7D, 0xF1, 0xA7, 0x74, 0xFC, 0x64, 0xAF, 0x32, 0x52, 0x1A, 0xE3, 0x58,
|
||||||
|
0x3F, 0x27, 0x63, 0xD8, 0x55, 0xCA, 0x43, 0x81, 0x51, 0xAC, 0x77, 0xCE, 0x09, 0x4A, 0xA8, 0x26, 0xC3, 0x1D, 0x36, 0xBD, 0xAA, 0xD9, 0x57, 0x19, 0x10, 0xD8, 0x2E, 0xAE, 0x63, 0x8A, 0x1B, 0xDA, 0x74, 0x03, 0x4B, 0x65, 0x27, 0xAE, 0x69, 0x82, 0x1D, 0x4F, 0x8A, 0xDE, 0x48, 0xB1, 0x04, 0xDF, 0x78, 0x56, 0xF8, 0x18, 0x9D, 0x60, 0x20, 0xCF, 0x3E, 0xA0, 0x05, 0xCB, 0xF6, 0x67, 0x85, 0xC6,
|
||||||
|
0x78, 0xCF, 0x87, 0x02, 0xEC, 0xA4, 0x69, 0xFD, 0xD5, 0x11, 0x3C, 0x9F, 0x87, 0xD9, 0x64, 0xF8, 0x8A, 0x6C, 0xE7, 0x4E, 0x01, 0x15, 0x9C, 0xF3, 0xB7, 0x66, 0x97, 0x02, 0xD2, 0x40, 0xB8, 0x4F, 0xAC, 0x16, 0xFF, 0x8D, 0xDD, 0x0E, 0xC5, 0x33, 0xB4, 0xE8, 0x69, 0x1A, 0x32, 0x8C, 0x6A, 0x2D, 0xA2, 0x10, 0xB3, 0x6D, 0xE5, 0x32, 0xB8, 0x85, 0x14, 0xE6, 0x4D, 0x7B, 0x96, 0x3A, 0xB7, 0x07,
|
||||||
|
0x52, 0xE7, 0xB9, 0x4A, 0x74, 0x21, 0xBD, 0x0B, 0x8F, 0xBA, 0x6A, 0xF4, 0x21, 0x36, 0xBD, 0x05, 0x46, 0xD7, 0xA1, 0x81, 0xEF, 0xCA, 0x73, 0x47, 0x84, 0x38, 0xF6, 0x55, 0x7A, 0xEB, 0x9B, 0x29, 0xCF, 0x7C, 0xBA, 0x33, 0x55, 0x99, 0xF5, 0x5E, 0x94, 0x25, 0xC7, 0xA2, 0xEC, 0xD3, 0x53, 0xBB, 0xEB, 0x3B, 0xD1, 0x93, 0x44, 0xCA, 0x53, 0xEF, 0x6A, 0x8E, 0xBD, 0x10, 0xDC, 0x22, 0xED, 0xAA,
|
||||||
|
0x99, 0x0F, 0x17, 0xAB, 0x90, 0x33, 0xE0, 0x3F, 0x5F, 0x29, 0xDD, 0x4D, 0xC4, 0x7D, 0x99, 0x58, 0xB3, 0x2E, 0x0E, 0x62, 0x3D, 0x90, 0x2E, 0xE1, 0x09, 0xCE, 0xB3, 0x25, 0xC8, 0x0E, 0x6F, 0x58, 0xF1, 0x42, 0x9E, 0xD4, 0x1F, 0x76, 0x40, 0x06, 0xD2, 0x7C, 0x3A, 0x5A, 0x79, 0x21, 0x99, 0x0B, 0x66, 0x86, 0x26, 0x5D, 0x1C, 0x82, 0x00, 0xAA, 0x37, 0xD9, 0x29, 0x5D, 0xA3, 0x49, 0x71, 0x30,
|
||||||
|
0xF8, 0x80, 0x40, 0xC8, 0xF6, 0x57, 0x82, 0xA9, 0xCD, 0x7B, 0x9A, 0x00, 0x65, 0xE5, 0x18, 0xEE, 0x77, 0x94, 0xF7, 0xBB, 0xA7, 0x1E, 0xBF, 0x5A, 0xA5, 0x71, 0x49, 0x86, 0xA1, 0x3A, 0xC0, 0x90, 0x0A, 0x6A, 0x13, 0x61, 0xEE, 0xBF, 0xA4, 0xE1, 0x4A, 0xB0, 0xFD, 0x00, 0xC0, 0x42, 0xF7, 0xC7, 0x47, 0xDB, 0xA4, 0xFB, 0xB6, 0xDA, 0x96, 0x62, 0x11, 0xB1, 0x76, 0xFA, 0xC9, 0x83, 0xD7, 0x63,
|
||||||
|
0xC1, 0x55, 0xE4, 0x68, 0x1C, 0x9F, 0x05, 0xEF, 0x4B, 0x1B, 0xF8, 0xB1, 0x3B, 0xA7, 0x0D, 0xCD, 0x3F, 0xC6, 0x22, 0x50, 0xE0, 0x6B, 0xFC, 0x8E, 0x23, 0xEF, 0x1A, 0xDB, 0x5E, 0xFC, 0x22, 0xD7, 0xA7, 0xE3, 0x8C, 0xB1, 0x37, 0x84, 0x2A, 0x68, 0x8D, 0x14, 0x6C, 0xD6, 0xA8, 0x85, 0x2F, 0x6F, 0xAF, 0x19, 0x79, 0x4B, 0x2E, 0x6E, 0x40, 0xF3, 0xCB, 0x4B, 0x95, 0x3A, 0x15, 0x03, 0xB0, 0x1F,
|
||||||
|
0x2D, 0xA6, 0x8C, 0x26, 0xD9, 0xC2, 0x72, 0x25, 0xB7, 0x8D, 0x31, 0x71, 0xD0, 0x52, 0x85, 0x68, 0x13, 0x5F, 0x89, 0x33, 0x7D, 0x06, 0x48, 0x35, 0xC3, 0x63, 0x95, 0x30, 0xB0, 0x76, 0x4C, 0x80, 0x2D, 0x3E, 0xC3, 0x04, 0x52, 0xE6, 0x0E, 0xC9, 0xEF, 0x2F, 0x9A, 0x4C, 0x13, 0x5E, 0xE0, 0x91, 0x14, 0xF0, 0x06, 0xCD, 0x9C, 0xE4, 0x23, 0xBA, 0x86, 0x18, 0xE8, 0x57, 0xBF, 0x8E, 0x46, 0xDE,
|
||||||
|
0x72, 0x06, 0xBC, 0x3B, 0x7D, 0x4E, 0x34, 0xDF, 0x66, 0xD4, 0x58, 0xEA, 0x26, 0x95, 0xFD, 0xB2, 0xDA, 0x9F, 0xE8, 0xCD, 0xB4, 0x9C, 0xD4, 0x7A, 0xAC, 0xD0, 0x45, 0xEB, 0x11, 0xCC, 0x05, 0xB7, 0xF2, 0x59, 0x7A, 0xFB, 0x9A, 0x6F, 0xB3, 0x57, 0x40, 0xBC, 0xDC, 0x81, 0xF3, 0xB4, 0x08, 0xCB, 0x52, 0x6A, 0xBD, 0x8C, 0x5F, 0x0C, 0xA5, 0x53, 0x70, 0x0A, 0xD1, 0xA8, 0x74, 0xF3, 0x60, 0x97,
|
||||||
|
0x40, 0xD3, 0xFE, 0x5F, 0xAF, 0xF2, 0x89, 0x9D, 0x41, 0xA6, 0x07, 0x81, 0xBB, 0x1B, 0x44, 0x2D, 0x7F, 0x03, 0x48, 0x16, 0x5D, 0x11, 0xE6, 0x56, 0x18, 0x02, 0x73, 0xA0, 0x59, 0x8B, 0xE2, 0x67, 0x99, 0x19, 0xCA, 0x25, 0x44, 0xD8, 0x1D, 0xA0, 0x88, 0x08, 0x62, 0x21, 0x35, 0x97, 0x45, 0x7B, 0xE5, 0xA6, 0x27, 0x3F, 0xF5, 0x7D, 0xD5, 0x2A, 0xFD, 0xA0, 0x64, 0x30, 0x21, 0xCC, 0x0D, 0xE9,
|
||||||
|
0x81, 0x54, 0x12, 0x99, 0x18, 0x07, 0xCD, 0x11, 0xE5, 0x16, 0xC7, 0x49, 0xA1, 0xDC, 0x75, 0xC4, 0x57, 0xF2, 0xA8, 0x74, 0xF8, 0x8C, 0x32, 0xA1, 0xF6, 0x88, 0xD8, 0x25, 0xC2, 0x36, 0xA9, 0x45, 0x28, 0xDD, 0xA9, 0x5F, 0xBD, 0x82, 0x34, 0xE2, 0x73, 0xF9, 0xAD, 0xCF, 0x72, 0xEA, 0xBC, 0x23, 0x39, 0x95, 0xD1, 0x55, 0x1C, 0xAD, 0x38, 0x92, 0xC4, 0x43, 0xDF, 0x8B, 0x50, 0xAD, 0x34, 0xA2,
|
||||||
|
0x23, 0xB3, 0x8A, 0xE4, 0x6D, 0xBB, 0x53, 0x79, 0x61, 0x86, 0xF4, 0x6C, 0x36, 0x5D, 0xED, 0x0E, 0x93, 0x36, 0xBE, 0x28, 0xC7, 0x42, 0x6E, 0xBB, 0x0F, 0x66, 0xB6, 0x4C, 0xF9, 0x7D, 0x1C, 0xEF, 0x89, 0x71, 0x3C, 0x8E, 0x0A, 0xF5, 0x64, 0xC6, 0x25, 0x4F, 0x3B, 0x9E, 0x53, 0x19, 0x60, 0x8A, 0xFD, 0x02, 0x66, 0xE1, 0xBF, 0x6E, 0xEA, 0x5D, 0x14, 0x78, 0x02, 0xB9, 0xF8, 0x7E, 0x5C, 0xC4,
|
||||||
|
0x6B, 0xCC, 0x31, 0x45, 0xD8, 0xA4, 0x3C, 0xFB, 0xC1, 0x31, 0xB0, 0x24, 0xD0, 0x91, 0x18, 0xAD, 0xD5, 0x66, 0x84, 0x53, 0x96, 0xDF, 0x23, 0xCE, 0x49, 0xE5, 0x34, 0x94, 0x07, 0x65, 0xD4, 0x53, 0xB2, 0x00, 0xEC, 0xD5, 0x2B, 0xAB, 0x46, 0x0E, 0xB5, 0x8E, 0xE6, 0x05, 0x85, 0xC5, 0xDA, 0xAA, 0x4A, 0xB7, 0x7A, 0x30, 0x9A, 0x49, 0x0B, 0x87, 0xB2, 0xEF, 0x97, 0x18, 0x3E, 0xD7, 0x0F, 0xEE,
|
||||||
|
0x49, 0x00, 0xF7, 0x61, 0x1E, 0x81, 0x27, 0x92, 0x1B, 0x4B, 0x97, 0xDE, 0x02, 0xB9, 0x81, 0x48, 0x15, 0xE6, 0x0A, 0xEE, 0x16, 0xAB, 0x7E, 0x5C, 0x9B, 0x1B, 0xAC, 0x72, 0xDF, 0xBC, 0x98, 0x2E, 0xC9, 0x63, 0x9E, 0x4F, 0x70, 0x92, 0xE8, 0x7E, 0xD9, 0x67, 0x2E, 0xB1, 0xF7, 0x22, 0x34, 0x72, 0x0F, 0xD4, 0x16, 0x89, 0xF9, 0x24, 0xC8, 0xE1, 0x4E, 0x35, 0x62, 0xCE, 0x6F, 0x9F, 0x2C, 0x94,
|
||||||
|
0xD9, 0xA9, 0x7A, 0x95, 0xC8, 0xEB, 0x5C, 0xD4, 0xAB, 0xE6, 0x5E, 0x77, 0x3E, 0x55, 0xFB, 0x6F, 0xA0, 0xC5, 0x41, 0xB4, 0x68, 0x00, 0xFD, 0x38, 0xD5, 0x83, 0xF5, 0x21, 0x56, 0x3C, 0x0E, 0x82, 0xFB, 0x22, 0x37, 0xC4, 0x19, 0xB8, 0x59, 0x37, 0xA2, 0x1D, 0xC9, 0x57, 0x7A, 0x44, 0x9A, 0xF1, 0x54, 0xA0, 0xE9, 0x44, 0xB4, 0x5B, 0xA0, 0x71, 0x27, 0xC2, 0xA6, 0x11, 0xE8, 0x51, 0xBE, 0x77,
|
||||||
|
0x20, 0x57, 0xC0, 0x10, 0x36, 0xA7, 0x04, 0x71, 0x2D, 0x82, 0x11, 0xF3, 0xA3, 0xC6, 0x28, 0xE0, 0x33, 0x5D, 0x98, 0x7B, 0xD9, 0x4D, 0x90, 0xB5, 0x28, 0x67, 0x45, 0xC8, 0x86, 0xAE, 0xE7, 0x44, 0xA6, 0x75, 0xDE, 0x86, 0xF6, 0x26, 0xD3, 0x03, 0xFE, 0x75, 0x3F, 0xDE, 0xA6, 0x64, 0xBD, 0x1C, 0x80, 0xC2, 0x63, 0x07, 0x76, 0xD8, 0x3B, 0x04, 0x91, 0xFA, 0x78, 0x44, 0x88, 0x06, 0xFC, 0x38
|
||||||
|
};
|
||||||
126
src/button_handler.cpp
Normal file
126
src/button_handler.cpp
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
#include "button_handler.h"
|
||||||
|
#include "power_manager.h"
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
void ButtonHandler::begin(PowerManager& power, SettingsManager& settings) {
|
||||||
|
_power = &power;
|
||||||
|
_settings = &settings;
|
||||||
|
|
||||||
|
pinMode(PIN_BTN_TOP, INPUT_PULLUP);
|
||||||
|
pinMode(PIN_BTN_UP, INPUT_PULLUP);
|
||||||
|
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
|
||||||
|
|
||||||
|
Serial.println("[buttons] Initialized (G1=random, G10=next, G9=pause)");
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonEvent ButtonHandler::poll() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
bool topNow = (digitalRead(PIN_BTN_TOP) == LOW);
|
||||||
|
bool upNow = (digitalRead(PIN_BTN_UP) == LOW);
|
||||||
|
bool downNow = (digitalRead(PIN_BTN_DOWN) == LOW);
|
||||||
|
|
||||||
|
// Track press start times
|
||||||
|
if (topNow && !_btnTopPressed) {
|
||||||
|
_btnTopPressTime = now;
|
||||||
|
_btnTopPressed = true;
|
||||||
|
} else if (!topNow) {
|
||||||
|
_btnTopPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upNow && !_btnUpPressed) {
|
||||||
|
_btnUpPressTime = now;
|
||||||
|
_btnUpPressed = true;
|
||||||
|
} else if (!upNow) {
|
||||||
|
_btnUpPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downNow && !_btnDownPressed) {
|
||||||
|
_btnDownPressTime = now;
|
||||||
|
_btnDownPressed = true;
|
||||||
|
} else if (!downNow) {
|
||||||
|
_btnDownPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check combos first (higher priority)
|
||||||
|
// BTN_TOP + BTN_DOWN held 3s = deep sleep
|
||||||
|
if (_btnTopPressed && _btnDownPressed) {
|
||||||
|
unsigned long holdTime = now - max(_btnTopPressTime, _btnDownPressTime);
|
||||||
|
if (holdTime >= COMBO_HOLD_MS) {
|
||||||
|
Serial.println("[buttons] Combo: deep sleep");
|
||||||
|
_power->flashLED(255, 0, 0, 500);
|
||||||
|
return ButtonEvent::DeepSleep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTN_UP held 5s = factory reset
|
||||||
|
if (_btnUpPressed && !_btnTopPressed && !_btnDownPressed) {
|
||||||
|
if (now - _btnUpPressTime >= FACTORY_RESET_HOLD_MS) {
|
||||||
|
Serial.println("[buttons] Combo: factory reset");
|
||||||
|
_power->flashLED(255, 255, 0, 1000);
|
||||||
|
_settings->factoryReset();
|
||||||
|
return ButtonEvent::FactoryReset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single press detection (on release, with debounce)
|
||||||
|
if (!debounced(now)) {
|
||||||
|
return ButtonEvent::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTN_TOP released (was short press) — double-press detection
|
||||||
|
if (!topNow && _btnTopPressTime > 0 &&
|
||||||
|
(now - _btnTopPressTime < COMBO_HOLD_MS) &&
|
||||||
|
(now - _btnTopPressTime > BUTTON_DEBOUNCE_MS)) {
|
||||||
|
_btnTopPressTime = 0;
|
||||||
|
_lastDebounce = now;
|
||||||
|
|
||||||
|
if (_btnTopPendingSingle && (now - _btnTopLastRelease) < DOUBLE_PRESS_WINDOW) {
|
||||||
|
// Second press within window -> deep sleep
|
||||||
|
_btnTopPendingSingle = false;
|
||||||
|
Serial.println("[buttons] BTN_TOP: double-press -> deep sleep");
|
||||||
|
_power->flashLED(255, 0, 0, 500);
|
||||||
|
return ButtonEvent::DeepSleep;
|
||||||
|
} else {
|
||||||
|
// First press — mark as pending, wait for possible second
|
||||||
|
_btnTopLastRelease = now;
|
||||||
|
_btnTopPendingSingle = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit pending single press after double-press window expires
|
||||||
|
if (_btnTopPendingSingle && (now - _btnTopLastRelease) >= DOUBLE_PRESS_WINDOW) {
|
||||||
|
_btnTopPendingSingle = false;
|
||||||
|
_power->flashLED(255, 255, 255);
|
||||||
|
Serial.println("[buttons] BTN_TOP: random photo");
|
||||||
|
return ButtonEvent::RandomPhoto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTN_UP released (was short press)
|
||||||
|
if (!upNow && _btnUpPressTime > 0 &&
|
||||||
|
(now - _btnUpPressTime < FACTORY_RESET_HOLD_MS) &&
|
||||||
|
(now - _btnUpPressTime > BUTTON_DEBOUNCE_MS)) {
|
||||||
|
_btnUpPressTime = 0;
|
||||||
|
_power->flashLED(255, 255, 255);
|
||||||
|
Serial.println("[buttons] BTN_UP: next photo");
|
||||||
|
_lastDebounce = now;
|
||||||
|
return ButtonEvent::NextPhoto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTN_DOWN released (was short press)
|
||||||
|
if (!downNow && _btnDownPressTime > 0 &&
|
||||||
|
(now - _btnDownPressTime < COMBO_HOLD_MS) &&
|
||||||
|
(now - _btnDownPressTime > BUTTON_DEBOUNCE_MS)) {
|
||||||
|
_btnDownPressTime = 0;
|
||||||
|
_power->flashLED(255, 255, 255);
|
||||||
|
Serial.println("[buttons] BTN_DOWN: play/pause");
|
||||||
|
_lastDebounce = now;
|
||||||
|
return ButtonEvent::PlayPause;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ButtonEvent::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ButtonHandler::debounced(unsigned long now) {
|
||||||
|
return (now - _lastDebounce) > BUTTON_DEBOUNCE_MS;
|
||||||
|
}
|
||||||
43
src/button_handler.h
Normal file
43
src/button_handler.h
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
class PowerManager;
|
||||||
|
class SettingsManager;
|
||||||
|
|
||||||
|
enum class ButtonEvent : uint8_t {
|
||||||
|
None,
|
||||||
|
NextPhoto,
|
||||||
|
RandomPhoto,
|
||||||
|
PlayPause,
|
||||||
|
DeepSleep,
|
||||||
|
FactoryReset
|
||||||
|
};
|
||||||
|
|
||||||
|
class ButtonHandler {
|
||||||
|
public:
|
||||||
|
void begin(PowerManager& power, SettingsManager& settings);
|
||||||
|
ButtonEvent poll();
|
||||||
|
|
||||||
|
private:
|
||||||
|
PowerManager* _power = nullptr;
|
||||||
|
SettingsManager* _settings = nullptr;
|
||||||
|
|
||||||
|
unsigned long _btnTopPressTime = 0;
|
||||||
|
unsigned long _btnUpPressTime = 0;
|
||||||
|
unsigned long _btnDownPressTime = 0;
|
||||||
|
|
||||||
|
bool _btnTopPressed = false;
|
||||||
|
bool _btnUpPressed = false;
|
||||||
|
bool _btnDownPressed = false;
|
||||||
|
|
||||||
|
// Double-press detection for BTN_TOP
|
||||||
|
unsigned long _btnTopLastRelease = 0;
|
||||||
|
bool _btnTopPendingSingle = false;
|
||||||
|
static constexpr unsigned long DOUBLE_PRESS_WINDOW = 400;
|
||||||
|
|
||||||
|
unsigned long _lastDebounce = 0;
|
||||||
|
|
||||||
|
bool debounced(unsigned long now);
|
||||||
|
};
|
||||||
47
src/config.h
47
src/config.h
@@ -3,8 +3,8 @@
|
|||||||
// --- Pin Definitions ---
|
// --- Pin Definitions ---
|
||||||
// Buttons
|
// Buttons
|
||||||
#define PIN_BTN_TOP 1 // G1 - Random photo
|
#define PIN_BTN_TOP 1 // G1 - Random photo
|
||||||
#define PIN_BTN_UP 9 // G9 - Next photo
|
#define PIN_BTN_UP 10 // G10 - Next photo
|
||||||
#define PIN_BTN_DOWN 10 // G10 - Play/Pause
|
#define PIN_BTN_DOWN 9 // G9 - Play/Pause
|
||||||
|
|
||||||
// RGB LEDs (NeoPixel)
|
// RGB LEDs (NeoPixel)
|
||||||
#define PIN_RGB_LED 21
|
#define PIN_RGB_LED 21
|
||||||
@@ -38,6 +38,9 @@
|
|||||||
#define DEFAULT_META_FLAGS 0x00
|
#define DEFAULT_META_FLAGS 0x00
|
||||||
#define DEFAULT_META_POS 0 // 0=bottom
|
#define DEFAULT_META_POS 0 // 0=bottom
|
||||||
#define DEFAULT_LED_BRIGHTNESS 50
|
#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"
|
#define DEFAULT_IMMICH_URL "https://photos.example.com"
|
||||||
|
|
||||||
// --- Timing ---
|
// --- Timing ---
|
||||||
@@ -62,23 +65,23 @@
|
|||||||
#define PORTRAIT_GAP_PX 8
|
#define PORTRAIT_GAP_PX 8
|
||||||
#define PORTRAIT_WIDTH 267 // (DISPLAY_HEIGHT * 2) / 3
|
#define PORTRAIT_WIDTH 267 // (DISPLAY_HEIGHT * 2) / 3
|
||||||
|
|
||||||
// --- Dither Palette (Spectra 6) ---
|
// --- Dither Palette (Spectra 6 calibrated, from epdoptimize "spectra6") ---
|
||||||
// Initial values — calibrate on real hardware
|
// Measured display appearance — what the panel actually shows
|
||||||
#define PALETTE_BLACK_R 0
|
#define PALETTE_BLACK_R 0x1F
|
||||||
#define PALETTE_BLACK_G 0
|
#define PALETTE_BLACK_G 0x22
|
||||||
#define PALETTE_BLACK_B 0
|
#define PALETTE_BLACK_B 0x26
|
||||||
#define PALETTE_WHITE_R 255
|
#define PALETTE_WHITE_R 0xB9
|
||||||
#define PALETTE_WHITE_G 255
|
#define PALETTE_WHITE_G 0xC7
|
||||||
#define PALETTE_WHITE_B 255
|
#define PALETTE_WHITE_B 0xC9
|
||||||
#define PALETTE_RED_R 200
|
#define PALETTE_RED_R 0x62
|
||||||
#define PALETTE_RED_G 30
|
#define PALETTE_RED_G 0x20
|
||||||
#define PALETTE_RED_B 30
|
#define PALETTE_RED_B 0x1E
|
||||||
#define PALETTE_GREEN_R 30
|
#define PALETTE_GREEN_R 0x35
|
||||||
#define PALETTE_GREEN_G 160
|
#define PALETTE_GREEN_G 0x56
|
||||||
#define PALETTE_GREEN_B 30
|
#define PALETTE_GREEN_B 0x3A
|
||||||
#define PALETTE_BLUE_R 30
|
#define PALETTE_BLUE_R 0x23
|
||||||
#define PALETTE_BLUE_G 30
|
#define PALETTE_BLUE_G 0x3F
|
||||||
#define PALETTE_BLUE_B 200
|
#define PALETTE_BLUE_B 0x8E
|
||||||
#define PALETTE_YELLOW_R 220
|
#define PALETTE_YELLOW_R 0xC1
|
||||||
#define PALETTE_YELLOW_G 200
|
#define PALETTE_YELLOW_G 0xBB
|
||||||
#define PALETTE_YELLOW_B 30
|
#define PALETTE_YELLOW_B 0x1E
|
||||||
|
|||||||
490
src/display_manager.cpp
Normal file
490
src/display_manager.cpp
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
#include "display_manager.h"
|
||||||
|
#include "power_manager.h"
|
||||||
|
#include <M5Unified.h>
|
||||||
|
|
||||||
|
// EPD-exact palette: values produce distance=0 in Panel_ED2208's
|
||||||
|
// _rgb_to_epd_color nearest-color lookup, guaranteeing correct mapping.
|
||||||
|
// Order matches our pipeline's palette indices (0=black..5=yellow).
|
||||||
|
static const lgfx::bgr888_t EPD_PALETTE[6] = {
|
||||||
|
{0, 0, 0}, // Black (EPD_BLACK)
|
||||||
|
{255, 255, 255}, // White (EPD_WHITE)
|
||||||
|
{191, 0, 0}, // Red (EPD_RED)
|
||||||
|
{67, 138, 28}, // Green (EPD_GREEN)
|
||||||
|
{100, 64, 255}, // Blue (EPD_BLUE)
|
||||||
|
{255, 243, 56} // Yellow (EPD_YELLOW)
|
||||||
|
};
|
||||||
|
|
||||||
|
void DisplayManager::begin(PowerManager& power) {
|
||||||
|
_power = &power;
|
||||||
|
_display = &M5.Display;
|
||||||
|
|
||||||
|
// Ensure no automatic e-ink refreshes happen
|
||||||
|
_display->setAutoDisplay(false);
|
||||||
|
|
||||||
|
// Set rotation for landscape (device physically rotated)
|
||||||
|
_display->setRotation(3);
|
||||||
|
|
||||||
|
// Disable the panel driver's internal dithering (epd_quality applies a diagonal
|
||||||
|
// bias pattern via _dither_row_rgb_pair). Since our ImagePipeline already does
|
||||||
|
// proper Floyd-Steinberg dithering to the 6-color palette, the driver should
|
||||||
|
// just do a clean nearest-color lookup with no additional spatial bias.
|
||||||
|
// On Panel_ED2208 this only affects the dither function — NOT refresh quality.
|
||||||
|
_display->setEpdMode(epd_mode_t::epd_fastest);
|
||||||
|
|
||||||
|
Serial.printf("[display] Initialized: %dx%d, rotation=%d, epd_mode=fastest (no driver dither)\n",
|
||||||
|
_display->width(), _display->height(), _display->getRotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showSetupScreen() {
|
||||||
|
_power->enableEPDPower();
|
||||||
|
|
||||||
|
// Batch all drawing — nothing hits the panel until display() at the end
|
||||||
|
_display->startWrite();
|
||||||
|
_display->fillScreen(TFT_WHITE);
|
||||||
|
|
||||||
|
// Generate QR code for WiFi auto-connect
|
||||||
|
const char* qrData = "WIFI:T:nopass;S:PaperColor-Setup;;";
|
||||||
|
QRCode qrcode;
|
||||||
|
uint8_t qrcodeData[qrcode_getBufferSize(6)];
|
||||||
|
qrcode_initText(&qrcode, qrcodeData, 6, ECC_LOW, qrData);
|
||||||
|
|
||||||
|
// Draw QR code centered vertically, left-of-center horizontally
|
||||||
|
int qrSize = qrcode.size;
|
||||||
|
int scale = 4;
|
||||||
|
int qrPixels = qrSize * scale;
|
||||||
|
int qrX = (DISPLAY_WIDTH / 3) - (qrPixels / 2);
|
||||||
|
int qrY = (DISPLAY_HEIGHT - qrPixels) / 2;
|
||||||
|
|
||||||
|
for (int y = 0; y < qrSize; y++) {
|
||||||
|
for (int x = 0; x < qrSize; x++) {
|
||||||
|
uint16_t color = qrcode_getModule(&qrcode, x, y) ? TFT_BLACK : TFT_WHITE;
|
||||||
|
_display->fillRect(qrX + x * scale, qrY + y * scale, scale, scale, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text instructions to the right of QR code
|
||||||
|
int textX = DISPLAY_WIDTH / 2 + 40;
|
||||||
|
_display->setTextColor(TFT_BLACK);
|
||||||
|
_display->setTextDatum(middle_left);
|
||||||
|
_display->setTextSize(2);
|
||||||
|
_display->drawString("PaperColor", textX, DISPLAY_HEIGHT / 2 - 60);
|
||||||
|
_display->setTextSize(1);
|
||||||
|
_display->drawString("Scan QR to connect", textX, DISPLAY_HEIGHT / 2 - 20);
|
||||||
|
_display->drawString("or join WiFi:", textX, DISPLAY_HEIGHT / 2 + 10);
|
||||||
|
_display->setTextSize(1.5);
|
||||||
|
_display->drawString("PaperColor-Setup", textX, DISPLAY_HEIGHT / 2 + 40);
|
||||||
|
_display->setTextSize(1);
|
||||||
|
_display->drawString("Then open:", textX, DISPLAY_HEIGHT / 2 + 70);
|
||||||
|
_display->drawString("192.168.4.1", textX, DISPLAY_HEIGHT / 2 + 95);
|
||||||
|
|
||||||
|
_display->endWrite();
|
||||||
|
|
||||||
|
// Single e-ink refresh — the only panel update during boot
|
||||||
|
triggerRefresh();
|
||||||
|
_power->disableEPDPower();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showImage(const ProcessedImage& img) {
|
||||||
|
if (!img.valid || img.framebuffer == nullptr) {
|
||||||
|
Serial.println("[display] Invalid image — skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_power->enableEPDPower();
|
||||||
|
|
||||||
|
Serial.println("[display] Writing framebuffer to e-ink...");
|
||||||
|
|
||||||
|
// Bulk-write framebuffer using palette lookup (handles rotation internally)
|
||||||
|
_display->startWrite();
|
||||||
|
_display->pushImage(0, 0, img.width, img.height,
|
||||||
|
img.framebuffer, lgfx::color_depth_t::palette_8bit, EPD_PALETTE);
|
||||||
|
_display->endWrite();
|
||||||
|
// Do NOT refresh here — caller should add metadata overlay then call refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showMessage(const char* title, const char* body) {
|
||||||
|
_power->enableEPDPower();
|
||||||
|
|
||||||
|
_display->startWrite();
|
||||||
|
_display->fillScreen(TFT_WHITE);
|
||||||
|
_display->setTextColor(TFT_BLACK);
|
||||||
|
_display->setTextDatum(middle_center);
|
||||||
|
_display->setTextSize(2);
|
||||||
|
_display->drawString(title, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 - 30);
|
||||||
|
_display->setTextSize(1);
|
||||||
|
_display->drawString(body, DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2 + 20);
|
||||||
|
_display->endWrite();
|
||||||
|
|
||||||
|
triggerRefresh();
|
||||||
|
_power->disableEPDPower();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
|
||||||
|
const String& dateFmt, const String& timeFmt) {
|
||||||
|
showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
|
||||||
|
uint16_t regionX, uint16_t regionY,
|
||||||
|
uint16_t regionW, uint16_t regionH,
|
||||||
|
const String& dateFmt, const String& timeFmt) {
|
||||||
|
if (metaFlags == 0) return;
|
||||||
|
|
||||||
|
// Build metadata string (excluding location — it gets its own badge)
|
||||||
|
String metaText = "";
|
||||||
|
|
||||||
|
// Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime
|
||||||
|
if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) {
|
||||||
|
struct tm t = {};
|
||||||
|
sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d",
|
||||||
|
&t.tm_year, &t.tm_mon, &t.tm_mday,
|
||||||
|
&t.tm_hour, &t.tm_min, &t.tm_sec);
|
||||||
|
t.tm_year -= 1900;
|
||||||
|
t.tm_mon -= 1;
|
||||||
|
|
||||||
|
char buf[64];
|
||||||
|
if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) {
|
||||||
|
String combinedFmt = dateFmt + " " + timeFmt;
|
||||||
|
strftime(buf, sizeof(buf), combinedFmt.c_str(), &t);
|
||||||
|
metaText += buf;
|
||||||
|
} else if (metaFlags & META_DATE) {
|
||||||
|
strftime(buf, sizeof(buf), dateFmt.c_str(), &t);
|
||||||
|
metaText += buf;
|
||||||
|
} else {
|
||||||
|
strftime(buf, sizeof(buf), timeFmt.c_str(), &t);
|
||||||
|
metaText += buf;
|
||||||
|
}
|
||||||
|
} else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) {
|
||||||
|
// Fallback if datetime is too short for full parse
|
||||||
|
metaText += info.dateTime.substring(0, 10);
|
||||||
|
}
|
||||||
|
if ((metaFlags & META_PEOPLE) && !info.people.empty()) {
|
||||||
|
if (metaText.length() > 0) metaText += " | ";
|
||||||
|
for (size_t i = 0; i < info.people.size(); i++) {
|
||||||
|
if (i > 0) metaText += ", ";
|
||||||
|
metaText += info.people[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((metaFlags & META_ALBUM)) {
|
||||||
|
// Album name would need to be passed separately
|
||||||
|
}
|
||||||
|
if ((metaFlags & META_CAMERA) && info.camera.length() > 0) {
|
||||||
|
if (metaText.length() > 0) metaText += " | ";
|
||||||
|
metaText += info.camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location gets its own badge in lower-left
|
||||||
|
String locationText = "";
|
||||||
|
if ((metaFlags & META_LOCATION) && info.city.length() > 0) {
|
||||||
|
locationText = info.city;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaText.length() == 0 && locationText.length() == 0) return;
|
||||||
|
|
||||||
|
static constexpr uint16_t MARGIN = 8;
|
||||||
|
static constexpr uint16_t PAD_X = 8;
|
||||||
|
static constexpr uint16_t PAD_Y = 5;
|
||||||
|
static constexpr uint16_t TEXT_H = 20;
|
||||||
|
static constexpr uint16_t ICON_SIZE = 12;
|
||||||
|
static constexpr uint16_t ICON_GAP = 6;
|
||||||
|
|
||||||
|
bool showIcons = (regionW == DISPLAY_WIDTH);
|
||||||
|
|
||||||
|
_display->setTextSize(1.5f);
|
||||||
|
_display->setTextColor(TFT_WHITE);
|
||||||
|
|
||||||
|
// Track where the main badge ends up (for location badge offset)
|
||||||
|
uint16_t mainBadgeBottomY = 0;
|
||||||
|
|
||||||
|
// Draw main metadata badge (date/time/people/camera) at configured position
|
||||||
|
if (metaText.length() > 0) {
|
||||||
|
switch (pos) {
|
||||||
|
case MetaPosition::CaptionBottom:
|
||||||
|
case MetaPosition::CaptionTop: {
|
||||||
|
uint16_t barH = 36;
|
||||||
|
uint16_t barY = (pos == MetaPosition::CaptionTop)
|
||||||
|
? regionY
|
||||||
|
: regionY + regionH - barH;
|
||||||
|
_display->fillRect(regionX, barY, regionW, barH, TFT_BLACK);
|
||||||
|
_display->setTextDatum(middle_center);
|
||||||
|
_display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case MetaPosition::OverlayTopLeft:
|
||||||
|
case MetaPosition::OverlayTopRight:
|
||||||
|
case MetaPosition::OverlayBottomLeft:
|
||||||
|
case MetaPosition::OverlayBottomRight: {
|
||||||
|
uint16_t textW = _display->textWidth(metaText.c_str());
|
||||||
|
uint16_t iconExtra = showIcons ? (ICON_SIZE + ICON_GAP) : 0;
|
||||||
|
uint16_t badgeW = min((uint16_t)(textW + iconExtra + PAD_X * 2), regionW);
|
||||||
|
uint16_t badgeH = TEXT_H + PAD_Y * 2;
|
||||||
|
|
||||||
|
uint16_t bx, by;
|
||||||
|
switch (pos) {
|
||||||
|
case MetaPosition::OverlayTopLeft:
|
||||||
|
bx = regionX + MARGIN;
|
||||||
|
by = regionY + MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayTopRight:
|
||||||
|
bx = regionX + regionW - badgeW - MARGIN;
|
||||||
|
by = regionY + MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayBottomLeft:
|
||||||
|
bx = regionX + MARGIN;
|
||||||
|
by = regionY + regionH - badgeH - MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayBottomRight:
|
||||||
|
bx = regionX + regionW - badgeW - MARGIN;
|
||||||
|
by = regionY + regionH - badgeH - MARGIN;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
bx = regionX + MARGIN;
|
||||||
|
by = regionY + MARGIN;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
|
_display->setTextDatum(middle_left);
|
||||||
|
uint16_t textX = bx + PAD_X;
|
||||||
|
if (showIcons) {
|
||||||
|
drawCalendarIcon(bx + PAD_X, by + (badgeH - ICON_SIZE) / 2 - 1, ICON_SIZE);
|
||||||
|
textX += ICON_SIZE + ICON_GAP;
|
||||||
|
}
|
||||||
|
_display->drawString(metaText.c_str(), textX, by + badgeH / 2);
|
||||||
|
mainBadgeBottomY = by + badgeH;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw location badge in lower-left corner (separate from main badge)
|
||||||
|
if (locationText.length() > 0) {
|
||||||
|
uint16_t locTextW = _display->textWidth(locationText.c_str());
|
||||||
|
uint16_t iconExtra = showIcons ? (ICON_SIZE + ICON_GAP) : 0;
|
||||||
|
uint16_t locBadgeW = min((uint16_t)(locTextW + iconExtra + PAD_X * 2), regionW);
|
||||||
|
uint16_t locBadgeH = TEXT_H + PAD_Y * 2;
|
||||||
|
|
||||||
|
uint16_t lx = regionX + MARGIN;
|
||||||
|
uint16_t ly = regionY + regionH - locBadgeH - MARGIN;
|
||||||
|
|
||||||
|
// If main badge is also in bottom-left, stack location above it
|
||||||
|
if (pos == MetaPosition::OverlayBottomLeft && metaText.length() > 0) {
|
||||||
|
ly = ly - locBadgeH - 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlayBadge(lx, ly, locBadgeW, locBadgeH);
|
||||||
|
_display->setTextColor(TFT_WHITE);
|
||||||
|
_display->setTextDatum(middle_left);
|
||||||
|
uint16_t locTextX = lx + PAD_X;
|
||||||
|
if (showIcons) {
|
||||||
|
drawPinIcon(lx + PAD_X, ly + (locBadgeH - ICON_SIZE) / 2 - 1, ICON_SIZE);
|
||||||
|
locTextX += ICON_SIZE + ICON_GAP;
|
||||||
|
}
|
||||||
|
_display->drawString(locationText.c_str(), locTextX, ly + locBadgeH / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
|
||||||
|
static constexpr int16_t R = 4;
|
||||||
|
|
||||||
|
if (!_translucent) {
|
||||||
|
_display->fillRoundRect(x, y, w, h, R, TFT_BLACK);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkerboard pattern within rounded-rect bounds for 50% opacity effect.
|
||||||
|
// Use fillRoundRect to create a clip mask in black, then punch holes by
|
||||||
|
// restoring every-other pixel. Simpler: just iterate and test corner radii.
|
||||||
|
for (uint16_t py = y; py < y + h; py++) {
|
||||||
|
for (uint16_t px = x; px < x + w; px++) {
|
||||||
|
if ((px + py) % 2 != 0) continue;
|
||||||
|
|
||||||
|
// Check if pixel is inside the rounded rect (corner exclusion)
|
||||||
|
int16_t lx = px - x;
|
||||||
|
int16_t ly = py - y;
|
||||||
|
bool inside = true;
|
||||||
|
|
||||||
|
// Top-left corner
|
||||||
|
if (lx < R && ly < R) {
|
||||||
|
int16_t dx = R - 1 - lx;
|
||||||
|
int16_t dy = R - 1 - ly;
|
||||||
|
inside = (dx * dx + dy * dy) <= (R * R);
|
||||||
|
}
|
||||||
|
// Top-right corner
|
||||||
|
else if (lx >= w - R && ly < R) {
|
||||||
|
int16_t dx = lx - (w - R);
|
||||||
|
int16_t dy = R - 1 - ly;
|
||||||
|
inside = (dx * dx + dy * dy) <= (R * R);
|
||||||
|
}
|
||||||
|
// Bottom-left corner
|
||||||
|
else if (lx < R && ly >= h - R) {
|
||||||
|
int16_t dx = R - 1 - lx;
|
||||||
|
int16_t dy = ly - (h - R);
|
||||||
|
inside = (dx * dx + dy * dy) <= (R * R);
|
||||||
|
}
|
||||||
|
// Bottom-right corner
|
||||||
|
else if (lx >= w - R && ly >= h - R) {
|
||||||
|
int16_t dx = lx - (w - R);
|
||||||
|
int16_t dy = ly - (h - R);
|
||||||
|
inside = (dx * dx + dy * dy) <= (R * R);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inside) {
|
||||||
|
_display->writePixel(px, py, TFT_BLACK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::drawPinIcon(int32_t x, int32_t y, int32_t size) {
|
||||||
|
int32_t r = size * 2 / 5;
|
||||||
|
int32_t cx = x + size / 2;
|
||||||
|
int32_t cy = y + r + 1;
|
||||||
|
_display->fillCircle(cx, cy, r, TFT_WHITE);
|
||||||
|
_display->fillTriangle(cx - r + 1, cy + r / 2, cx + r - 1, cy + r / 2,
|
||||||
|
cx, y + size - 1, TFT_WHITE);
|
||||||
|
_display->fillCircle(cx, cy, r / 3, TFT_BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::drawCalendarIcon(int32_t x, int32_t y, int32_t size) {
|
||||||
|
int32_t bodyY = y + size / 4;
|
||||||
|
int32_t bodyH = size - size / 4;
|
||||||
|
int32_t bodyW = size;
|
||||||
|
_display->drawRect(x, bodyY, bodyW, bodyH, TFT_WHITE);
|
||||||
|
int32_t headerH = bodyH / 3;
|
||||||
|
_display->drawLine(x, bodyY + headerH, x + bodyW - 1, bodyY + headerH, TFT_WHITE);
|
||||||
|
int32_t tabW = 2, tabH = size / 4 + 1;
|
||||||
|
_display->fillRect(x + bodyW / 4, y, tabW, tabH, TFT_WHITE);
|
||||||
|
_display->fillRect(x + bodyW * 3 / 4 - tabW, y, tabW, tabH, TFT_WHITE);
|
||||||
|
int32_t dotSize = max((int32_t)1, size / 6);
|
||||||
|
int32_t gridY = bodyY + headerH + 2;
|
||||||
|
int32_t gridX = x + 2;
|
||||||
|
int32_t gapX = (bodyW - 4) / 3;
|
||||||
|
int32_t gapY = (bodyH - headerH - 3) / 2;
|
||||||
|
for (int row = 0; row < 2; row++)
|
||||||
|
for (int col = 0; col < 3; col++)
|
||||||
|
_display->fillRect(gridX + col * gapX, gridY + row * gapY,
|
||||||
|
dotSize, dotSize, TFT_WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showSleepIndicator(MetaPosition metaPos) {
|
||||||
|
_power->enableEPDPower();
|
||||||
|
|
||||||
|
static constexpr uint16_t MARGIN = 8;
|
||||||
|
static constexpr uint16_t PAD_X = 6;
|
||||||
|
static constexpr uint16_t PAD_Y = 4;
|
||||||
|
|
||||||
|
_display->setTextSize(1);
|
||||||
|
uint16_t textW = _display->textWidth("SLP");
|
||||||
|
uint16_t textH = 14;
|
||||||
|
uint16_t badgeW = textW + PAD_X * 2;
|
||||||
|
uint16_t badgeH = textH + PAD_Y * 2;
|
||||||
|
|
||||||
|
// Pick a corner opposite to metadata position
|
||||||
|
uint16_t bx, by;
|
||||||
|
switch (metaPos) {
|
||||||
|
case MetaPosition::OverlayTopLeft:
|
||||||
|
case MetaPosition::CaptionTop:
|
||||||
|
bx = DISPLAY_WIDTH - badgeW - MARGIN;
|
||||||
|
by = DISPLAY_HEIGHT - badgeH - MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayTopRight:
|
||||||
|
bx = MARGIN;
|
||||||
|
by = DISPLAY_HEIGHT - badgeH - MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayBottomLeft:
|
||||||
|
bx = DISPLAY_WIDTH - badgeW - MARGIN;
|
||||||
|
by = MARGIN;
|
||||||
|
break;
|
||||||
|
case MetaPosition::OverlayBottomRight:
|
||||||
|
case MetaPosition::CaptionBottom:
|
||||||
|
default:
|
||||||
|
bx = MARGIN;
|
||||||
|
by = MARGIN;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
|
_display->setTextColor(TFT_WHITE);
|
||||||
|
_display->setTextDatum(middle_center);
|
||||||
|
_display->drawString("SLP", bx + badgeW / 2, by + badgeH / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::showBatteryIndicator(uint8_t percent, MetaPosition metaPos) {
|
||||||
|
static constexpr uint16_t MARGIN = 8;
|
||||||
|
static constexpr uint16_t PAD_X = 6;
|
||||||
|
static constexpr uint16_t PAD_Y = 4;
|
||||||
|
|
||||||
|
// Battery icon dimensions
|
||||||
|
static constexpr uint16_t BATT_W = 20;
|
||||||
|
static constexpr uint16_t BATT_H = 10;
|
||||||
|
static constexpr uint16_t NUB_W = 3;
|
||||||
|
static constexpr uint16_t NUB_H = 5;
|
||||||
|
static constexpr uint16_t GAP = 4;
|
||||||
|
|
||||||
|
// Build percentage text
|
||||||
|
char pctText[5];
|
||||||
|
snprintf(pctText, sizeof(pctText), "%d%%", percent);
|
||||||
|
|
||||||
|
_display->setTextSize(1);
|
||||||
|
uint16_t textW = _display->textWidth(pctText);
|
||||||
|
uint16_t textH = 14;
|
||||||
|
|
||||||
|
// Total badge size: icon + gap + text + padding
|
||||||
|
uint16_t contentW = BATT_W + NUB_W + GAP + textW;
|
||||||
|
uint16_t contentH = max(BATT_H, textH);
|
||||||
|
uint16_t badgeW = contentW + PAD_X * 2;
|
||||||
|
uint16_t badgeH = contentH + PAD_Y * 2;
|
||||||
|
|
||||||
|
// Position: top-right by default, top-left if metadata occupies top-right
|
||||||
|
uint16_t bx, by;
|
||||||
|
if (metaPos == MetaPosition::OverlayTopRight) {
|
||||||
|
bx = MARGIN;
|
||||||
|
by = MARGIN;
|
||||||
|
} else {
|
||||||
|
bx = DISPLAY_WIDTH - badgeW - MARGIN;
|
||||||
|
by = MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw badge background
|
||||||
|
drawOverlayBadge(bx, by, badgeW, badgeH);
|
||||||
|
|
||||||
|
// Draw battery outline (white rect with 1px border)
|
||||||
|
uint16_t iconX = bx + PAD_X;
|
||||||
|
uint16_t iconY = by + (badgeH - BATT_H) / 2;
|
||||||
|
_display->drawRect(iconX, iconY, BATT_W, BATT_H, TFT_WHITE);
|
||||||
|
|
||||||
|
// Draw nub on right side of battery
|
||||||
|
uint16_t nubX = iconX + BATT_W;
|
||||||
|
uint16_t nubY = iconY + (BATT_H - NUB_H) / 2;
|
||||||
|
_display->fillRect(nubX, nubY, NUB_W, NUB_H, TFT_WHITE);
|
||||||
|
|
||||||
|
// Draw fill level inside battery (1px inset)
|
||||||
|
uint16_t fillMaxW = BATT_W - 2;
|
||||||
|
uint16_t fillW = (fillMaxW * percent) / 100;
|
||||||
|
if (fillW > 0) {
|
||||||
|
_display->fillRect(iconX + 1, iconY + 1, fillW, BATT_H - 2, TFT_WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw percentage text
|
||||||
|
uint16_t textX = iconX + BATT_W + NUB_W + GAP;
|
||||||
|
_display->setTextColor(TFT_WHITE);
|
||||||
|
_display->setTextDatum(middle_left);
|
||||||
|
_display->drawString(pctText, textX, by + badgeH / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::refresh() {
|
||||||
|
unsigned long start = millis();
|
||||||
|
triggerRefresh();
|
||||||
|
unsigned long elapsed = millis() - start;
|
||||||
|
Serial.printf("[display] Refresh complete in %lu ms\n", elapsed);
|
||||||
|
_power->disableEPDPower();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayManager::triggerRefresh() {
|
||||||
|
// Panel_ED2208::_exec_transfer always sends the full 400x600 frame
|
||||||
|
// regardless of dirty region — the dirty rect only gates whether
|
||||||
|
// a transfer happens at all. ~30ms at 4MHz SPI, fine for photo frame.
|
||||||
|
_display->display();
|
||||||
|
}
|
||||||
|
|
||||||
39
src/display_manager.h
Normal file
39
src/display_manager.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <M5GFX.h>
|
||||||
|
#include <qrcode.h>
|
||||||
|
#include "config.h"
|
||||||
|
#include "image_pipeline.h"
|
||||||
|
#include "immich_client.h"
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
class PowerManager;
|
||||||
|
|
||||||
|
class DisplayManager {
|
||||||
|
public:
|
||||||
|
void begin(PowerManager& power);
|
||||||
|
void showSetupScreen();
|
||||||
|
void showImage(const ProcessedImage& img);
|
||||||
|
void showMessage(const char* title, const char* body);
|
||||||
|
void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
|
||||||
|
const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
|
||||||
|
void showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos,
|
||||||
|
uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH,
|
||||||
|
const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S");
|
||||||
|
void showSleepIndicator(MetaPosition metaPos);
|
||||||
|
void showBatteryIndicator(uint8_t percent, MetaPosition metaPos);
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
void setTranslucent(bool enabled) { _translucent = enabled; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
PowerManager* _power = nullptr;
|
||||||
|
M5GFX* _display = nullptr;
|
||||||
|
bool _translucent = false;
|
||||||
|
|
||||||
|
void drawOverlayBadge(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
|
||||||
|
void drawPinIcon(int32_t x, int32_t y, int32_t size);
|
||||||
|
void drawCalendarIcon(int32_t x, int32_t y, int32_t size);
|
||||||
|
void triggerRefresh();
|
||||||
|
};
|
||||||
975
src/image_pipeline.cpp
Normal file
975
src/image_pipeline.cpp
Normal file
@@ -0,0 +1,975 @@
|
|||||||
|
#include "image_pipeline.h"
|
||||||
|
#include "blue_noise.h"
|
||||||
|
#include "stb_image.h"
|
||||||
|
#include <M5GFX.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
// Spectra 6 calibrated palette (measured display appearance from epdoptimize)
|
||||||
|
// These represent what the display ACTUALLY shows, used for dithering decisions
|
||||||
|
static const uint8_t PALETTE_CALIBRATED[6][3] = {
|
||||||
|
{0x1F, 0x22, 0x26}, // Black -> appears as dark gray
|
||||||
|
{0xB9, 0xC7, 0xC9}, // White -> appears as light gray-blue
|
||||||
|
{0x62, 0x20, 0x1E}, // Red -> appears as dark red/brown
|
||||||
|
{0x35, 0x56, 0x3A}, // Green -> appears as dark forest green
|
||||||
|
{0x23, 0x3F, 0x8E}, // Blue -> appears as dark navy
|
||||||
|
{0xC1, 0xBB, 0x1E} // Yellow -> appears as olive/mustard
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color science helpers (sRGB <-> LAB, luma, saturation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static inline uint8_t clampByte(float v) {
|
||||||
|
if (v <= 0.0f) return 0;
|
||||||
|
if (v >= 255.0f) return 255;
|
||||||
|
return (uint8_t)(v + 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline float clampF(float v, float lo, float hi) {
|
||||||
|
return (v < lo) ? lo : (v > hi) ? hi : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline float luma709(float r, float g, float b) {
|
||||||
|
return 0.2126f * r + 0.7152f * g + 0.0722f * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-computed sRGB-to-linear LUT (avoids per-pixel powf)
|
||||||
|
static const float* getSrgbToLinear() {
|
||||||
|
static float lut[256];
|
||||||
|
static bool initialized = false;
|
||||||
|
if (!initialized) {
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
float n = i / 255.0f;
|
||||||
|
lut[i] = (n > 0.04045f)
|
||||||
|
? powf((n + 0.055f) / 1.055f, 2.4f)
|
||||||
|
: n / 12.92f;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return lut;
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline float labForwardPivot(float v) {
|
||||||
|
return (v > 0.008856f) ? cbrtf(v) : 7.787f * v + 16.0f / 116.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightness-only conversion (for DRC histogram pass)
|
||||||
|
static float rgbToLabLightness(uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
const float* lut = getSrgbToLinear();
|
||||||
|
float y = lut[r] * 0.2126729f + lut[g] * 0.7151522f + lut[b] * 0.0721750f;
|
||||||
|
return 116.0f * labForwardPivot(y) - 16.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full sRGB -> CIE LAB (D65 illuminant)
|
||||||
|
static void srgbToLab(uint8_t r, uint8_t g, uint8_t b,
|
||||||
|
float* L, float* a, float* bOut) {
|
||||||
|
const float* lut = getSrgbToLinear();
|
||||||
|
float rn = lut[r], gn = lut[g], bn = lut[b];
|
||||||
|
|
||||||
|
float x = rn * 0.4124564f + gn * 0.3575761f + bn * 0.1804375f;
|
||||||
|
float y = rn * 0.2126729f + gn * 0.7151522f + bn * 0.0721750f;
|
||||||
|
float z = rn * 0.0193339f + gn * 0.1191920f + bn * 0.9503041f;
|
||||||
|
|
||||||
|
float fx = labForwardPivot(x / 0.95047f);
|
||||||
|
float fy = labForwardPivot(y);
|
||||||
|
float fz = labForwardPivot(z / 1.08883f);
|
||||||
|
|
||||||
|
*L = 116.0f * fy - 16.0f;
|
||||||
|
*a = 500.0f * (fx - fy);
|
||||||
|
*bOut = 200.0f * (fy - fz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIE LAB -> sRGB (D65 illuminant)
|
||||||
|
static void labToSrgb(float L, float a, float b,
|
||||||
|
uint8_t* rOut, uint8_t* gOut, uint8_t* bOut) {
|
||||||
|
float fy = (L + 16.0f) / 116.0f;
|
||||||
|
float fx = a / 500.0f + fy;
|
||||||
|
float fz = fy - b / 200.0f;
|
||||||
|
|
||||||
|
float x = (fx > 0.206897f) ? fx * fx * fx : (fx - 16.0f / 116.0f) / 7.787f;
|
||||||
|
float y = (fy > 0.206897f) ? fy * fy * fy : (fy - 16.0f / 116.0f) / 7.787f;
|
||||||
|
float z = (fz > 0.206897f) ? fz * fz * fz : (fz - 16.0f / 116.0f) / 7.787f;
|
||||||
|
|
||||||
|
x *= 0.95047f;
|
||||||
|
z *= 1.08883f;
|
||||||
|
|
||||||
|
float rl = x * 3.2404542f + y * -1.5371385f + z * -0.4985314f;
|
||||||
|
float gl = x * -0.9692660f + y * 1.8760108f + z * 0.0415560f;
|
||||||
|
float bl = x * 0.0556434f + y * -0.2040259f + z * 1.0572252f;
|
||||||
|
|
||||||
|
auto linearToSrgb = [](float v) -> float {
|
||||||
|
if (v <= 0.0f) return 0.0f;
|
||||||
|
return (v > 0.0031308f)
|
||||||
|
? 1.055f * powf(v, 1.0f / 2.4f) - 0.055f
|
||||||
|
: 12.92f * v;
|
||||||
|
};
|
||||||
|
|
||||||
|
*rOut = clampByte(linearToSrgb(rl) * 255.0f);
|
||||||
|
*gOut = clampByte(linearToSrgb(gl) * 255.0f);
|
||||||
|
*bOut = clampByte(linearToSrgb(bl) * 255.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSV-style saturation (max-min)/max in 0..1 range
|
||||||
|
static inline float pixelSaturation(float r, float g, float b) {
|
||||||
|
float mx = fmaxf(r, fmaxf(g, b));
|
||||||
|
float mn = fminf(r, fminf(g, b));
|
||||||
|
return (mx > 0.0f) ? (mx - mn) / mx : 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tone mapping (replaces enhanceContrast)
|
||||||
|
//
|
||||||
|
// Uses epdoptimize's "dynamic" preset:
|
||||||
|
// - Asymmetric power-curve S-curve built into a 256-entry LUT
|
||||||
|
// - HSL-space saturation adjustment (preserves hue fidelity)
|
||||||
|
// - Lookup-table contrast and exposure
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static constexpr float SHADOW_TONE_RESPONSE = 1.5f;
|
||||||
|
|
||||||
|
static void toneMap(uint8_t* rgb, size_t pixelCount) {
|
||||||
|
static constexpr float EXPOSURE = 0.0f; // stops (2^0 = 1.0x)
|
||||||
|
static constexpr float SATURATION_ADJ = 0.3f; // -> 1.3x multiplier
|
||||||
|
static constexpr float CONTRAST_ADJ = 0.0f; // -> 1.0x multiplier
|
||||||
|
static constexpr float STRENGTH = 0.9f;
|
||||||
|
static constexpr float SHADOW_BOOST = 0.0f;
|
||||||
|
static constexpr float HIGHLIGHT_COMP = -1.5f;
|
||||||
|
static constexpr float MIDPOINT = 0.5f;
|
||||||
|
|
||||||
|
float exposureMul = powf(2.0f, EXPOSURE);
|
||||||
|
float satMul = fmaxf(0.0f, SATURATION_ADJ + 1.0f);
|
||||||
|
float contrastMul = (CONTRAST_ADJ < 0.0f)
|
||||||
|
? fmaxf(0.5f, 1.0f + CONTRAST_ADJ * 0.5f)
|
||||||
|
: CONTRAST_ADJ + 1.0f;
|
||||||
|
|
||||||
|
// Build S-curve LUT (asymmetric power curve per epdoptimize)
|
||||||
|
float mid = clampF(MIDPOINT, 0.01f, 0.99f);
|
||||||
|
float shadowExp = clampF(1.0f - STRENGTH * SHADOW_BOOST * SHADOW_TONE_RESPONSE, 0.15f, 3.0f);
|
||||||
|
float highlightExp = clampF(1.0f - STRENGTH * HIGHLIGHT_COMP, 0.15f, 3.0f);
|
||||||
|
|
||||||
|
uint8_t exposureLut[256];
|
||||||
|
uint8_t toneLut[256];
|
||||||
|
|
||||||
|
for (int v = 0; v < 256; v++) {
|
||||||
|
exposureLut[v] = clampByte((float)v * exposureMul);
|
||||||
|
|
||||||
|
float tv = clampF(((float)v - 128.0f) * contrastMul + 128.0f, 0.0f, 255.0f);
|
||||||
|
|
||||||
|
if (STRENGTH != 0.0f) {
|
||||||
|
float n = tv / 255.0f;
|
||||||
|
float curved;
|
||||||
|
if (n <= mid) {
|
||||||
|
curved = powf(n / mid, shadowExp) * mid;
|
||||||
|
} else {
|
||||||
|
curved = mid + powf((n - mid) / (1.0f - mid), highlightExp) * (1.0f - mid);
|
||||||
|
}
|
||||||
|
tv = clampF(curved * 255.0f, 0.0f, 255.0f);
|
||||||
|
}
|
||||||
|
toneLut[v] = (uint8_t)tv;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool needsSaturation = (satMul != 1.0f);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < pixelCount; i++) {
|
||||||
|
size_t idx = i * 3;
|
||||||
|
|
||||||
|
if (!needsSaturation) {
|
||||||
|
rgb[idx] = toneLut[exposureLut[rgb[idx]]];
|
||||||
|
rgb[idx + 1] = toneLut[exposureLut[rgb[idx + 1]]];
|
||||||
|
rgb[idx + 2] = toneLut[exposureLut[rgb[idx + 2]]];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSL-space saturation (preserves hue, matches epdoptimize)
|
||||||
|
float r0 = exposureLut[rgb[idx]] / 255.0f;
|
||||||
|
float g0 = exposureLut[rgb[idx + 1]] / 255.0f;
|
||||||
|
float b0 = exposureLut[rgb[idx + 2]] / 255.0f;
|
||||||
|
|
||||||
|
float maxC = fmaxf(r0, fmaxf(g0, b0));
|
||||||
|
float minC = fminf(r0, fminf(g0, b0));
|
||||||
|
float light = (maxC + minC) * 0.5f;
|
||||||
|
float r = r0, g = g0, b = b0;
|
||||||
|
|
||||||
|
if (maxC != minC) {
|
||||||
|
float delta = maxC - minC;
|
||||||
|
float sat = (light > 0.5f)
|
||||||
|
? delta / (2.0f - maxC - minC)
|
||||||
|
: delta / fmaxf(maxC + minC, 1e-6f);
|
||||||
|
|
||||||
|
float hue;
|
||||||
|
if (maxC == r0) {
|
||||||
|
hue = (g0 - b0) / delta;
|
||||||
|
if (g0 < b0) hue += 6.0f;
|
||||||
|
hue /= 6.0f;
|
||||||
|
} else if (maxC == g0) {
|
||||||
|
hue = ((b0 - r0) / delta + 2.0f) / 6.0f;
|
||||||
|
} else {
|
||||||
|
hue = ((r0 - g0) / delta + 4.0f) / 6.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float newSat = clampF(sat * satMul, 0.0f, 1.0f);
|
||||||
|
float c = (1.0f - fabsf(2.0f * light - 1.0f)) * newSat;
|
||||||
|
float x = c * (1.0f - fabsf(fmodf(hue * 6.0f, 2.0f) - 1.0f));
|
||||||
|
float m = light - c * 0.5f;
|
||||||
|
|
||||||
|
int sector = (int)(hue * 6.0f);
|
||||||
|
if (sector >= 6) sector = 5;
|
||||||
|
switch (sector) {
|
||||||
|
case 0: r = c + m; g = x + m; b = m; break;
|
||||||
|
case 1: r = x + m; g = c + m; b = m; break;
|
||||||
|
case 2: r = m; g = c + m; b = x + m; break;
|
||||||
|
case 3: r = m; g = x + m; b = c + m; break;
|
||||||
|
case 4: r = x + m; g = m; b = c + m; break;
|
||||||
|
case 5: r = c + m; g = m; b = x + m; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rgb[idx] = toneLut[clampByte(r * 255.0f)];
|
||||||
|
rgb[idx + 1] = toneLut[clampByte(g * 255.0f)];
|
||||||
|
rgb[idx + 2] = toneLut[clampByte(b * 255.0f)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] toneMap: exposure=%.1f sat=%.1fx strength=%.1f\n",
|
||||||
|
EXPOSURE, satMul, STRENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dynamic range compression (fast luma path with chroma protection)
|
||||||
|
//
|
||||||
|
// Uses epdoptimize's "balanced" preset approach:
|
||||||
|
// - Histogram percentile scan for source range detection
|
||||||
|
// - Remap into palette luminance range
|
||||||
|
// - smoothstep chroma protection prevents saturated color washout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void compressDynamicRange(uint8_t* rgb, size_t pixelCount) {
|
||||||
|
static constexpr float STRENGTH = 1.0f;
|
||||||
|
static constexpr float LOW_PERCENTILE = 0.01f;
|
||||||
|
static constexpr float HIGH_PERCENTILE = 0.99f;
|
||||||
|
|
||||||
|
float blackY = luma709(PALETTE_CALIBRATED[0][0],
|
||||||
|
PALETTE_CALIBRATED[0][1],
|
||||||
|
PALETTE_CALIBRATED[0][2]);
|
||||||
|
float whiteY = luma709(PALETTE_CALIBRATED[1][0],
|
||||||
|
PALETTE_CALIBRATED[1][1],
|
||||||
|
PALETTE_CALIBRATED[1][2]);
|
||||||
|
float targetRange = whiteY - blackY;
|
||||||
|
if (targetRange <= 0.0f) return;
|
||||||
|
|
||||||
|
// Build luma histogram for percentile detection
|
||||||
|
uint32_t histogram[256] = {0};
|
||||||
|
for (size_t i = 0; i < pixelCount; i++) {
|
||||||
|
size_t idx = i * 3;
|
||||||
|
histogram[clampByte(luma709(rgb[idx], rgb[idx + 1], rgb[idx + 2]))]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find percentile endpoints
|
||||||
|
auto findPercentile = [&](float p) -> float {
|
||||||
|
uint32_t target = (uint32_t)((pixelCount - 1) * p);
|
||||||
|
uint32_t seen = 0;
|
||||||
|
for (int i = 0; i < 256; i++) {
|
||||||
|
seen += histogram[i];
|
||||||
|
if (seen > target) return (float)i;
|
||||||
|
}
|
||||||
|
return 255.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
float sourceBlackY = findPercentile(LOW_PERCENTILE);
|
||||||
|
float sourceWhiteY = findPercentile(HIGH_PERCENTILE);
|
||||||
|
float sourceRange = sourceWhiteY - sourceBlackY;
|
||||||
|
if (sourceRange <= 0.0001f) return;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < pixelCount; i++) {
|
||||||
|
size_t idx = i * 3;
|
||||||
|
float r = rgb[idx], g = rgb[idx + 1], b = rgb[idx + 2];
|
||||||
|
float y = luma709(r, g, b);
|
||||||
|
|
||||||
|
float normalizedY = clampF((y - sourceBlackY) / sourceRange, 0.0f, 1.0f);
|
||||||
|
float targetY = blackY + normalizedY * targetRange;
|
||||||
|
|
||||||
|
// Chroma protection: smoothstep(0.18, 0.68, saturation) * 0.85
|
||||||
|
float sat = pixelSaturation(r, g, b);
|
||||||
|
float chromaProtection = 0.0f;
|
||||||
|
if (sat > 0.18f) {
|
||||||
|
float t = clampF((sat - 0.18f) / (0.68f - 0.18f), 0.0f, 1.0f);
|
||||||
|
chromaProtection = t * t * (3.0f - 2.0f * t) * 0.85f;
|
||||||
|
}
|
||||||
|
float effectiveStrength = STRENGTH * (1.0f - chromaProtection);
|
||||||
|
|
||||||
|
float nextY = y + (targetY - y) * effectiveStrength;
|
||||||
|
float ratio = (y > 0.0f) ? nextY / y : 0.0f;
|
||||||
|
float maxChannel = fmaxf(r, fmaxf(g, b));
|
||||||
|
if (maxChannel > 0.0f) ratio = fminf(ratio, 255.0f / maxChannel);
|
||||||
|
|
||||||
|
rgb[idx] = clampByte(r * ratio);
|
||||||
|
rgb[idx + 1] = clampByte(g * ratio);
|
||||||
|
rgb[idx + 2] = clampByte(b * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] DRC: src=[%.0f..%.0f] -> dst=[%.0f..%.0f]\n",
|
||||||
|
sourceBlackY, sourceWhiteY, blackY, whiteY);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Average a single edge of the fitted image (4 rows or columns deep)
|
||||||
|
static constexpr int EDGE_DEPTH = 4;
|
||||||
|
|
||||||
|
struct EdgeColor { uint8_t r, g, b; };
|
||||||
|
|
||||||
|
static EdgeColor averageEdge(const uint8_t* rgb, uint16_t w, uint16_t h,
|
||||||
|
int side) {
|
||||||
|
// side: 0=top, 1=bottom, 2=left, 3=right
|
||||||
|
uint32_t sumR = 0, sumG = 0, sumB = 0, count = 0;
|
||||||
|
|
||||||
|
switch (side) {
|
||||||
|
case 0: { // top rows
|
||||||
|
int rows = min((int)h, EDGE_DEPTH);
|
||||||
|
for (int y = 0; y < rows; y++)
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
size_t idx = ((size_t)y * w + x) * 3;
|
||||||
|
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1: { // bottom rows
|
||||||
|
int rows = min((int)h, EDGE_DEPTH);
|
||||||
|
for (int y = h - rows; y < h; y++)
|
||||||
|
for (int x = 0; x < w; x++) {
|
||||||
|
size_t idx = ((size_t)y * w + x) * 3;
|
||||||
|
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2: { // left columns
|
||||||
|
int cols = min((int)w, EDGE_DEPTH);
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = 0; x < cols; x++) {
|
||||||
|
size_t idx = ((size_t)y * w + x) * 3;
|
||||||
|
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3: { // right columns
|
||||||
|
int cols = min((int)w, EDGE_DEPTH);
|
||||||
|
for (int y = 0; y < h; y++)
|
||||||
|
for (int x = w - cols; x < w; x++) {
|
||||||
|
size_t idx = ((size_t)y * w + x) * 3;
|
||||||
|
sumR += rgb[idx]; sumG += rgb[idx+1]; sumB += rgb[idx+2]; count++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0) return {0, 0, 0};
|
||||||
|
return {(uint8_t)(sumR / count), (uint8_t)(sumG / count), (uint8_t)(sumB / count)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-place horizontal box blur on a rectangular sub-region of a row-major RGB buffer.
|
||||||
|
// Operates on rows from y0..y1-1, columns x0..x1-1, within a buffer of stride `stride` pixels.
|
||||||
|
static void boxBlurH(uint8_t* buf, uint16_t stride,
|
||||||
|
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
|
||||||
|
int width = x1 - x0;
|
||||||
|
if (width <= 0) return;
|
||||||
|
|
||||||
|
// Temp row buffer for one scanline (3 bytes per pixel)
|
||||||
|
uint8_t* tmp = (uint8_t*)malloc(width * 3);
|
||||||
|
if (tmp == nullptr) return;
|
||||||
|
|
||||||
|
int diam = radius * 2 + 1;
|
||||||
|
|
||||||
|
for (uint16_t y = y0; y < y1; y++) {
|
||||||
|
uint8_t* row = buf + ((size_t)y * stride + x0) * 3;
|
||||||
|
|
||||||
|
// Running sum initialization
|
||||||
|
int sumR = 0, sumG = 0, sumB = 0;
|
||||||
|
for (int i = -radius; i <= radius; i++) {
|
||||||
|
int xi = constrain(i, 0, width - 1);
|
||||||
|
sumR += row[xi * 3];
|
||||||
|
sumG += row[xi * 3 + 1];
|
||||||
|
sumB += row[xi * 3 + 2];
|
||||||
|
}
|
||||||
|
tmp[0] = sumR / diam;
|
||||||
|
tmp[1] = sumG / diam;
|
||||||
|
tmp[2] = sumB / diam;
|
||||||
|
|
||||||
|
for (int x = 1; x < width; x++) {
|
||||||
|
int addIdx = constrain(x + radius, 0, width - 1);
|
||||||
|
int remIdx = constrain(x - radius - 1, 0, width - 1);
|
||||||
|
sumR += row[addIdx * 3] - row[remIdx * 3];
|
||||||
|
sumG += row[addIdx * 3 + 1] - row[remIdx * 3 + 1];
|
||||||
|
sumB += row[addIdx * 3 + 2] - row[remIdx * 3 + 2];
|
||||||
|
tmp[x * 3] = sumR / diam;
|
||||||
|
tmp[x * 3 + 1] = sumG / diam;
|
||||||
|
tmp[x * 3 + 2] = sumB / diam;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(row, tmp, width * 3);
|
||||||
|
}
|
||||||
|
free(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-place vertical box blur on a rectangular sub-region.
|
||||||
|
static void boxBlurV(uint8_t* buf, uint16_t stride,
|
||||||
|
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) {
|
||||||
|
int height = y1 - y0;
|
||||||
|
if (height <= 0) return;
|
||||||
|
|
||||||
|
uint8_t* tmp = (uint8_t*)malloc(height * 3);
|
||||||
|
if (tmp == nullptr) return;
|
||||||
|
|
||||||
|
int diam = radius * 2 + 1;
|
||||||
|
|
||||||
|
for (uint16_t x = x0; x < x1; x++) {
|
||||||
|
// Running sum initialization
|
||||||
|
int sumR = 0, sumG = 0, sumB = 0;
|
||||||
|
for (int i = -radius; i <= radius; i++) {
|
||||||
|
int yi = y0 + constrain(i, 0, height - 1);
|
||||||
|
size_t idx = ((size_t)yi * stride + x) * 3;
|
||||||
|
sumR += buf[idx];
|
||||||
|
sumG += buf[idx + 1];
|
||||||
|
sumB += buf[idx + 2];
|
||||||
|
}
|
||||||
|
tmp[0] = sumR / diam;
|
||||||
|
tmp[1] = sumG / diam;
|
||||||
|
tmp[2] = sumB / diam;
|
||||||
|
|
||||||
|
for (int y = 1; y < height; y++) {
|
||||||
|
int addY = y0 + constrain(y + radius, 0, height - 1);
|
||||||
|
int remY = y0 + constrain(y - radius - 1, 0, height - 1);
|
||||||
|
size_t addIdx = ((size_t)addY * stride + x) * 3;
|
||||||
|
size_t remIdx = ((size_t)remY * stride + x) * 3;
|
||||||
|
sumR += buf[addIdx] - buf[remIdx];
|
||||||
|
sumG += buf[addIdx + 1] - buf[remIdx + 1];
|
||||||
|
sumB += buf[addIdx + 2] - buf[remIdx + 2];
|
||||||
|
tmp[y * 3] = sumR / diam;
|
||||||
|
tmp[y * 3 + 1] = sumG / diam;
|
||||||
|
tmp[y * 3 + 2] = sumB / diam;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
size_t idx = ((size_t)(y0 + y) * stride + x) * 3;
|
||||||
|
buf[idx] = tmp[y * 3];
|
||||||
|
buf[idx + 1] = tmp[y * 3 + 1];
|
||||||
|
buf[idx + 2] = tmp[y * 3 + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-pass box blur (approximates Gaussian) on a sub-region
|
||||||
|
static void gaussianBlurRegion(uint8_t* buf, uint16_t stride,
|
||||||
|
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1,
|
||||||
|
int radius) {
|
||||||
|
for (int pass = 0; pass < 3; pass++) {
|
||||||
|
boxBlurH(buf, stride, x0, y0, x1, y1, radius);
|
||||||
|
boxBlurV(buf, stride, x0, y0, x1, y1, radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill letterbox bars with mirrored + blurred + faded content from the photo edge.
|
||||||
|
// The fitted image must already be placed in final_buf at (offsetX, offsetY).
|
||||||
|
static void fillLetterbox(uint8_t* final_buf, uint16_t targetW, uint16_t targetH,
|
||||||
|
const uint8_t* fitted, uint16_t fitW, uint16_t fitH,
|
||||||
|
uint16_t offsetX, uint16_t offsetY) {
|
||||||
|
static constexpr int BLUR_RADIUS = 6;
|
||||||
|
|
||||||
|
if (offsetY > 0) {
|
||||||
|
// Horizontal bars (top and bottom)
|
||||||
|
EdgeColor topEdge = averageEdge(fitted, fitW, fitH, 0);
|
||||||
|
EdgeColor botEdge = averageEdge(fitted, fitW, fitH, 1);
|
||||||
|
|
||||||
|
// --- Extend top edge into top bar ---
|
||||||
|
// Every row in the bar copies from the photo's topmost row (row 0)
|
||||||
|
for (uint16_t y = 0; y < offsetY; y++) {
|
||||||
|
for (uint16_t x = 0; x < targetW; x++) {
|
||||||
|
int srcX = (int)x - (int)offsetX;
|
||||||
|
srcX = constrain(srcX, 0, (int)fitW - 1);
|
||||||
|
size_t srcIdx = ((size_t)0 * fitW + srcX) * 3; // always row 0
|
||||||
|
size_t dstIdx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[dstIdx] = fitted[srcIdx];
|
||||||
|
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
|
||||||
|
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extend bottom edge into bottom bar ---
|
||||||
|
// Every row copies from the photo's bottommost row (fitH - 1)
|
||||||
|
uint16_t botStart = offsetY + fitH;
|
||||||
|
uint16_t lastRow = fitH - 1;
|
||||||
|
for (uint16_t y = botStart; y < targetH; y++) {
|
||||||
|
for (uint16_t x = 0; x < targetW; x++) {
|
||||||
|
int srcX = (int)x - (int)offsetX;
|
||||||
|
srcX = constrain(srcX, 0, (int)fitW - 1);
|
||||||
|
size_t srcIdx = ((size_t)lastRow * fitW + srcX) * 3;
|
||||||
|
size_t dstIdx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[dstIdx] = fitted[srcIdx];
|
||||||
|
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
|
||||||
|
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Blur both bars ---
|
||||||
|
gaussianBlurRegion(final_buf, targetW, 0, 0, targetW, offsetY, BLUR_RADIUS);
|
||||||
|
gaussianBlurRegion(final_buf, targetW, 0, botStart, targetW, targetH, BLUR_RADIUS);
|
||||||
|
|
||||||
|
// --- Fade toward solid edge color ---
|
||||||
|
for (uint16_t y = 0; y < offsetY; y++) {
|
||||||
|
float alpha = (float)(offsetY - 1 - y) / (float)offsetY; // 0 at photo, 1 at screen edge
|
||||||
|
for (uint16_t x = 0; x < targetW; x++) {
|
||||||
|
size_t idx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + topEdge.r * alpha);
|
||||||
|
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + topEdge.g * alpha);
|
||||||
|
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + topEdge.b * alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uint16_t y = botStart; y < targetH; y++) {
|
||||||
|
float alpha = (float)(y - botStart) / (float)(targetH - botStart);
|
||||||
|
for (uint16_t x = 0; x < targetW; x++) {
|
||||||
|
size_t idx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + botEdge.r * alpha);
|
||||||
|
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + botEdge.g * alpha);
|
||||||
|
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + botEdge.b * alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] Letterbox TB: bars %dpx, top=#%02X%02X%02X bot=#%02X%02X%02X\n",
|
||||||
|
offsetY, topEdge.r, topEdge.g, topEdge.b, botEdge.r, botEdge.g, botEdge.b);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offsetX > 0) {
|
||||||
|
// Vertical bars (left and right)
|
||||||
|
EdgeColor leftEdge = averageEdge(fitted, fitW, fitH, 2);
|
||||||
|
EdgeColor rightEdge = averageEdge(fitted, fitW, fitH, 3);
|
||||||
|
|
||||||
|
// --- Extend left edge into left bar ---
|
||||||
|
// Every column in the bar copies from the photo's leftmost column (col 0)
|
||||||
|
for (uint16_t y = 0; y < targetH; y++) {
|
||||||
|
int srcY = (int)y - (int)offsetY;
|
||||||
|
srcY = constrain(srcY, 0, (int)fitH - 1);
|
||||||
|
size_t srcIdx = ((size_t)srcY * fitW + 0) * 3; // always column 0
|
||||||
|
for (uint16_t x = 0; x < offsetX; x++) {
|
||||||
|
size_t dstIdx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[dstIdx] = fitted[srcIdx];
|
||||||
|
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
|
||||||
|
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extend right edge into right bar ---
|
||||||
|
// Every column copies from the photo's rightmost column (fitW - 1)
|
||||||
|
uint16_t rightStart = offsetX + fitW;
|
||||||
|
uint16_t lastCol = fitW - 1;
|
||||||
|
for (uint16_t y = 0; y < targetH; y++) {
|
||||||
|
int srcY = (int)y - (int)offsetY;
|
||||||
|
srcY = constrain(srcY, 0, (int)fitH - 1);
|
||||||
|
size_t srcIdx = ((size_t)srcY * fitW + lastCol) * 3;
|
||||||
|
for (uint16_t x = rightStart; x < targetW; x++) {
|
||||||
|
size_t dstIdx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[dstIdx] = fitted[srcIdx];
|
||||||
|
final_buf[dstIdx + 1] = fitted[srcIdx + 1];
|
||||||
|
final_buf[dstIdx + 2] = fitted[srcIdx + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Blur both bars ---
|
||||||
|
gaussianBlurRegion(final_buf, targetW, 0, 0, offsetX, targetH, BLUR_RADIUS);
|
||||||
|
gaussianBlurRegion(final_buf, targetW, rightStart, 0, targetW, targetH, BLUR_RADIUS);
|
||||||
|
|
||||||
|
// --- Fade toward solid edge color ---
|
||||||
|
for (uint16_t y = 0; y < targetH; y++) {
|
||||||
|
for (uint16_t x = 0; x < offsetX; x++) {
|
||||||
|
float alpha = (float)(offsetX - 1 - x) / (float)offsetX;
|
||||||
|
size_t idx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + leftEdge.r * alpha);
|
||||||
|
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + leftEdge.g * alpha);
|
||||||
|
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + leftEdge.b * alpha);
|
||||||
|
}
|
||||||
|
for (uint16_t x = rightStart; x < targetW; x++) {
|
||||||
|
float alpha = (float)(x - rightStart) / (float)(targetW - rightStart);
|
||||||
|
size_t idx = ((size_t)y * targetW + x) * 3;
|
||||||
|
final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + rightEdge.r * alpha);
|
||||||
|
final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + rightEdge.g * alpha);
|
||||||
|
final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + rightEdge.b * alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] Letterbox LR: bars %dpx, left=#%02X%02X%02X right=#%02X%02X%02X\n",
|
||||||
|
offsetX, leftEdge.r, leftEdge.g, leftEdge.b,
|
||||||
|
rightEdge.r, rightEdge.g, rightEdge.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bilinear resize from src (srcW x srcH) to dst (dstW x dstH)
|
||||||
|
static void bilinearResize(const uint8_t* src, uint16_t srcW, uint16_t srcH,
|
||||||
|
uint8_t* dst, uint16_t dstW, uint16_t dstH) {
|
||||||
|
if (dstW <= 1 || dstH <= 1 || srcW <= 1 || srcH <= 1) return;
|
||||||
|
|
||||||
|
float xRatio = (float)(srcW - 1) / (float)(dstW - 1);
|
||||||
|
float yRatio = (float)(srcH - 1) / (float)(dstH - 1);
|
||||||
|
|
||||||
|
for (uint16_t y = 0; y < dstH; y++) {
|
||||||
|
float srcY = y * yRatio;
|
||||||
|
uint16_t y0 = (uint16_t)srcY;
|
||||||
|
uint16_t y1 = min((uint16_t)(y0 + 1), (uint16_t)(srcH - 1));
|
||||||
|
float yFrac = srcY - y0;
|
||||||
|
|
||||||
|
for (uint16_t x = 0; x < dstW; x++) {
|
||||||
|
float srcX = x * xRatio;
|
||||||
|
uint16_t x0 = (uint16_t)srcX;
|
||||||
|
uint16_t x1 = min((uint16_t)(x0 + 1), (uint16_t)(srcW - 1));
|
||||||
|
float xFrac = srcX - x0;
|
||||||
|
|
||||||
|
for (int c = 0; c < 3; c++) {
|
||||||
|
float top = src[(y0 * srcW + x0) * 3 + c] * (1 - xFrac) +
|
||||||
|
src[(y0 * srcW + x1) * 3 + c] * xFrac;
|
||||||
|
float bot = src[(y1 * srcW + x0) * 3 + c] * (1 - xFrac) +
|
||||||
|
src[(y1 * srcW + x1) * 3 + c] * xFrac;
|
||||||
|
float val = top * (1 - yFrac) + bot * yFrac;
|
||||||
|
dst[(y * dstW + x) * 3 + c] = (uint8_t)(val + 0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
uint8_t* ImagePipeline::decodeAndFit(uint8_t* data, size_t size,
|
||||||
|
uint16_t targetW, uint16_t targetH,
|
||||||
|
uint16_t* outW, uint16_t* outH) {
|
||||||
|
// Decode JPEG using stb_image (handles baseline, progressive, extended sequential)
|
||||||
|
int imgW, imgH, channels;
|
||||||
|
uint8_t* decoded = stbi_load_from_memory(data, (int)size, &imgW, &imgH, &channels, 3);
|
||||||
|
if (decoded == nullptr) {
|
||||||
|
Serial.printf("[pipeline] stbi decode failed: %s\n", stbi_failure_reason());
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] JPEG: %dx%d decoded (channels=%d)\n", imgW, imgH, channels);
|
||||||
|
|
||||||
|
// Fit-contain: scale to fit entirely within target dimensions
|
||||||
|
float fitScaleW = (float)targetW / (float)imgW;
|
||||||
|
float fitScaleH = (float)targetH / (float)imgH;
|
||||||
|
float fitScale = fminf(fitScaleW, fitScaleH);
|
||||||
|
uint16_t fitW = (uint16_t)(imgW * fitScale);
|
||||||
|
uint16_t fitH = (uint16_t)(imgH * fitScale);
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] Fit: %dx%d -> %dx%d (scale %.2f)\n",
|
||||||
|
imgW, imgH, fitW, fitH, fitScale);
|
||||||
|
|
||||||
|
// Resize decoded image to fit dimensions
|
||||||
|
uint8_t* fitted = (uint8_t*)ps_malloc((size_t)fitW * fitH * 3);
|
||||||
|
if (fitted == nullptr) {
|
||||||
|
Serial.println("[pipeline] Fit alloc failed");
|
||||||
|
stbi_image_free(decoded);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
bilinearResize(decoded, (uint16_t)imgW, (uint16_t)imgH, fitted, fitW, fitH);
|
||||||
|
stbi_image_free(decoded);
|
||||||
|
|
||||||
|
// Allocate final target-sized buffer (zeroed)
|
||||||
|
uint8_t* final_buf = (uint8_t*)ps_calloc(targetW * targetH, 3);
|
||||||
|
if (final_buf == nullptr) {
|
||||||
|
Serial.println("[pipeline] Final buffer alloc failed");
|
||||||
|
free(fitted);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center the fitted image into the final buffer
|
||||||
|
uint16_t offsetX = (targetW - fitW) / 2;
|
||||||
|
uint16_t offsetY = (targetH - fitH) / 2;
|
||||||
|
for (uint16_t y = 0; y < fitH; y++) {
|
||||||
|
memcpy(final_buf + ((size_t)(offsetY + y) * targetW + offsetX) * 3,
|
||||||
|
fitted + (size_t)y * fitW * 3,
|
||||||
|
fitW * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill letterbox bars — each bar matches the adjacent photo edge
|
||||||
|
fillLetterbox(final_buf, targetW, targetH, fitted, fitW, fitH, offsetX, offsetY);
|
||||||
|
free(fitted);
|
||||||
|
|
||||||
|
*outW = targetW;
|
||||||
|
*outH = targetH;
|
||||||
|
return final_buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessedImage ImagePipeline::process(uint8_t* jpegData, size_t jpegSize) {
|
||||||
|
ProcessedImage result = {nullptr, DISPLAY_WIDTH, DISPLAY_HEIGHT, false};
|
||||||
|
|
||||||
|
uint16_t outW, outH;
|
||||||
|
uint8_t* rgb = decodeAndFit(jpegData, jpegSize, DISPLAY_WIDTH, DISPLAY_HEIGHT, &outW, &outH);
|
||||||
|
if (rgb == nullptr) {
|
||||||
|
Serial.println("[pipeline] Decode/fit failed");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[pipeline] Got %dx%d fitted RGB\n", outW, outH);
|
||||||
|
|
||||||
|
size_t totalPixels = (size_t)outW * outH;
|
||||||
|
switch (_mode) {
|
||||||
|
case PipelineMode::DYNAMIC:
|
||||||
|
toneMap(rgb, totalPixels);
|
||||||
|
break;
|
||||||
|
case PipelineMode::BALANCED:
|
||||||
|
compressDynamicRange(rgb, totalPixels);
|
||||||
|
break;
|
||||||
|
case PipelineMode::NONE:
|
||||||
|
break;
|
||||||
|
// No default — compiler warns on unhandled PipelineMode via -Wswitch
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* dithered = ditherRowByRow(rgb, outW, outH);
|
||||||
|
free(rgb);
|
||||||
|
|
||||||
|
if (dithered == nullptr) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.framebuffer = dithered;
|
||||||
|
result.width = outW;
|
||||||
|
result.height = outH;
|
||||||
|
result.valid = true;
|
||||||
|
const char* modeName = (_mode == PipelineMode::DYNAMIC) ? "dynamic"
|
||||||
|
: (_mode == PipelineMode::BALANCED) ? "balanced"
|
||||||
|
: "none";
|
||||||
|
Serial.printf("[pipeline] Processing complete (%dx%d, mode=%s)\n",
|
||||||
|
outW, outH, modeName);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessedImage ImagePipeline::processPortraitPair(uint8_t* jpeg1Data, size_t jpeg1Size,
|
||||||
|
uint8_t* jpeg2Data, size_t jpeg2Size) {
|
||||||
|
ProcessedImage result = {nullptr, DISPLAY_WIDTH, DISPLAY_HEIGHT, false};
|
||||||
|
|
||||||
|
uint16_t portraitW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
|
||||||
|
uint16_t portraitH = DISPLAY_HEIGHT;
|
||||||
|
|
||||||
|
uint16_t out1W, out1H;
|
||||||
|
uint8_t* rgb1 = decodeAndFit(jpeg1Data, jpeg1Size, portraitW, portraitH, &out1W, &out1H);
|
||||||
|
|
||||||
|
uint16_t out2W, out2H;
|
||||||
|
uint8_t* rgb2 = decodeAndFit(jpeg2Data, jpeg2Size, portraitW, portraitH, &out2W, &out2H);
|
||||||
|
|
||||||
|
// Allocate combined RGB buffer
|
||||||
|
uint8_t* combined = (uint8_t*)ps_calloc(DISPLAY_WIDTH * DISPLAY_HEIGHT, 3);
|
||||||
|
if (combined == nullptr) {
|
||||||
|
if (rgb1) free(rgb1);
|
||||||
|
if (rgb2) free(rgb2);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rgb1 != nullptr) {
|
||||||
|
for (uint16_t y = 0; y < out1H; y++) {
|
||||||
|
memcpy(combined + y * DISPLAY_WIDTH * 3,
|
||||||
|
rgb1 + y * out1W * 3, out1W * 3);
|
||||||
|
}
|
||||||
|
free(rgb1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rgb2 != nullptr) {
|
||||||
|
uint16_t offsetX = portraitW + PORTRAIT_GAP_PX;
|
||||||
|
for (uint16_t y = 0; y < out2H; y++) {
|
||||||
|
memcpy(combined + (y * DISPLAY_WIDTH + offsetX) * 3,
|
||||||
|
rgb2 + y * out2W * 3, out2W * 3);
|
||||||
|
}
|
||||||
|
free(rgb2);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t combinedPixels = (size_t)DISPLAY_WIDTH * DISPLAY_HEIGHT;
|
||||||
|
switch (_mode) {
|
||||||
|
case PipelineMode::DYNAMIC:
|
||||||
|
toneMap(combined, combinedPixels);
|
||||||
|
break;
|
||||||
|
case PipelineMode::BALANCED:
|
||||||
|
compressDynamicRange(combined, combinedPixels);
|
||||||
|
break;
|
||||||
|
case PipelineMode::NONE:
|
||||||
|
break;
|
||||||
|
// No default — compiler warns on unhandled PipelineMode via -Wswitch
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* dithered = ditherRowByRow(combined, DISPLAY_WIDTH, DISPLAY_HEIGHT);
|
||||||
|
free(combined);
|
||||||
|
|
||||||
|
if (dithered == nullptr) return result;
|
||||||
|
|
||||||
|
result.framebuffer = dithered;
|
||||||
|
result.width = DISPLAY_WIDTH;
|
||||||
|
result.height = DISPLAY_HEIGHT;
|
||||||
|
result.valid = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ImagePipeline::freeImage(ProcessedImage& img) {
|
||||||
|
if (img.framebuffer) {
|
||||||
|
free(img.framebuffer);
|
||||||
|
img.framebuffer = nullptr;
|
||||||
|
}
|
||||||
|
img.valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* ImagePipeline::ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t height) {
|
||||||
|
size_t pixelCount = (size_t)width * height;
|
||||||
|
uint8_t* output = (uint8_t*)ps_malloc(pixelCount);
|
||||||
|
if (output == nullptr) {
|
||||||
|
Serial.println("[pipeline] Dither output alloc failed");
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t rowBytes = (size_t)width * 3 * sizeof(int16_t);
|
||||||
|
int16_t* errCurrent = (int16_t*)ps_malloc(rowBytes);
|
||||||
|
int16_t* errNext = (int16_t*)ps_malloc(rowBytes);
|
||||||
|
if (errCurrent == nullptr || errNext == nullptr) {
|
||||||
|
Serial.println("[pipeline] Dither error buffer alloc failed");
|
||||||
|
free(output);
|
||||||
|
if (errCurrent) free(errCurrent);
|
||||||
|
if (errNext) free(errNext);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize first row
|
||||||
|
for (uint16_t x = 0; x < width; x++) {
|
||||||
|
size_t srcIdx = x * 3;
|
||||||
|
size_t errIdx = x * 3;
|
||||||
|
errCurrent[errIdx] = rgb[srcIdx];
|
||||||
|
errCurrent[errIdx + 1] = rgb[srcIdx + 1];
|
||||||
|
errCurrent[errIdx + 2] = rgb[srcIdx + 2];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint16_t y = 0; y < height; y++) {
|
||||||
|
// Prepare next row from source
|
||||||
|
if (y + 1 < height) {
|
||||||
|
size_t nextRowOffset = (size_t)(y + 1) * width * 3;
|
||||||
|
for (uint16_t x = 0; x < width; x++) {
|
||||||
|
size_t srcIdx = nextRowOffset + x * 3;
|
||||||
|
size_t errIdx = x * 3;
|
||||||
|
errNext[errIdx] = rgb[srcIdx];
|
||||||
|
errNext[errIdx + 1] = rgb[srcIdx + 1];
|
||||||
|
errNext[errIdx + 2] = rgb[srcIdx + 2];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
memset(errNext, 0, rowBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serpentine: alternate scan direction each row
|
||||||
|
bool forward = (y % 2 == 0);
|
||||||
|
int xStart = forward ? 0 : (int)width - 1;
|
||||||
|
int xEnd = forward ? (int)width : -1;
|
||||||
|
int xStep = forward ? 1 : -1;
|
||||||
|
|
||||||
|
for (int x = xStart; x != xEnd; x += xStep) {
|
||||||
|
size_t errIdx = (size_t)x * 3;
|
||||||
|
|
||||||
|
int r = constrain(errCurrent[errIdx], 0, 255);
|
||||||
|
int g = constrain(errCurrent[errIdx + 1], 0, 255);
|
||||||
|
int b = constrain(errCurrent[errIdx + 2], 0, 255);
|
||||||
|
|
||||||
|
uint8_t nearest = findNearest(r, g, b);
|
||||||
|
output[y * width + x] = nearest;
|
||||||
|
|
||||||
|
int errR = r - PALETTE_CALIBRATED[nearest][0];
|
||||||
|
int errG = g - PALETTE_CALIBRATED[nearest][1];
|
||||||
|
int errB = b - PALETTE_CALIBRATED[nearest][2];
|
||||||
|
|
||||||
|
// Floyd-Steinberg: mirror dx offsets on reverse rows
|
||||||
|
int xRight = forward ? x + 1 : x - 1;
|
||||||
|
int xLeft = forward ? x - 1 : x + 1;
|
||||||
|
|
||||||
|
// 7/16 to next pixel in scan direction
|
||||||
|
if (xRight >= 0 && xRight < (int)width) {
|
||||||
|
size_t ni = (size_t)xRight * 3;
|
||||||
|
errCurrent[ni] += errR * 7 / 16;
|
||||||
|
errCurrent[ni + 1] += errG * 7 / 16;
|
||||||
|
errCurrent[ni + 2] += errB * 7 / 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y + 1 < height) {
|
||||||
|
if (_blueNoise) {
|
||||||
|
// Randomized error scatter: the diagonal pattern is caused
|
||||||
|
// by the 5/16 "below" weight always landing error on the
|
||||||
|
// same column, creating vertical correlation that manifests
|
||||||
|
// as diagonal lines. We break this by randomly offsetting
|
||||||
|
// the entire below-row error target by -2..+2 pixels.
|
||||||
|
// Energy is perfectly conserved (same 9/16 total, same
|
||||||
|
// 3/5/1 ratio — just shifted horizontally).
|
||||||
|
uint32_t h = (uint32_t)x * 2654435761u
|
||||||
|
^ (uint32_t)y * 2246822519u;
|
||||||
|
int offset = (int)(h % 5u) - 2; // -2, -1, 0, +1, or +2
|
||||||
|
|
||||||
|
int xBL = xLeft + offset;
|
||||||
|
int xB = x + offset;
|
||||||
|
int xBR = xRight + offset;
|
||||||
|
|
||||||
|
// 3/16 to below-left (shifted)
|
||||||
|
if (xBL >= 0 && xBL < (int)width) {
|
||||||
|
size_t ni = (size_t)xBL * 3;
|
||||||
|
errNext[ni] += errR * 3 / 16;
|
||||||
|
errNext[ni + 1] += errG * 3 / 16;
|
||||||
|
errNext[ni + 2] += errB * 3 / 16;
|
||||||
|
}
|
||||||
|
// 5/16 to below (shifted)
|
||||||
|
if (xB >= 0 && xB < (int)width) {
|
||||||
|
size_t ni = (size_t)xB * 3;
|
||||||
|
errNext[ni] += errR * 5 / 16;
|
||||||
|
errNext[ni + 1] += errG * 5 / 16;
|
||||||
|
errNext[ni + 2] += errB * 5 / 16;
|
||||||
|
}
|
||||||
|
// 1/16 to below-right (shifted)
|
||||||
|
if (xBR >= 0 && xBR < (int)width) {
|
||||||
|
size_t ni = (size_t)xBR * 3;
|
||||||
|
errNext[ni] += errR * 1 / 16;
|
||||||
|
errNext[ni + 1] += errG * 1 / 16;
|
||||||
|
errNext[ni + 2] += errB * 1 / 16;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Standard fixed Floyd-Steinberg weights
|
||||||
|
if (xLeft >= 0 && xLeft < (int)width) {
|
||||||
|
size_t ni = (size_t)xLeft * 3;
|
||||||
|
errNext[ni] += errR * 3 / 16;
|
||||||
|
errNext[ni + 1] += errG * 3 / 16;
|
||||||
|
errNext[ni + 2] += errB * 3 / 16;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
size_t ni = (size_t)x * 3;
|
||||||
|
errNext[ni] += errR * 5 / 16;
|
||||||
|
errNext[ni + 1] += errG * 5 / 16;
|
||||||
|
errNext[ni + 2] += errB * 5 / 16;
|
||||||
|
}
|
||||||
|
if (xRight >= 0 && xRight < (int)width) {
|
||||||
|
size_t ni = (size_t)xRight * 3;
|
||||||
|
errNext[ni] += errR * 1 / 16;
|
||||||
|
errNext[ni + 1] += errG * 1 / 16;
|
||||||
|
errNext[ni + 2] += errB * 1 / 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t* tmp = errCurrent;
|
||||||
|
errCurrent = errNext;
|
||||||
|
errNext = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
free(errCurrent);
|
||||||
|
free(errNext);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t ImagePipeline::findNearest(int r, int g, int b) {
|
||||||
|
uint8_t best = 0;
|
||||||
|
int32_t bestDist = INT32_MAX;
|
||||||
|
for (int i = 0; i < DISPLAY_COLORS; i++) {
|
||||||
|
int dr = r - PALETTE_CALIBRATED[i][0];
|
||||||
|
int dg = g - PALETTE_CALIBRATED[i][1];
|
||||||
|
int db = b - PALETTE_CALIBRATED[i][2];
|
||||||
|
// Rec. 709 luminance-weighted distance (integer weights x10000)
|
||||||
|
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = static_cast<uint8_t>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
51
src/image_pipeline.h
Normal file
51
src/image_pipeline.h
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
struct ProcessedImage {
|
||||||
|
uint8_t* framebuffer; // Palette indices, one byte per pixel
|
||||||
|
uint16_t width;
|
||||||
|
uint16_t height;
|
||||||
|
bool valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pipeline processing mode (epdoptimize recommends one or the other, not both)
|
||||||
|
enum class PipelineMode : uint8_t {
|
||||||
|
DYNAMIC, // S-curve tone mapping + saturation boost, no DRC
|
||||||
|
BALANCED, // Dynamic range compression only, no tone mapping
|
||||||
|
NONE // No pre-processing, straight to dither
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImagePipeline {
|
||||||
|
public:
|
||||||
|
// Process single landscape photo (fit-contain with edge-color letterbox)
|
||||||
|
ProcessedImage process(uint8_t* jpegData, size_t jpegSize);
|
||||||
|
|
||||||
|
// Process portrait pair (two photos side by side)
|
||||||
|
ProcessedImage processPortraitPair(uint8_t* jpeg1Data, size_t jpeg1Size,
|
||||||
|
uint8_t* jpeg2Data, size_t jpeg2Size);
|
||||||
|
|
||||||
|
void freeImage(ProcessedImage& img);
|
||||||
|
|
||||||
|
void setPipelineMode(PipelineMode mode) { _mode = mode; }
|
||||||
|
PipelineMode getPipelineMode() const { return _mode; }
|
||||||
|
|
||||||
|
void setBlueNoiseEnabled(bool enabled) { _blueNoise = enabled; }
|
||||||
|
bool getBlueNoiseEnabled() const { return _blueNoise; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
PipelineMode _mode = PipelineMode::DYNAMIC;
|
||||||
|
bool _blueNoise = true;
|
||||||
|
|
||||||
|
// Decode JPEG and fit into target dimensions with letterboxing
|
||||||
|
uint8_t* decodeAndFit(uint8_t* data, size_t size,
|
||||||
|
uint16_t targetW, uint16_t targetH,
|
||||||
|
uint16_t* outW, uint16_t* outH);
|
||||||
|
|
||||||
|
// Serpentine Floyd-Steinberg dither RGB888 to 6-color palette indices
|
||||||
|
uint8_t* ditherRowByRow(uint8_t* rgb, uint16_t width, uint16_t height);
|
||||||
|
|
||||||
|
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
|
||||||
|
uint8_t findNearest(int r, int g, int b);
|
||||||
|
};
|
||||||
601
src/immich_client.cpp
Normal file
601
src/immich_client.cpp
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
#include "immich_client.h"
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
|
||||||
|
void ImmichClient::begin(const String& baseUrl, const String& apiKey) {
|
||||||
|
_baseUrl = baseUrl;
|
||||||
|
// Remove trailing slash if present
|
||||||
|
if (_baseUrl.endsWith("/")) {
|
||||||
|
_baseUrl.remove(_baseUrl.length() - 1);
|
||||||
|
}
|
||||||
|
_apiKey = apiKey;
|
||||||
|
|
||||||
|
// Debug: show masked key so we can verify it's non-empty and correct prefix
|
||||||
|
String maskedKey = _apiKey.length() > 8
|
||||||
|
? _apiKey.substring(0, 8) + "..."
|
||||||
|
: (_apiKey.length() > 0 ? "***" : "(empty)");
|
||||||
|
Serial.printf("[immich] Configured: %s | key: %s (len=%d)\n",
|
||||||
|
_baseUrl.c_str(), maskedKey.c_str(), _apiKey.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<AlbumInfo> ImmichClient::fetchAlbums() {
|
||||||
|
std::vector<AlbumInfo> albums;
|
||||||
|
String url = buildUrl("/api/albums");
|
||||||
|
String response = httpGet(url);
|
||||||
|
|
||||||
|
if (response.isEmpty()) {
|
||||||
|
Serial.println("[immich] fetchAlbums: empty response");
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[immich] fetchAlbums JSON error: %s\n", err.c_str());
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonObject obj : arr) {
|
||||||
|
AlbumInfo album;
|
||||||
|
album.id = obj["id"].as<String>();
|
||||||
|
album.title = obj["albumName"].as<String>();
|
||||||
|
album.assetCount = obj["assetCount"] | 0;
|
||||||
|
albums.push_back(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Fetched %d albums\n", albums.size());
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> ImmichClient::fetchAlbumAssetIds(const String& albumId) {
|
||||||
|
std::vector<String> ids;
|
||||||
|
String url = buildUrl("/api/albums/" + albumId);
|
||||||
|
|
||||||
|
// Album responses can be very large (full asset metadata per photo).
|
||||||
|
// Use PSRAM-backed fetch to avoid exhausting regular heap.
|
||||||
|
size_t responseLen = 0;
|
||||||
|
char* response = httpGetPsram(url, &responseLen);
|
||||||
|
if (response == nullptr || responseLen == 0) {
|
||||||
|
Serial.printf("[immich] Album %s: empty response\n", albumId.c_str());
|
||||||
|
if (response) free(response);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Album %s: response %d bytes\n", albumId.c_str(), responseLen);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response, responseLen);
|
||||||
|
free(response); // Free PSRAM buffer immediately after parsing
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[immich] fetchAlbumAssets JSON error: %s\n", err.c_str());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray assets = doc["assets"].as<JsonArray>();
|
||||||
|
for (JsonObject asset : assets) {
|
||||||
|
String id = asset["id"].as<String>();
|
||||||
|
if (id.length() > 0) {
|
||||||
|
ids.push_back(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Album %s: %d assets\n", albumId.c_str(), ids.size());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> ImmichClient::fetchRandomAssetIds(int count,
|
||||||
|
const std::vector<String>& albumIds) {
|
||||||
|
std::vector<String> ids;
|
||||||
|
String url = buildUrl("/api/search/random");
|
||||||
|
|
||||||
|
JsonDocument reqDoc;
|
||||||
|
reqDoc["size"] = count;
|
||||||
|
reqDoc["type"] = "IMAGE";
|
||||||
|
if (!albumIds.empty()) {
|
||||||
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
||||||
|
for (auto& id : albumIds) arr.add(id);
|
||||||
|
}
|
||||||
|
String body;
|
||||||
|
serializeJson(reqDoc, body);
|
||||||
|
|
||||||
|
String response = httpPost(url, body);
|
||||||
|
|
||||||
|
if (response.isEmpty()) {
|
||||||
|
Serial.println("[immich] fetchRandom: empty response");
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[immich] fetchRandom JSON error: %s\n", err.c_str());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
int skippedRaw = 0;
|
||||||
|
for (JsonObject asset : arr) {
|
||||||
|
String id = asset["id"].as<String>();
|
||||||
|
if (id.length() == 0) continue;
|
||||||
|
|
||||||
|
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")) {
|
||||||
|
skippedRaw++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.push_back(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Fetched %d random assets%s (skipped %d RAW)\n",
|
||||||
|
ids.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<RandomAsset> ImmichClient::fetchRandomAssets(int count,
|
||||||
|
const std::vector<String>& albumIds) {
|
||||||
|
std::vector<RandomAsset> assets;
|
||||||
|
String url = buildUrl("/api/search/random");
|
||||||
|
|
||||||
|
JsonDocument reqDoc;
|
||||||
|
reqDoc["size"] = count;
|
||||||
|
reqDoc["type"] = "IMAGE";
|
||||||
|
if (!albumIds.empty()) {
|
||||||
|
JsonArray albumArr = reqDoc["albumIds"].to<JsonArray>();
|
||||||
|
for (auto& aid : albumIds) albumArr.add(aid);
|
||||||
|
}
|
||||||
|
String body;
|
||||||
|
serializeJson(reqDoc, body);
|
||||||
|
|
||||||
|
String response = httpPost(url, body);
|
||||||
|
if (response.isEmpty()) return assets;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) return assets;
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
int skippedRaw = 0;
|
||||||
|
for (JsonObject asset : arr) {
|
||||||
|
String id = asset["id"].as<String>();
|
||||||
|
if (id.length() == 0) continue;
|
||||||
|
|
||||||
|
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")) {
|
||||||
|
skippedRaw++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RandomAsset ra;
|
||||||
|
ra.id = id;
|
||||||
|
ra.dateTime = asset["localDateTime"] | "";
|
||||||
|
assets.push_back(ra);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Fetched %d random assets with dates%s (skipped %d RAW)\n",
|
||||||
|
assets.size(), albumIds.empty() ? "" : " from albums", skippedRaw);
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> ImmichClient::fetchFavoriteAssetIds() {
|
||||||
|
std::vector<String> ids;
|
||||||
|
String url = buildUrl("/api/assets?isFavorite=true");
|
||||||
|
String response = httpGet(url);
|
||||||
|
|
||||||
|
if (response.isEmpty()) return ids;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) return ids;
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonObject asset : arr) {
|
||||||
|
ids.push_back(asset["id"].as<String>());
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] Favorites: %d assets\n", ids.size());
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ImmichClient::fetchOneRandomAssetId(const RandomFetchFilter& filter) {
|
||||||
|
String url = buildUrl("/api/search/random");
|
||||||
|
|
||||||
|
JsonDocument reqDoc;
|
||||||
|
reqDoc["size"] = 1;
|
||||||
|
reqDoc["type"] = "IMAGE";
|
||||||
|
|
||||||
|
if (!filter.albumIds.empty()) {
|
||||||
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
||||||
|
for (auto& id : filter.albumIds) arr.add(id);
|
||||||
|
}
|
||||||
|
if (filter.favoriteOnly) {
|
||||||
|
reqDoc["isFavorite"] = true;
|
||||||
|
}
|
||||||
|
if (filter.takenAfter.length() > 0) {
|
||||||
|
reqDoc["takenAfter"] = filter.takenAfter;
|
||||||
|
}
|
||||||
|
if (filter.takenBefore.length() > 0) {
|
||||||
|
reqDoc["takenBefore"] = filter.takenBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
String body;
|
||||||
|
serializeJson(reqDoc, body);
|
||||||
|
|
||||||
|
Serial.printf("[immich] fetchOne: %s\n", body.c_str());
|
||||||
|
|
||||||
|
String response = httpPost(url, body);
|
||||||
|
if (response.isEmpty()) {
|
||||||
|
Serial.println("[immich] fetchOne: empty response");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[immich] fetchOne JSON error: %s\n", err.c_str());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonObject asset : arr) {
|
||||||
|
String id = asset["id"].as<String>();
|
||||||
|
if (id.length() == 0) continue;
|
||||||
|
|
||||||
|
// Skip RAW files
|
||||||
|
String filename = asset["originalFileName"] | "";
|
||||||
|
filename.toLowerCase();
|
||||||
|
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
|
||||||
|
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
|
||||||
|
filename.endsWith(".nef") || filename.endsWith(".arw") ||
|
||||||
|
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
|
||||||
|
filename.endsWith(".raf") || filename.endsWith(".srw")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[immich] fetchOne: no usable asset returned");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> ImmichClient::fetchFilteredBatch(const RandomFetchFilter& filter, int count) {
|
||||||
|
std::vector<String> results;
|
||||||
|
String url = buildUrl("/api/search/random");
|
||||||
|
|
||||||
|
JsonDocument reqDoc;
|
||||||
|
reqDoc["size"] = count;
|
||||||
|
reqDoc["type"] = "IMAGE";
|
||||||
|
|
||||||
|
if (!filter.albumIds.empty()) {
|
||||||
|
JsonArray arr = reqDoc["albumIds"].to<JsonArray>();
|
||||||
|
for (auto& id : filter.albumIds) arr.add(id);
|
||||||
|
}
|
||||||
|
if (filter.favoriteOnly) {
|
||||||
|
reqDoc["isFavorite"] = true;
|
||||||
|
}
|
||||||
|
if (filter.takenAfter.length() > 0) {
|
||||||
|
reqDoc["takenAfter"] = filter.takenAfter;
|
||||||
|
}
|
||||||
|
if (filter.takenBefore.length() > 0) {
|
||||||
|
reqDoc["takenBefore"] = filter.takenBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
String body;
|
||||||
|
serializeJson(reqDoc, body);
|
||||||
|
|
||||||
|
Serial.printf("[immich] fetchBatch(%d): %s\n", count, body.c_str());
|
||||||
|
|
||||||
|
String response = httpPost(url, body);
|
||||||
|
if (response.isEmpty()) {
|
||||||
|
Serial.println("[immich] fetchBatch: empty response");
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) {
|
||||||
|
Serial.printf("[immich] fetchBatch JSON error: %s\n", err.c_str());
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonObject asset : arr) {
|
||||||
|
String id = asset["id"].as<String>();
|
||||||
|
if (id.length() == 0) continue;
|
||||||
|
|
||||||
|
// Skip RAW files
|
||||||
|
String filename = asset["originalFileName"] | "";
|
||||||
|
filename.toLowerCase();
|
||||||
|
if (filename.endsWith(".dng") || filename.endsWith(".raw") ||
|
||||||
|
filename.endsWith(".cr2") || filename.endsWith(".cr3") ||
|
||||||
|
filename.endsWith(".nef") || filename.endsWith(".arw") ||
|
||||||
|
filename.endsWith(".orf") || filename.endsWith(".rw2") ||
|
||||||
|
filename.endsWith(".raf") || filename.endsWith(".srw")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push_back(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[immich] fetchBatch: got %d usable assets\n", (int)results.size());
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetInfo ImmichClient::fetchAssetInfo(const String& assetId) {
|
||||||
|
AssetInfo info;
|
||||||
|
info.id = assetId;
|
||||||
|
info.isFavorite = false;
|
||||||
|
info.isPortrait = false;
|
||||||
|
|
||||||
|
String url = buildUrl("/api/assets/" + assetId);
|
||||||
|
String response = httpGet(url);
|
||||||
|
|
||||||
|
if (response.isEmpty()) return info;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, response);
|
||||||
|
if (err) return info;
|
||||||
|
|
||||||
|
info.originalFileName = doc["originalFileName"] | "";
|
||||||
|
info.isFavorite = doc["isFavorite"] | false;
|
||||||
|
|
||||||
|
// Date
|
||||||
|
info.dateTime = doc["localDateTime"] | "";
|
||||||
|
|
||||||
|
// EXIF data
|
||||||
|
JsonObject exif = doc["exifInfo"];
|
||||||
|
if (!exif.isNull()) {
|
||||||
|
info.city = exif["city"] | "";
|
||||||
|
String make = exif["make"] | "";
|
||||||
|
String model = exif["model"] | "";
|
||||||
|
if (make.length() > 0 || model.length() > 0) {
|
||||||
|
info.camera = make + " " + model;
|
||||||
|
info.camera.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portrait detection: check orientation or dimensions
|
||||||
|
int width = exif["exifImageWidth"] | 0;
|
||||||
|
int height = exif["exifImageHeight"] | 0;
|
||||||
|
int orientation = exif["orientation"] | 1;
|
||||||
|
// Orientations 5-8 mean the image is rotated 90/270 degrees
|
||||||
|
if (orientation >= 5 && orientation <= 8) {
|
||||||
|
info.isPortrait = (width > height);
|
||||||
|
} else {
|
||||||
|
info.isPortrait = (height > width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// People
|
||||||
|
JsonArray people = doc["people"];
|
||||||
|
if (!people.isNull()) {
|
||||||
|
for (JsonObject person : people) {
|
||||||
|
String name = person["name"] | "";
|
||||||
|
if (name.length() > 0) {
|
||||||
|
info.people.push_back(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImmichClient::downloadAsset(const String& assetId, ImageQuality quality,
|
||||||
|
uint8_t** outBuffer, size_t* outSize) {
|
||||||
|
// Always request preview size with JPEG format.
|
||||||
|
// Server controls actual pixel dimensions via its image config (720px baseline).
|
||||||
|
// The "quality" setting is kept for future use but we always use preview for now
|
||||||
|
// to stay within PSRAM budget.
|
||||||
|
(void)quality;
|
||||||
|
String url = buildUrl("/api/assets/" + assetId + "/thumbnail?size=preview&format=jpeg");
|
||||||
|
|
||||||
|
if (!httpGetBinary(url, outBuffer, outSize)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JPEG magic bytes (FF D8 FF)
|
||||||
|
if (*outSize < 3 || (*outBuffer)[0] != 0xFF ||
|
||||||
|
(*outBuffer)[1] != 0xD8 || (*outBuffer)[2] != 0xFF) {
|
||||||
|
Serial.printf("[immich] Not JPEG data (magic: %02X %02X %02X)\n",
|
||||||
|
(*outBuffer)[0], (*outBuffer)[1], (*outBuffer)[2]);
|
||||||
|
free(*outBuffer);
|
||||||
|
*outBuffer = nullptr;
|
||||||
|
*outSize = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ImmichClient::buildUrl(const String& path) {
|
||||||
|
return _baseUrl + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ImmichClient::httpGet(const String& url) {
|
||||||
|
WiFiClientSecure client;
|
||||||
|
client.setInsecure(); // Skip TLS cert verification for self-hosted
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(client, url);
|
||||||
|
http.addHeader("x-api-key", _apiKey);
|
||||||
|
http.setTimeout(30000);
|
||||||
|
|
||||||
|
int code = http.GET();
|
||||||
|
String result = "";
|
||||||
|
|
||||||
|
if (code == HTTP_CODE_OK) {
|
||||||
|
result = http.getString();
|
||||||
|
} else {
|
||||||
|
Serial.printf("[immich] HTTP GET %s failed: %d\n", url.c_str(), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ImmichClient::httpPost(const String& url, const String& body) {
|
||||||
|
WiFiClientSecure client;
|
||||||
|
client.setInsecure();
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(client, url);
|
||||||
|
http.addHeader("x-api-key", _apiKey);
|
||||||
|
http.addHeader("Content-Type", "application/json");
|
||||||
|
http.setTimeout(30000);
|
||||||
|
|
||||||
|
int code = http.POST(body);
|
||||||
|
String result = "";
|
||||||
|
|
||||||
|
if (code == HTTP_CODE_OK) {
|
||||||
|
result = http.getString();
|
||||||
|
} else {
|
||||||
|
Serial.printf("[immich] HTTP POST %s failed: %d\n", url.c_str(), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* ImmichClient::httpGetPsram(const String& url, size_t* outLen) {
|
||||||
|
*outLen = 0;
|
||||||
|
WiFiClientSecure client;
|
||||||
|
client.setInsecure();
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(client, url);
|
||||||
|
http.addHeader("x-api-key", _apiKey);
|
||||||
|
http.setTimeout(60000);
|
||||||
|
|
||||||
|
int code = http.GET();
|
||||||
|
if (code != HTTP_CODE_OK) {
|
||||||
|
Serial.printf("[immich] PSRAM GET %s failed: %d\n", url.c_str(), code);
|
||||||
|
http.end();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int contentLength = http.getSize();
|
||||||
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
|
||||||
|
// For chunked responses without Content-Length, read in chunks
|
||||||
|
if (contentLength <= 0) {
|
||||||
|
// Read incrementally into PSRAM, up to 4MB max
|
||||||
|
const size_t maxSize = 4 * 1024 * 1024;
|
||||||
|
size_t capacity = 64 * 1024;
|
||||||
|
char* buf = (char*)ps_malloc(capacity);
|
||||||
|
if (!buf) {
|
||||||
|
Serial.println("[immich] PSRAM alloc failed");
|
||||||
|
http.end();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
size_t total = 0;
|
||||||
|
while (stream->connected() || stream->available()) {
|
||||||
|
int avail = stream->available();
|
||||||
|
if (avail <= 0) { delay(1); continue; }
|
||||||
|
if (total + avail >= capacity) {
|
||||||
|
capacity = min(capacity * 2, maxSize);
|
||||||
|
char* newBuf = (char*)ps_realloc(buf, capacity);
|
||||||
|
if (!newBuf) { free(buf); http.end(); return nullptr; }
|
||||||
|
buf = newBuf;
|
||||||
|
}
|
||||||
|
int read = stream->readBytes(buf + total, avail);
|
||||||
|
total += read;
|
||||||
|
if (total >= maxSize) break;
|
||||||
|
}
|
||||||
|
buf[total] = '\0';
|
||||||
|
*outLen = total;
|
||||||
|
http.end();
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known content length — single PSRAM allocation
|
||||||
|
char* buf = (char*)ps_malloc(contentLength + 1);
|
||||||
|
if (!buf) {
|
||||||
|
Serial.printf("[immich] PSRAM alloc failed for %d bytes\n", contentLength);
|
||||||
|
http.end();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t bytesRead = 0;
|
||||||
|
while (bytesRead < (size_t)contentLength && (stream->connected() || stream->available())) {
|
||||||
|
int avail = stream->available();
|
||||||
|
if (avail <= 0) { delay(1); continue; }
|
||||||
|
int read = stream->readBytes(buf + bytesRead, min(avail, (int)(contentLength - bytesRead)));
|
||||||
|
bytesRead += read;
|
||||||
|
}
|
||||||
|
buf[bytesRead] = '\0';
|
||||||
|
*outLen = bytesRead;
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ImmichClient::httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize) {
|
||||||
|
WiFiClientSecure client;
|
||||||
|
client.setInsecure();
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(client, url);
|
||||||
|
http.addHeader("x-api-key", _apiKey);
|
||||||
|
http.setTimeout(60000);
|
||||||
|
|
||||||
|
int code = http.GET();
|
||||||
|
if (code != HTTP_CODE_OK) {
|
||||||
|
Serial.printf("[immich] Binary GET failed: %d\n", code);
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int contentLength = http.getSize();
|
||||||
|
if (contentLength <= 0) {
|
||||||
|
Serial.println("[immich] Unknown content length");
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate in PSRAM
|
||||||
|
*outBuffer = (uint8_t*)ps_malloc(contentLength);
|
||||||
|
if (*outBuffer == nullptr) {
|
||||||
|
Serial.printf("[immich] Failed to allocate %d bytes in PSRAM\n", contentLength);
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFiClient* stream = http.getStreamPtr();
|
||||||
|
size_t bytesRead = 0;
|
||||||
|
while (bytesRead < (size_t)contentLength) {
|
||||||
|
size_t available = stream->available();
|
||||||
|
if (available > 0) {
|
||||||
|
size_t toRead = min(available, (size_t)(contentLength - bytesRead));
|
||||||
|
size_t read = stream->readBytes(*outBuffer + bytesRead, toRead);
|
||||||
|
bytesRead += read;
|
||||||
|
} else {
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
if (!http.connected() && bytesRead < (size_t)contentLength) {
|
||||||
|
Serial.println("[immich] Connection lost during download");
|
||||||
|
free(*outBuffer);
|
||||||
|
*outBuffer = nullptr;
|
||||||
|
http.end();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*outSize = bytesRead;
|
||||||
|
http.end();
|
||||||
|
Serial.printf("[immich] Downloaded %d bytes\n", bytesRead);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
61
src/immich_client.h
Normal file
61
src/immich_client.h
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
struct AlbumInfo {
|
||||||
|
String id;
|
||||||
|
String title;
|
||||||
|
int assetCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AssetInfo {
|
||||||
|
String id;
|
||||||
|
String originalFileName;
|
||||||
|
String dateTime;
|
||||||
|
String city;
|
||||||
|
String camera;
|
||||||
|
bool isFavorite;
|
||||||
|
bool isPortrait;
|
||||||
|
std::vector<String> people;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RandomAsset {
|
||||||
|
String id;
|
||||||
|
String dateTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RandomFetchFilter {
|
||||||
|
std::vector<String> albumIds;
|
||||||
|
bool favoriteOnly = false;
|
||||||
|
String takenAfter; // ISO datetime, empty = no filter
|
||||||
|
String takenBefore; // ISO datetime, empty = no filter
|
||||||
|
};
|
||||||
|
|
||||||
|
class ImmichClient {
|
||||||
|
public:
|
||||||
|
void begin(const String& baseUrl, const String& apiKey);
|
||||||
|
std::vector<AlbumInfo> fetchAlbums();
|
||||||
|
std::vector<String> fetchAlbumAssetIds(const String& albumId);
|
||||||
|
std::vector<String> fetchRandomAssetIds(int count = 50,
|
||||||
|
const std::vector<String>& albumIds = {});
|
||||||
|
std::vector<RandomAsset> fetchRandomAssets(int count = 50,
|
||||||
|
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);
|
||||||
|
|
||||||
|
private:
|
||||||
|
String _baseUrl;
|
||||||
|
String _apiKey;
|
||||||
|
|
||||||
|
String buildUrl(const String& path);
|
||||||
|
String httpGet(const String& url);
|
||||||
|
String httpPost(const String& url, const String& body);
|
||||||
|
char* httpGetPsram(const String& url, size_t* outLen);
|
||||||
|
bool httpGetBinary(const String& url, uint8_t** outBuffer, size_t* outSize);
|
||||||
|
};
|
||||||
819
src/main.cpp
819
src/main.cpp
@@ -1,26 +1,825 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <M5Unified.h>
|
#include <M5Unified.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "settings.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
#include "power_manager.h"
|
||||||
|
#include "button_handler.h"
|
||||||
|
#include "immich_client.h"
|
||||||
|
#include "photo_queue.h"
|
||||||
|
#include "image_pipeline.h"
|
||||||
|
#include "display_manager.h"
|
||||||
|
#include "web_server.h"
|
||||||
|
#include "time_utils.h"
|
||||||
|
#include "wake_log.h"
|
||||||
|
|
||||||
|
// Global instances
|
||||||
|
SettingsManager settingsManager;
|
||||||
|
WiFiManager wifiManager;
|
||||||
|
PowerManager powerManager;
|
||||||
|
ButtonHandler buttonHandler;
|
||||||
|
ImmichClient immichClient;
|
||||||
|
PhotoQueue photoQueue;
|
||||||
|
ImagePipeline imagePipeline;
|
||||||
|
DisplayManager displayManager;
|
||||||
|
AppWebServer webServer;
|
||||||
|
|
||||||
|
// Pre-rendered photo ready for instant display on next slideshow tick
|
||||||
|
struct PendingPhoto {
|
||||||
|
ProcessedImage img;
|
||||||
|
AssetInfo info;
|
||||||
|
AssetInfo info2;
|
||||||
|
bool isPair;
|
||||||
|
bool ready;
|
||||||
|
uint32_t settingsHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
static uint32_t computeSettingsHash(const Settings& s) {
|
||||||
|
return (uint32_t)s.pipeline_mode
|
||||||
|
| ((uint32_t)s.dither_noise << 8)
|
||||||
|
| ((uint32_t)s.img_quality << 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared state
|
||||||
|
SemaphoreHandle_t stateMutex;
|
||||||
|
volatile bool slideshowPlaying = true;
|
||||||
|
volatile bool refreshRequested = false;
|
||||||
|
volatile bool randomRequested = false;
|
||||||
|
volatile unsigned long lastRefreshTime = 0;
|
||||||
|
PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
|
||||||
|
|
||||||
|
// Task handles
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
|
||||||
|
// Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer.
|
||||||
|
// Callers provide the image, metadata info, and current settings. Returns true if displayed.
|
||||||
|
static bool displayPhoto(ProcessedImage& img, const AssetInfo& info, const AssetInfo& info2,
|
||||||
|
bool isPair, const Settings& s) {
|
||||||
|
if (!img.valid) return false;
|
||||||
|
|
||||||
|
if (isPair) {
|
||||||
|
uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2;
|
||||||
|
uint16_t rightX = halfW + PORTRAIT_GAP_PX;
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadataInRegion(
|
||||||
|
info, s.meta_flags, s.meta_pos,
|
||||||
|
0, 0, halfW, DISPLAY_HEIGHT,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
displayManager.showMetadataInRegion(
|
||||||
|
info2, s.meta_flags, s.meta_pos,
|
||||||
|
rightX, 0, halfW, DISPLAY_HEIGHT,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.show_battery) {
|
||||||
|
uint8_t battPct = powerManager.getBatteryPercent();
|
||||||
|
displayManager.showBatteryIndicator(battPct, s.meta_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
displayManager.refresh();
|
||||||
|
imagePipeline.freeImage(img);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch, download, decode, and dither one photo (or portrait pair) from the queue.
|
||||||
|
// On success, fills out the PendingPhoto fields and returns true.
|
||||||
|
// On failure after MAX_RETRIES attempts, returns false with pending.ready untouched.
|
||||||
|
static bool prepareNextPhoto(PendingPhoto& out, const Settings& s, bool wantsRandom) {
|
||||||
|
static constexpr int MAX_RETRIES = 10;
|
||||||
|
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
|
String assetId = wantsRandom ? photoQueue.random() : photoQueue.next();
|
||||||
|
if (assetId.length() == 0) break;
|
||||||
|
|
||||||
|
Serial.printf("[prepare] Loading asset: %s (attempt %d)\n",
|
||||||
|
assetId.c_str(), attempt + 1);
|
||||||
|
|
||||||
|
AssetInfo info = immichClient.fetchAssetInfo(assetId);
|
||||||
|
|
||||||
|
uint8_t* jpegBuf = nullptr;
|
||||||
|
size_t jpegSize = 0;
|
||||||
|
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)
|
||||||
|
|| jpegBuf == nullptr) {
|
||||||
|
Serial.println("[prepare] Download failed, trying next");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[prepare] JPEG: %u KB, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(jpegSize / 1024),
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
|
||||||
|
ProcessedImage img = {nullptr, 0, 0, false};
|
||||||
|
AssetInfo info2 = {};
|
||||||
|
bool isPair = false;
|
||||||
|
|
||||||
|
if (info.isPortrait) {
|
||||||
|
Serial.println("[prepare] Portrait detected, searching for pair");
|
||||||
|
String pairId = photoQueue.findNextPortrait();
|
||||||
|
|
||||||
|
if (pairId.length() > 0) {
|
||||||
|
info2 = immichClient.fetchAssetInfo(pairId);
|
||||||
|
|
||||||
|
uint8_t* jpeg2Buf = nullptr;
|
||||||
|
size_t jpeg2Size = 0;
|
||||||
|
bool dl2 = immichClient.downloadAsset(
|
||||||
|
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
|
||||||
|
|
||||||
|
if (dl2 && jpeg2Buf != nullptr) {
|
||||||
|
Serial.printf("[prepare] Portrait pair: %u KB + %u KB\n",
|
||||||
|
(unsigned)(jpegSize / 1024),
|
||||||
|
(unsigned)(jpeg2Size / 1024));
|
||||||
|
img = imagePipeline.processPortraitPair(
|
||||||
|
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
|
||||||
|
free(jpeg2Buf);
|
||||||
|
isPair = img.valid;
|
||||||
|
|
||||||
|
if (!img.valid) {
|
||||||
|
Serial.println("[prepare] Pair processing failed, showing solo");
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.println("[prepare] Pair download failed, showing solo");
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(jpegBuf);
|
||||||
|
|
||||||
|
Serial.printf("[prepare] Post-process PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
|
||||||
|
if (img.valid) {
|
||||||
|
out.img = img;
|
||||||
|
out.info = info;
|
||||||
|
out.info2 = info2;
|
||||||
|
out.isPair = isPair;
|
||||||
|
out.settingsHash = computeSettingsHash(s);
|
||||||
|
out.ready = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[prepare] Processing failed, trying next");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard a pre-rendered pending photo, freeing its framebuffer.
|
||||||
|
static void discardPending(PendingPhoto& p) {
|
||||||
|
if (p.ready && p.img.valid) {
|
||||||
|
Serial.println("[prerender] Discarding stale pending image");
|
||||||
|
imagePipeline.freeImage(p.img);
|
||||||
|
}
|
||||||
|
p.ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void displayTask(void* param) {
|
||||||
|
Serial.println("[display_task] Started on Core 1");
|
||||||
|
|
||||||
|
// Initial sync
|
||||||
|
if (wifiManager.isConnected()) {
|
||||||
|
Settings s = settingsManager.get();
|
||||||
|
immichClient.begin(s.immich_url, s.immich_key);
|
||||||
|
photoQueue.begin(immichClient, settingsManager);
|
||||||
|
|
||||||
|
if (photoQueue.sync()) {
|
||||||
|
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
refreshRequested = true;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displayManager.showMessage("No Photos", "Select albums in web UI");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
unsigned long now = millis();
|
||||||
|
Settings s = settingsManager.get();
|
||||||
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
|
unsigned long intervalMs = s.interval_min * 60000UL;
|
||||||
|
|
||||||
|
bool shouldRefresh = false;
|
||||||
|
bool wantsRandom = false;
|
||||||
|
bool isManual = false;
|
||||||
|
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
if (refreshRequested) {
|
||||||
|
shouldRefresh = true;
|
||||||
|
isManual = true;
|
||||||
|
refreshRequested = false;
|
||||||
|
} else if (randomRequested) {
|
||||||
|
shouldRefresh = true;
|
||||||
|
wantsRandom = true;
|
||||||
|
isManual = true;
|
||||||
|
randomRequested = false;
|
||||||
|
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
|
||||||
|
shouldRefresh = true;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic re-sync (includes album change requests)
|
||||||
|
if (photoQueue.needsResync() && wifiManager.isConnected()) {
|
||||||
|
photoQueue.sync();
|
||||||
|
discardPending(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 1: Display ---
|
||||||
|
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
|
bool displayed = false;
|
||||||
|
uint32_t currentHash = computeSettingsHash(s);
|
||||||
|
|
||||||
|
if (!isManual && pending.ready && pending.settingsHash == currentHash) {
|
||||||
|
// Use pre-rendered image — instant display
|
||||||
|
Serial.println("[display_task] Using pre-rendered image");
|
||||||
|
displayed = displayPhoto(pending.img, pending.info, pending.info2,
|
||||||
|
pending.isPair, s);
|
||||||
|
pending.ready = false;
|
||||||
|
} else {
|
||||||
|
// Manual request, stale settings, or no pre-render available — inline pipeline
|
||||||
|
if (pending.ready) {
|
||||||
|
if (isManual) {
|
||||||
|
Serial.println("[display_task] Manual request — discarding pre-render");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n",
|
||||||
|
pending.settingsHash, currentHash);
|
||||||
|
}
|
||||||
|
discardPending(pending);
|
||||||
|
} else {
|
||||||
|
Serial.println("[display_task] No pre-render available — inline fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
|
||||||
|
PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
|
||||||
|
if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) {
|
||||||
|
displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info,
|
||||||
|
inlinePhoto.info2, inlinePhoto.isPair, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log display result (normal mode)
|
||||||
|
if (displayed) {
|
||||||
|
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), 0,
|
||||||
|
photoQueue.current(), "", "n/a",
|
||||||
|
WakeLogResult::Ok, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
lastRefreshTime = millis();
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 2: Pre-render next photo ---
|
||||||
|
if (displayed && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
|
bool playing = false;
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
playing = slideshowPlaying;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
Serial.println("[prerender] Starting pre-render of next photo");
|
||||||
|
Settings preS = settingsManager.get();
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(preS.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(preS.dither_noise != 0);
|
||||||
|
|
||||||
|
if (prepareNextPhoto(pending, preS, false)) {
|
||||||
|
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
} else {
|
||||||
|
Serial.println("[prerender] Pre-render failed — will fall back to inline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 2 (idle): Pre-render if we don't have one yet ---
|
||||||
|
if (!pending.ready && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
|
bool playing = false;
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
playing = slideshowPlaying;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
Serial.println("[prerender] No pending image — pre-rendering now");
|
||||||
|
Settings preS = settingsManager.get();
|
||||||
|
|
||||||
|
if (prepareNextPhoto(pending, preS, false)) {
|
||||||
|
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
} else {
|
||||||
|
Serial.println("[prerender] Pre-render failed — will retry next loop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield — check every second
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void webActionCallback(const String& action) {
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
if (action == "next") {
|
||||||
|
refreshRequested = true;
|
||||||
|
} else if (action == "random") {
|
||||||
|
randomRequested = true;
|
||||||
|
} else if (action == "pause") {
|
||||||
|
slideshowPlaying = false;
|
||||||
|
} else if (action == "play") {
|
||||||
|
slideshowPlaying = true;
|
||||||
|
} else if (action == "albums_changed") {
|
||||||
|
photoQueue.requestSync();
|
||||||
|
refreshRequested = true;
|
||||||
|
}
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a RandomFetchFilter based on the current cycle mode and probability.
|
||||||
|
// Returns a filter with appropriate date/favorite constraints.
|
||||||
|
static RandomFetchFilter buildTimerFilter(const Settings& s,
|
||||||
|
const std::vector<String>& albumIds) {
|
||||||
|
RandomFetchFilter filter;
|
||||||
|
filter.albumIds = albumIds;
|
||||||
|
|
||||||
|
// If system clock isn't valid, weighted/chrono modes can't work — use plain random
|
||||||
|
if (!hasValidTime()) {
|
||||||
|
Serial.println("[timer_wake] No valid time — using unfiltered random");
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probability roll: 0-99
|
||||||
|
int roll = random(0, 100);
|
||||||
|
|
||||||
|
switch (s.cycle_mode) {
|
||||||
|
case CycleMode::Random:
|
||||||
|
// No extra filters
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CycleMode::Chronological:
|
||||||
|
if (s.timer_last_date.length() > 0) {
|
||||||
|
filter.takenAfter = s.timer_last_date;
|
||||||
|
} else {
|
||||||
|
filter.takenAfter = "1970-01-01T00:00:00.000Z";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CycleMode::ReverseChronological:
|
||||||
|
if (s.timer_last_date.length() > 0) {
|
||||||
|
filter.takenBefore = s.timer_last_date;
|
||||||
|
} else {
|
||||||
|
filter.takenBefore = isoNow();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CycleMode::FavoritesWeighted:
|
||||||
|
// 66% chance: favorites only, 33% chance: no filter
|
||||||
|
if (roll < 66) {
|
||||||
|
filter.favoriteOnly = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CycleMode::WeightedChronological:
|
||||||
|
// 66% chance: recent photos (last 30 days), 33% chance: no filter
|
||||||
|
if (roll < 66) {
|
||||||
|
filter.takenAfter = isoNowMinus30d();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CycleMode::WeightedReverseChronological:
|
||||||
|
// 66% chance: older photos (before 30 days ago), 33% chance: no filter
|
||||||
|
if (roll < 66) {
|
||||||
|
filter.takenBefore = isoNowMinus30d();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
CycleMode unreachable = s.cycle_mode;
|
||||||
|
(void)unreachable;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a human-readable filter name from the cycle mode and filter state.
|
||||||
|
static String filterName(CycleMode mode, bool relaxed) {
|
||||||
|
if (relaxed) return "relaxed";
|
||||||
|
switch (mode) {
|
||||||
|
case CycleMode::Random: return "random";
|
||||||
|
case CycleMode::Chronological: return "chrono";
|
||||||
|
case CycleMode::ReverseChronological: return "rev_chrono";
|
||||||
|
case CycleMode::FavoritesWeighted: return "fav";
|
||||||
|
case CycleMode::WeightedChronological: return "recent";
|
||||||
|
case CycleMode::WeightedReverseChronological: return "oldest";
|
||||||
|
default: {
|
||||||
|
CycleMode unreachable = mode;
|
||||||
|
(void)unreachable;
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute a simple hash of settings that affect the batch cache validity.
|
||||||
|
static uint32_t timerCacheHash(const Settings& s) {
|
||||||
|
uint32_t h = 5381;
|
||||||
|
h = h * 33 + static_cast<uint8_t>(s.cycle_mode);
|
||||||
|
h = h * 33 + s.timer_batch_size;
|
||||||
|
for (size_t i = 0; i < s.albums_json.length(); i++) {
|
||||||
|
h = h * 33 + (uint8_t)s.albums_json[i];
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse NVS cache string: "<hash>:<id1>,<id2>,..."
|
||||||
|
// Returns empty vector if hash doesn't match or string is empty/invalid.
|
||||||
|
static std::vector<String> parseCacheIds(const String& raw, uint32_t expectedHash) {
|
||||||
|
std::vector<String> ids;
|
||||||
|
int colonIdx = raw.indexOf(':');
|
||||||
|
if (colonIdx < 1) return ids;
|
||||||
|
|
||||||
|
uint32_t storedHash = (uint32_t)strtoul(raw.substring(0, colonIdx).c_str(), nullptr, 10);
|
||||||
|
if (storedHash != expectedHash) {
|
||||||
|
Serial.println("[timer_wake] Cache hash mismatch — invalidating");
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
String idsPart = raw.substring(colonIdx + 1);
|
||||||
|
if (idsPart.length() == 0) return ids;
|
||||||
|
|
||||||
|
int start = 0;
|
||||||
|
while (start < (int)idsPart.length()) {
|
||||||
|
int commaIdx = idsPart.indexOf(',', start);
|
||||||
|
if (commaIdx < 0) commaIdx = idsPart.length();
|
||||||
|
String id = idsPart.substring(start, commaIdx);
|
||||||
|
if (id.length() > 0) ids.push_back(id);
|
||||||
|
start = commaIdx + 1;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize cache IDs back to NVS format: "<hash>:<id1>,<id2>,..."
|
||||||
|
static String buildCacheString(uint32_t hash, const std::vector<String>& ids) {
|
||||||
|
String result = String(hash) + ":";
|
||||||
|
for (size_t i = 0; i < ids.size(); i++) {
|
||||||
|
if (i > 0) result += ',';
|
||||||
|
result += ids[i];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal boot path for RTC timer wake: fetch one photo, display, sleep again
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Immich client and apply pipeline/display modes
|
||||||
|
immichClient.begin(s.immich_url, s.immich_key);
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
|
|
||||||
|
// Parse selected album IDs from settings
|
||||||
|
std::vector<String> albumIds;
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, s.albums_json);
|
||||||
|
if (!err) {
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonVariant v : arr) {
|
||||||
|
albumIds.push_back(v.as<String>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
|
||||||
|
// If cache is empty, refill it with a batch fetch
|
||||||
|
if (cachedIds.empty()) {
|
||||||
|
Serial.printf("[timer_wake] Cache empty — fetching batch of %d\n", s.timer_batch_size);
|
||||||
|
|
||||||
|
static constexpr int BATCH_FETCH_RETRIES = 3;
|
||||||
|
for (int attempt = 0; attempt < BATCH_FETCH_RETRIES && cachedIds.empty(); attempt++) {
|
||||||
|
RandomFetchFilter filter;
|
||||||
|
if (filterRelaxed) {
|
||||||
|
filter.albumIds = albumIds;
|
||||||
|
} else {
|
||||||
|
filter = buildTimerFilter(s, albumIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedIds = immichClient.fetchFilteredBatch(filter, s.timer_batch_size);
|
||||||
|
|
||||||
|
if (cachedIds.empty()) {
|
||||||
|
if (!filterRelaxed && (filter.favoriteOnly ||
|
||||||
|
filter.takenAfter.length() > 0 || filter.takenBefore.length() > 0)) {
|
||||||
|
Serial.println("[timer_wake] Filtered batch empty — relaxing filter");
|
||||||
|
filterRelaxed = true;
|
||||||
|
|
||||||
|
if (s.cycle_mode == CycleMode::Chronological ||
|
||||||
|
s.cycle_mode == CycleMode::ReverseChronological) {
|
||||||
|
settingsManager.saveField("timer_date", "");
|
||||||
|
s.timer_last_date = "";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Serial.printf("[timer_wake] Batch fetch failed (attempt %d)\n", attempt + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedIds.empty()) {
|
||||||
|
// All retries exhausted — log failure and sleep
|
||||||
|
Serial.println("[timer_wake] Batch fetch failed — no assets available");
|
||||||
|
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs,
|
||||||
|
"", "", filterName(s.cycle_mode, filterRelaxed),
|
||||||
|
WakeLogResult::FailFetch, true);
|
||||||
|
powerManager.enterDeepSleep(s.interval_min);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[timer_wake] Batch fetched %d IDs\n", (int)cachedIds.size());
|
||||||
|
} else {
|
||||||
|
Serial.printf("[timer_wake] Cache hit — %d IDs remaining\n", (int)cachedIds.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop the first ID from cache
|
||||||
|
String assetId = cachedIds[0];
|
||||||
|
cachedIds.erase(cachedIds.begin());
|
||||||
|
|
||||||
|
// Save updated cache (remaining IDs) back to NVS
|
||||||
|
if (cachedIds.empty()) {
|
||||||
|
settingsManager.saveField("timer_ids", "");
|
||||||
|
} else {
|
||||||
|
settingsManager.saveField("timer_ids", buildCacheString(cacheHash, cachedIds).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Download and display ---
|
||||||
|
static constexpr int DISPLAY_RETRIES = 3;
|
||||||
|
bool displayed = false;
|
||||||
|
String lastPhotoDate = "";
|
||||||
|
WakeLogResult lastResult = WakeLogResult::FailDownload;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < DISPLAY_RETRIES && !displayed; attempt++) {
|
||||||
|
AssetInfo info = immichClient.fetchAssetInfo(assetId);
|
||||||
|
lastPhotoDate = info.dateTime;
|
||||||
|
|
||||||
|
uint8_t* jpegBuf = nullptr;
|
||||||
|
size_t jpegSize = 0;
|
||||||
|
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)) {
|
||||||
|
Serial.printf("[timer_wake] Download failed (attempt %d)\n", attempt + 1);
|
||||||
|
lastResult = WakeLogResult::FailDownload;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
free(jpegBuf);
|
||||||
|
|
||||||
|
if (img.valid) {
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
}
|
||||||
|
if (s.show_battery) {
|
||||||
|
uint8_t battPct = powerManager.getBatteryPercent();
|
||||||
|
displayManager.showBatteryIndicator(battPct, s.meta_pos);
|
||||||
|
}
|
||||||
|
displayManager.showSleepIndicator(s.meta_pos);
|
||||||
|
displayManager.refresh();
|
||||||
|
imagePipeline.freeImage(img);
|
||||||
|
displayed = true;
|
||||||
|
lastResult = WakeLogResult::Ok;
|
||||||
|
|
||||||
|
// Update chronological cursor with this photo's date
|
||||||
|
if (info.dateTime.length() > 0) {
|
||||||
|
settingsManager.saveField("timer_date", info.dateTime.c_str());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.println("[timer_wake] Processing failed, retrying");
|
||||||
|
lastResult = WakeLogResult::FailProcess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log this wake cycle
|
||||||
|
String logFilter = cacheWasEmpty
|
||||||
|
? filterName(s.cycle_mode, filterRelaxed)
|
||||||
|
: String("cached");
|
||||||
|
wakeLogAppend(powerManager.getBatteryPercent(), WiFi.RSSI(), wifiMs,
|
||||||
|
assetId, lastPhotoDate, logFilter, lastResult, true);
|
||||||
|
|
||||||
|
if (!displayed) {
|
||||||
|
Serial.println("[timer_wake] Failed to display photo");
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[timer_wake] Cycle complete — sleeping %u min\n", s.interval_min);
|
||||||
|
powerManager.enterDeepSleep(s.interval_min);
|
||||||
|
}
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
auto cfg = M5.config();
|
auto cfg = M5.config();
|
||||||
|
cfg.clear_display = false; // Don't clear/refresh e-ink on init
|
||||||
M5.begin(cfg);
|
M5.begin(cfg);
|
||||||
|
|
||||||
|
// Prevent any automatic e-ink refreshes until we explicitly call display()
|
||||||
|
M5.Display.setAutoDisplay(false);
|
||||||
|
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
Serial.println("[main] Immich Frame booting...");
|
Serial.println("[main] Immich Frame v1.0 booting...");
|
||||||
|
Serial.printf("[main] PSRAM: %u KB free / %u KB total\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024),
|
||||||
|
(unsigned)(heap_caps_get_total_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
Serial.printf("[main] Internal RAM: %u KB free\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024));
|
||||||
|
|
||||||
// Disable audio permanently
|
// Seed system clock from hardware RTC (RX8130CE) for immediate time availability
|
||||||
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
|
{
|
||||||
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
|
auto rtcDt = M5.Rtc.getDateTime();
|
||||||
pinMode(PIN_SPK_EN, OUTPUT);
|
struct tm t = {};
|
||||||
digitalWrite(PIN_SPK_EN, LOW);
|
t.tm_year = rtcDt.date.year - 1900;
|
||||||
|
t.tm_mon = rtcDt.date.month - 1;
|
||||||
|
t.tm_mday = rtcDt.date.date;
|
||||||
|
t.tm_hour = rtcDt.time.hours;
|
||||||
|
t.tm_min = rtcDt.time.minutes;
|
||||||
|
t.tm_sec = rtcDt.time.seconds;
|
||||||
|
time_t rtcTime = mktime(&t);
|
||||||
|
if (rtcTime > 1600000000) { // Plausible (post-2020)
|
||||||
|
struct timeval tv = { .tv_sec = rtcTime, .tv_usec = 0 };
|
||||||
|
settimeofday(&tv, nullptr);
|
||||||
|
Serial.printf("[main] System clock seeded from RTC: %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||||
|
rtcDt.date.year, rtcDt.date.month, rtcDt.date.date,
|
||||||
|
rtcDt.time.hours, rtcDt.time.minutes, rtcDt.time.seconds);
|
||||||
|
} else {
|
||||||
|
Serial.println("[main] RTC has no valid time — will sync from NTP after WiFi");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Serial.printf("[main] Free heap: %d, PSRAM: %d\n",
|
// Initialize core subsystems needed for both wake paths
|
||||||
ESP.getFreeHeap(), ESP.getFreePsram());
|
powerManager.begin();
|
||||||
Serial.println("[main] Boot complete (skeleton)");
|
settingsManager.begin();
|
||||||
|
displayManager.begin(powerManager);
|
||||||
|
|
||||||
|
// Check if we woke from a timed deep sleep (persisted flag in NVS)
|
||||||
|
bool timerWoke = PowerManager::wasTimedSleepWake();
|
||||||
|
|
||||||
|
if (timerWoke) {
|
||||||
|
Serial.println("[main] Woke from timed deep sleep");
|
||||||
|
|
||||||
|
// Escape hatch: hold play/pause button during boot to stay awake
|
||||||
|
pinMode(PIN_BTN_DOWN, INPUT_PULLUP);
|
||||||
|
delay(50);
|
||||||
|
bool pauseHeld = (digitalRead(PIN_BTN_DOWN) == LOW);
|
||||||
|
|
||||||
|
if (!pauseHeld) {
|
||||||
|
// Minimal cycle: show photo and go back to sleep
|
||||||
|
timerWakeCycle();
|
||||||
|
// timerWakeCycle() calls enterDeepSleep — should not return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape hatch triggered — clear the flag and enter full interactive mode
|
||||||
|
PowerManager::clearTimedSleepFlag();
|
||||||
|
Serial.println("[main] Play/pause held — entering full interactive mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full interactive boot path
|
||||||
|
// Create state mutex
|
||||||
|
stateMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
// Initialize remaining subsystems
|
||||||
|
wifiManager.begin(settingsManager);
|
||||||
|
buttonHandler.begin(powerManager, settingsManager);
|
||||||
|
|
||||||
|
if (!wifiManager.isAPMode()) {
|
||||||
|
// Station mode — set up Immich and web server
|
||||||
|
Settings s = settingsManager.get();
|
||||||
|
immichClient.begin(s.immich_url, s.immich_key);
|
||||||
|
photoQueue.begin(immichClient, settingsManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start web server (works in both AP and station modes)
|
||||||
|
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();
|
||||||
|
} else {
|
||||||
|
// Skip "Loading photos" screen — the display task will show the first photo
|
||||||
|
// directly, avoiding a redundant 17s e-ink refresh cycle on boot.
|
||||||
|
|
||||||
|
// Create display task on Core 1
|
||||||
|
xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1,
|
||||||
|
&displayTaskHandle, 1);
|
||||||
|
|
||||||
|
// Enable light sleep only in station mode (not supported in AP mode)
|
||||||
|
powerManager.enableLightSleep();
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[main] Boot complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
M5.update();
|
M5.update();
|
||||||
delay(1000);
|
powerManager.updateBatteryLED();
|
||||||
|
|
||||||
|
// Handle button events
|
||||||
|
ButtonEvent event = buttonHandler.poll();
|
||||||
|
switch (event) {
|
||||||
|
case ButtonEvent::NextPhoto:
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||||
|
refreshRequested = true;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ButtonEvent::RandomPhoto:
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||||
|
randomRequested = true;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ButtonEvent::PlayPause:
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(50)) == pdTRUE) {
|
||||||
|
slideshowPlaying = !slideshowPlaying;
|
||||||
|
Serial.printf("[main] Slideshow: %s\n", slideshowPlaying ? "playing" : "paused");
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ButtonEvent::DeepSleep: {
|
||||||
|
Serial.println("[main] Entering timed deep sleep slideshow...");
|
||||||
|
Settings sleepS = settingsManager.get();
|
||||||
|
displayManager.showSleepIndicator(sleepS.meta_pos);
|
||||||
|
if (sleepS.show_battery) {
|
||||||
|
uint8_t battPct = powerManager.getBatteryPercent();
|
||||||
|
displayManager.showBatteryIndicator(battPct, sleepS.meta_pos);
|
||||||
|
}
|
||||||
|
displayManager.refresh();
|
||||||
|
powerManager.enterDeepSleep(sleepS.interval_min);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ButtonEvent::FactoryReset:
|
||||||
|
break;
|
||||||
|
case ButtonEvent::None:
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
ButtonEvent unreachable = event;
|
||||||
|
(void)unreachable;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(10);
|
||||||
}
|
}
|
||||||
|
|||||||
388
src/photo_queue.cpp
Normal file
388
src/photo_queue.cpp
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
#include "photo_queue.h"
|
||||||
|
#include "immich_client.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <algorithm>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
void PhotoQueue::begin(ImmichClient& client, SettingsManager& settings) {
|
||||||
|
_client = &client;
|
||||||
|
_settings = &settings;
|
||||||
|
|
||||||
|
Settings s = _settings->get();
|
||||||
|
_cursor = s.queue_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PhotoQueue::sync() {
|
||||||
|
if (_client == nullptr || _settings == nullptr) return false;
|
||||||
|
|
||||||
|
_lastSyncAttempt = millis();
|
||||||
|
_syncRequested = false;
|
||||||
|
|
||||||
|
Settings s = _settings->get();
|
||||||
|
auto albumIds = getSelectedAlbumIds();
|
||||||
|
|
||||||
|
bool success;
|
||||||
|
if (!albumIds.empty()) {
|
||||||
|
success = syncFromAlbums(albumIds, s);
|
||||||
|
} else {
|
||||||
|
success = syncFromAllPhotos(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
_syncRetryDelay = min(_syncRetryDelay * 2, SYNC_RETRY_MAX);
|
||||||
|
Serial.printf("[queue] Sync failed, retry in %lus\n", _syncRetryDelay / 1000);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursor = 0;
|
||||||
|
_lastSyncTime = millis();
|
||||||
|
_syncRetryDelay = SYNC_RETRY_MIN;
|
||||||
|
Serial.printf("[queue] Synced: %d assets, album_mode=%d, cursor at %d\n",
|
||||||
|
_queue.size(), _albumMode, _cursor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PhotoQueue::syncFromAlbums(const std::vector<String>& albumIds, const Settings& s) {
|
||||||
|
Serial.printf("[queue] Syncing from %d selected album(s)\n", albumIds.size());
|
||||||
|
|
||||||
|
// Get current album counts for 24h resync change detection
|
||||||
|
auto albums = _client->fetchAlbums();
|
||||||
|
std::map<String, int> albumCounts;
|
||||||
|
for (auto& selId : albumIds) {
|
||||||
|
for (auto& album : albums) {
|
||||||
|
if (album.id == selId) {
|
||||||
|
albumCounts[selId] = album.assetCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use POST /api/search/random with albumIds filter (Immich v3 compatible).
|
||||||
|
// The server returns up to `size` random assets from the specified albums.
|
||||||
|
// For small albums (< TARGET_QUEUE_SIZE), all assets are returned.
|
||||||
|
auto batch = _client->fetchRandomAssets(
|
||||||
|
static_cast<int>(TARGET_QUEUE_SIZE), albumIds);
|
||||||
|
|
||||||
|
if (batch.empty()) {
|
||||||
|
Serial.println("[queue] No assets found in selected albums");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> newIds;
|
||||||
|
std::vector<String> newDates;
|
||||||
|
for (auto& asset : batch) {
|
||||||
|
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
|
||||||
|
newIds.push_back(asset.id);
|
||||||
|
newDates.push_back(asset.dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[queue] Album sync: %d assets from server\n", newIds.size());
|
||||||
|
|
||||||
|
_queue = newIds;
|
||||||
|
_albumMode = true;
|
||||||
|
_lastAlbumCounts = albumCounts;
|
||||||
|
|
||||||
|
applyCycleMode(s, newDates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PhotoQueue::syncFromAllPhotos(const Settings& s) {
|
||||||
|
Serial.printf("[queue] Syncing from all photos (global random)\n");
|
||||||
|
|
||||||
|
std::vector<String> newIds;
|
||||||
|
std::vector<String> newDates;
|
||||||
|
int rounds = 0;
|
||||||
|
|
||||||
|
Serial.printf("[queue] Fetching until %d usable assets (excluding %d shown)\n",
|
||||||
|
TARGET_QUEUE_SIZE, _shown.size());
|
||||||
|
|
||||||
|
while (newIds.size() < TARGET_QUEUE_SIZE && rounds < MAX_FETCH_ROUNDS) {
|
||||||
|
auto batch = _client->fetchRandomAssets(50);
|
||||||
|
if (batch.empty()) {
|
||||||
|
Serial.printf("[queue] Fetch round %d returned empty\n", rounds + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& asset : batch) {
|
||||||
|
if (newIds.size() >= TARGET_QUEUE_SIZE) break;
|
||||||
|
if (std::find(newIds.begin(), newIds.end(), asset.id) != newIds.end()) continue;
|
||||||
|
if (std::find(_shown.begin(), _shown.end(), asset.id) != _shown.end()) continue;
|
||||||
|
newIds.push_back(asset.id);
|
||||||
|
newDates.push_back(asset.dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
rounds++;
|
||||||
|
Serial.printf("[queue] Round %d: have %d/%d usable assets\n",
|
||||||
|
rounds, newIds.size(), TARGET_QUEUE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIds.empty()) return false;
|
||||||
|
|
||||||
|
_queue = newIds;
|
||||||
|
_albumMode = false;
|
||||||
|
_lastAlbumCounts.clear();
|
||||||
|
|
||||||
|
applyCycleMode(s, newDates);
|
||||||
|
Serial.printf("[queue] All-photos sync: %d assets in %d rounds\n",
|
||||||
|
_queue.size(), rounds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::applyCycleMode(const Settings& s, const std::vector<String>& dates) {
|
||||||
|
switch (s.cycle_mode) {
|
||||||
|
case CycleMode::Random:
|
||||||
|
applyFavoritesWeighting();
|
||||||
|
shuffle();
|
||||||
|
break;
|
||||||
|
case CycleMode::Chronological:
|
||||||
|
sortChronological(dates, false);
|
||||||
|
break;
|
||||||
|
case CycleMode::ReverseChronological:
|
||||||
|
sortChronological(dates, true);
|
||||||
|
break;
|
||||||
|
case CycleMode::FavoritesWeighted:
|
||||||
|
applyFavoritesWeighting();
|
||||||
|
shuffle();
|
||||||
|
break;
|
||||||
|
case CycleMode::WeightedChronological:
|
||||||
|
applyRecencyWeighting(dates, true);
|
||||||
|
shuffle();
|
||||||
|
break;
|
||||||
|
case CycleMode::WeightedReverseChronological:
|
||||||
|
applyRecencyWeighting(dates, false);
|
||||||
|
shuffle();
|
||||||
|
break;
|
||||||
|
// No default — compiler warns on unhandled CycleMode via -Wswitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::requestSync() {
|
||||||
|
_syncRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String PhotoQueue::next() {
|
||||||
|
// Auto-refill when queue is exhausted
|
||||||
|
if (_queue.empty() || _cursor >= _queue.size()) {
|
||||||
|
if (_albumMode && !_queue.empty()) {
|
||||||
|
// Album mode: reshuffle existing queue instead of re-fetching
|
||||||
|
Serial.println("[queue] Album queue exhausted, reshuffling in place");
|
||||||
|
Settings s = _settings->get();
|
||||||
|
// Clear shown history since we're replaying the same set
|
||||||
|
_shown.clear();
|
||||||
|
applyCycleMode(s, {});
|
||||||
|
_cursor = 0;
|
||||||
|
} else {
|
||||||
|
// All-photos mode: fetch a new batch
|
||||||
|
Serial.println("[queue] Queue exhausted, fetching new batch");
|
||||||
|
sync();
|
||||||
|
_cursor = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_queue.empty()) return "";
|
||||||
|
|
||||||
|
String id = _queue[_cursor++];
|
||||||
|
|
||||||
|
// Track shown IDs for dedup on next refill
|
||||||
|
_shown.push_back(id);
|
||||||
|
if (_shown.size() > MAX_SHOWN_HISTORY) {
|
||||||
|
_shown.erase(_shown.begin());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist cursor
|
||||||
|
_settings->saveField("queue_cursor", static_cast<uint32_t>(_cursor));
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String PhotoQueue::random() {
|
||||||
|
if (_queue.empty()) return "";
|
||||||
|
size_t idx = ::random(0, _queue.size());
|
||||||
|
return _queue[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
String PhotoQueue::current() {
|
||||||
|
if (_queue.empty()) return "";
|
||||||
|
size_t idx = (_cursor > 0) ? _cursor - 1 : 0;
|
||||||
|
return _queue[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t PhotoQueue::size() {
|
||||||
|
return _queue.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PhotoQueue::needsResync() {
|
||||||
|
if (_syncRequested) return true;
|
||||||
|
|
||||||
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
// If never synced successfully, use retry backoff
|
||||||
|
if (_lastSyncTime == 0) {
|
||||||
|
if (_lastSyncAttempt == 0) return true;
|
||||||
|
return (now - _lastSyncAttempt) >= _syncRetryDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal resync interval
|
||||||
|
unsigned long elapsed = now - _lastSyncTime;
|
||||||
|
if (elapsed < (QUEUE_RESYNC_HOURS * 3600000UL)) return false;
|
||||||
|
|
||||||
|
// In album mode, check if album sizes actually changed before re-fetching
|
||||||
|
if (_albumMode && !_lastAlbumCounts.empty()) {
|
||||||
|
auto albums = _client->fetchAlbums();
|
||||||
|
auto selectedIds = getSelectedAlbumIds();
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
|
for (auto& selId : selectedIds) {
|
||||||
|
int currentCount = 0;
|
||||||
|
for (auto& album : albums) {
|
||||||
|
if (album.id == selId) {
|
||||||
|
currentCount = album.assetCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto it = _lastAlbumCounts.find(selId);
|
||||||
|
int lastCount = (it != _lastAlbumCounts.end()) ? it->second : -1;
|
||||||
|
if (currentCount != lastCount) {
|
||||||
|
Serial.printf("[queue] Album %s count changed: %d -> %d\n",
|
||||||
|
selId.c_str(), lastCount, currentCount);
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
Serial.println("[queue] Album counts unchanged, reshuffling instead of re-fetching");
|
||||||
|
Settings s = _settings->get();
|
||||||
|
_shown.clear();
|
||||||
|
applyCycleMode(s, {});
|
||||||
|
_cursor = 0;
|
||||||
|
_lastSyncTime = millis();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String PhotoQueue::findNextPortrait() {
|
||||||
|
if (_client == nullptr) return "";
|
||||||
|
|
||||||
|
size_t scanEnd = min(_cursor + PORTRAIT_LOOKAHEAD, _queue.size());
|
||||||
|
for (size_t i = _cursor; i < scanEnd; i++) {
|
||||||
|
AssetInfo info = _client->fetchAssetInfo(_queue[i]);
|
||||||
|
if (info.isPortrait) {
|
||||||
|
String id = _queue[i];
|
||||||
|
_queue.erase(_queue.begin() + i);
|
||||||
|
Serial.printf("[queue] Found portrait pair: %s (scanned %d ahead)\n",
|
||||||
|
id.c_str(), (int)(i - _cursor + 1));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Serial.println("[queue] No portrait pair found in lookahead");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::shuffle() {
|
||||||
|
for (size_t i = _queue.size() - 1; i > 0; i--) {
|
||||||
|
size_t j = ::random(0, i + 1);
|
||||||
|
std::swap(_queue[i], _queue[j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::sortChronological(const std::vector<String>& dates, bool reverse) {
|
||||||
|
if (dates.size() != _queue.size()) {
|
||||||
|
// Dates unavailable (e.g. reshuffle after exhaustion) — fall back to shuffle
|
||||||
|
if (dates.empty()) {
|
||||||
|
shuffle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Serial.println("[queue] sortChronological: date/queue size mismatch, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build index array sorted by date (ISO 8601 strings compare correctly)
|
||||||
|
std::vector<size_t> indices(dates.size());
|
||||||
|
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
|
||||||
|
|
||||||
|
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
|
||||||
|
return dates[a] < dates[b];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reverse) std::reverse(indices.begin(), indices.end());
|
||||||
|
|
||||||
|
std::vector<String> sorted;
|
||||||
|
sorted.reserve(_queue.size());
|
||||||
|
for (size_t idx : indices) {
|
||||||
|
sorted.push_back(_queue[idx]);
|
||||||
|
}
|
||||||
|
_queue = sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent) {
|
||||||
|
if (dates.size() != _queue.size() || _queue.size() < 3) {
|
||||||
|
// Dates unavailable — fall back to shuffle
|
||||||
|
if (dates.empty()) return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort queue by date (oldest first)
|
||||||
|
std::vector<size_t> indices(dates.size());
|
||||||
|
for (size_t i = 0; i < indices.size(); i++) indices[i] = i;
|
||||||
|
std::sort(indices.begin(), indices.end(), [&](size_t a, size_t b) {
|
||||||
|
return dates[a] < dates[b];
|
||||||
|
});
|
||||||
|
|
||||||
|
std::vector<String> sorted;
|
||||||
|
sorted.reserve(_queue.size());
|
||||||
|
for (size_t idx : indices) {
|
||||||
|
sorted.push_back(_queue[idx]);
|
||||||
|
}
|
||||||
|
_queue = sorted;
|
||||||
|
|
||||||
|
// Duplicate the preferred third (3x copies total)
|
||||||
|
size_t third = _queue.size() / 3;
|
||||||
|
size_t start = favorRecent ? _queue.size() - third : 0;
|
||||||
|
size_t end = favorRecent ? _queue.size() : third;
|
||||||
|
|
||||||
|
for (size_t i = start; i < end; i++) {
|
||||||
|
_queue.push_back(_queue[i]);
|
||||||
|
_queue.push_back(_queue[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[queue] Recency weighting: %s third duplicated (%d -> %d entries)\n",
|
||||||
|
favorRecent ? "recent" : "oldest", (int)sorted.size(), (int)_queue.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void PhotoQueue::applyFavoritesWeighting() {
|
||||||
|
if (_client == nullptr) return;
|
||||||
|
|
||||||
|
Settings s = _settings->get();
|
||||||
|
if (s.cycle_mode != CycleMode::FavoritesWeighted) return;
|
||||||
|
|
||||||
|
auto favorites = _client->fetchFavoriteAssetIds();
|
||||||
|
for (auto& fav : favorites) {
|
||||||
|
bool inQueue = false;
|
||||||
|
for (auto& id : _queue) {
|
||||||
|
if (id == fav) { inQueue = true; break; }
|
||||||
|
}
|
||||||
|
if (inQueue) {
|
||||||
|
_queue.push_back(fav);
|
||||||
|
_queue.push_back(fav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<String> PhotoQueue::getSelectedAlbumIds() {
|
||||||
|
std::vector<String> ids;
|
||||||
|
Settings s = _settings->get();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError err = deserializeJson(doc, s.albums_json);
|
||||||
|
if (err) return ids;
|
||||||
|
|
||||||
|
JsonArray arr = doc.as<JsonArray>();
|
||||||
|
for (JsonVariant v : arr) {
|
||||||
|
ids.push_back(v.as<String>());
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
50
src/photo_queue.h
Normal file
50
src/photo_queue.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <map>
|
||||||
|
#include <vector>
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
class ImmichClient;
|
||||||
|
|
||||||
|
class PhotoQueue {
|
||||||
|
public:
|
||||||
|
void begin(ImmichClient& client, SettingsManager& settings);
|
||||||
|
bool sync();
|
||||||
|
void requestSync();
|
||||||
|
String next();
|
||||||
|
String random();
|
||||||
|
String current();
|
||||||
|
size_t size();
|
||||||
|
bool needsResync();
|
||||||
|
String findNextPortrait();
|
||||||
|
|
||||||
|
private:
|
||||||
|
ImmichClient* _client = nullptr;
|
||||||
|
SettingsManager* _settings = nullptr;
|
||||||
|
|
||||||
|
std::vector<String> _queue;
|
||||||
|
std::vector<String> _shown; // Recently shown IDs for dedup on refill
|
||||||
|
size_t _cursor = 0;
|
||||||
|
unsigned long _lastSyncTime = 0;
|
||||||
|
unsigned long _lastSyncAttempt = 0;
|
||||||
|
unsigned long _syncRetryDelay = 10000;
|
||||||
|
volatile bool _syncRequested = false;
|
||||||
|
bool _albumMode = false;
|
||||||
|
std::map<String, int> _lastAlbumCounts;
|
||||||
|
|
||||||
|
static constexpr unsigned long SYNC_RETRY_MIN = 10000; // 10 seconds
|
||||||
|
static constexpr unsigned long SYNC_RETRY_MAX = 300000; // 5 minutes
|
||||||
|
static constexpr size_t TARGET_QUEUE_SIZE = 50;
|
||||||
|
static constexpr int MAX_FETCH_ROUNDS = 5;
|
||||||
|
static constexpr size_t MAX_SHOWN_HISTORY = 200;
|
||||||
|
|
||||||
|
bool syncFromAlbums(const std::vector<String>& albumIds, const Settings& s);
|
||||||
|
bool syncFromAllPhotos(const Settings& s);
|
||||||
|
void applyCycleMode(const Settings& s, const std::vector<String>& dates);
|
||||||
|
void shuffle();
|
||||||
|
void sortChronological(const std::vector<String>& dates, bool reverse);
|
||||||
|
void applyRecencyWeighting(const std::vector<String>& dates, bool favorRecent);
|
||||||
|
void applyFavoritesWeighting();
|
||||||
|
std::vector<String> getSelectedAlbumIds();
|
||||||
|
};
|
||||||
169
src/power_manager.cpp
Normal file
169
src/power_manager.cpp
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#include "power_manager.h"
|
||||||
|
#include <M5Unified.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
#include <esp_pm.h>
|
||||||
|
|
||||||
|
static const char* NVS_PM_NAMESPACE = "pm_state";
|
||||||
|
|
||||||
|
void PowerManager::begin() {
|
||||||
|
Serial.println("[power] Initializing power manager");
|
||||||
|
|
||||||
|
// Disable audio power rails permanently
|
||||||
|
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
|
||||||
|
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
|
||||||
|
pinMode(PIN_SPK_EN, OUTPUT);
|
||||||
|
digitalWrite(PIN_SPK_EN, LOW);
|
||||||
|
|
||||||
|
// Initialize RGB LEDs via M5Unified (handles NeoPixel on G21)
|
||||||
|
// M5Unified manages the LED strip internally
|
||||||
|
|
||||||
|
clearLEDs();
|
||||||
|
|
||||||
|
Serial.printf("[power] Battery: %d%%, Charging: %s\n",
|
||||||
|
getBatteryPercent(), isCharging() ? "yes" : "no");
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t PowerManager::getBatteryPercent() {
|
||||||
|
int32_t level = M5.Power.getBatteryLevel();
|
||||||
|
if (level < 0) level = 0;
|
||||||
|
if (level > 100) level = 100;
|
||||||
|
_lastBatteryPct = static_cast<uint8_t>(level);
|
||||||
|
return _lastBatteryPct;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PowerManager::isCharging() {
|
||||||
|
return M5.Power.isCharging();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::updateBatteryLED() {
|
||||||
|
unsigned long now = millis();
|
||||||
|
uint8_t pct = getBatteryPercent();
|
||||||
|
|
||||||
|
// Auto deep sleep at critical level
|
||||||
|
if (pct <= BATTERY_SHUTDOWN_PCT && !isCharging()) {
|
||||||
|
Serial.println("[power] Battery critical — entering deep sleep");
|
||||||
|
enterDeepSleep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine pulse interval based on battery level
|
||||||
|
unsigned long interval = 0;
|
||||||
|
uint8_t r = 0, g = 0, b = 0;
|
||||||
|
|
||||||
|
if (isCharging()) {
|
||||||
|
interval = BATTERY_WARN_INTERVAL_MS;
|
||||||
|
g = 255; // Green pulse
|
||||||
|
} else if (pct <= BATTERY_CRITICAL_PCT) {
|
||||||
|
interval = BATTERY_CRIT_INTERVAL_MS;
|
||||||
|
r = 255; // Red pulse
|
||||||
|
} else if (pct <= BATTERY_LOW_PCT) {
|
||||||
|
interval = BATTERY_WARN_INTERVAL_MS;
|
||||||
|
r = 255; g = 165; // Orange pulse
|
||||||
|
} else {
|
||||||
|
// Battery fine — no LED
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - _lastLEDPulse >= interval) {
|
||||||
|
_lastLEDPulse = now;
|
||||||
|
flashLED(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms) {
|
||||||
|
// Scale by brightness
|
||||||
|
float scale = _ledBrightness / 255.0f;
|
||||||
|
uint8_t sr = static_cast<uint8_t>(r * scale);
|
||||||
|
uint8_t sg = static_cast<uint8_t>(g * scale);
|
||||||
|
uint8_t sb = static_cast<uint8_t>(b * scale);
|
||||||
|
|
||||||
|
setLED(0, sr, sg, sb);
|
||||||
|
setLED(1, sr, sg, sb);
|
||||||
|
|
||||||
|
// Non-blocking: we'll clear on next update cycle
|
||||||
|
// For simplicity, use a short blocking delay for LED flash feedback
|
||||||
|
delay(duration_ms);
|
||||||
|
clearLEDs();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::enterDeepSleep(uint32_t sleepMinutes) {
|
||||||
|
if (sleepMinutes > 0) {
|
||||||
|
Serial.printf("[power] Entering timed deep sleep — wake in %u min\n", sleepMinutes);
|
||||||
|
} else {
|
||||||
|
Serial.println("[power] Entering indefinite deep sleep");
|
||||||
|
}
|
||||||
|
Serial.flush();
|
||||||
|
delay(100);
|
||||||
|
|
||||||
|
clearLEDs();
|
||||||
|
|
||||||
|
// Persist flag in NVS so we know on next boot this was a timed sleep
|
||||||
|
if (sleepMinutes > 0) {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(NVS_PM_NAMESPACE, false);
|
||||||
|
prefs.putUChar("timed_sleep", 1);
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t sleepUs = (sleepMinutes > 0)
|
||||||
|
? static_cast<uint64_t>(sleepMinutes) * 60ULL * 1000000ULL
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// M5.Power.deepSleep handles M5PM1 hardware properly
|
||||||
|
M5.Power.deepSleep(sleepUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PowerManager::wasTimedSleepWake() {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(NVS_PM_NAMESPACE, true);
|
||||||
|
uint8_t flag = prefs.getUChar("timed_sleep", 0);
|
||||||
|
prefs.end();
|
||||||
|
return flag == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::clearTimedSleepFlag() {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(NVS_PM_NAMESPACE, false);
|
||||||
|
prefs.putUChar("timed_sleep", 0);
|
||||||
|
prefs.end();
|
||||||
|
Serial.println("[power] Timed sleep flag cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::enableLightSleep() {
|
||||||
|
esp_pm_config_esp32s3_t pm_config;
|
||||||
|
pm_config.max_freq_mhz = 240;
|
||||||
|
pm_config.min_freq_mhz = 80;
|
||||||
|
pm_config.light_sleep_enable = true;
|
||||||
|
esp_err_t err = esp_pm_configure(&pm_config);
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
Serial.println("[power] Light sleep enabled (80-240MHz)");
|
||||||
|
} else {
|
||||||
|
Serial.printf("[power] Light sleep config failed: %d\n", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::disableEPDPower() {
|
||||||
|
// M5PM1 PYG0 controls e-paper power — managed via M5Unified Power API
|
||||||
|
// M5.Power controls the PM1 rails
|
||||||
|
Serial.println("[power] EPD power disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::enableEPDPower() {
|
||||||
|
Serial.println("[power] EPD power enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::setLED(uint8_t index, uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
// M5Unified provides LED control — exact API depends on board support
|
||||||
|
// Fallback: direct NeoPixel control if M5Unified doesn't cover it
|
||||||
|
(void)index;
|
||||||
|
(void)r;
|
||||||
|
(void)g;
|
||||||
|
(void)b;
|
||||||
|
// TODO: Implement via M5Unified LED API or direct NeoPixel library
|
||||||
|
// This will be filled in during hardware bring-up when we can test on device
|
||||||
|
}
|
||||||
|
|
||||||
|
void PowerManager::clearLEDs() {
|
||||||
|
setLED(0, 0, 0, 0);
|
||||||
|
setLED(1, 0, 0, 0);
|
||||||
|
}
|
||||||
29
src/power_manager.h
Normal file
29
src/power_manager.h
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
class PowerManager {
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
uint8_t getBatteryPercent();
|
||||||
|
bool isCharging();
|
||||||
|
void updateBatteryLED();
|
||||||
|
void flashLED(uint8_t r, uint8_t g, uint8_t b, uint16_t duration_ms = LED_PULSE_DURATION_MS);
|
||||||
|
void enterDeepSleep(uint32_t sleepMinutes = 0);
|
||||||
|
void enableLightSleep();
|
||||||
|
void disableEPDPower();
|
||||||
|
void enableEPDPower();
|
||||||
|
|
||||||
|
static bool wasTimedSleepWake();
|
||||||
|
static void clearTimedSleepFlag();
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint8_t _lastBatteryPct = 100;
|
||||||
|
unsigned long _lastLEDPulse = 0;
|
||||||
|
uint8_t _ledBrightness = DEFAULT_LED_BRIGHTNESS;
|
||||||
|
|
||||||
|
void setLED(uint8_t index, uint8_t r, uint8_t g, uint8_t b);
|
||||||
|
void clearLEDs();
|
||||||
|
};
|
||||||
111
src/settings.cpp
Normal file
111
src/settings.cpp
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#include "settings.h"
|
||||||
|
#include <Preferences.h>
|
||||||
|
|
||||||
|
static const char* NVS_NAMESPACE = "immich_frame";
|
||||||
|
static Preferences prefs;
|
||||||
|
|
||||||
|
void SettingsManager::begin() {
|
||||||
|
prefs.begin(NVS_NAMESPACE, false);
|
||||||
|
Serial.println("[settings] NVS initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings SettingsManager::get() {
|
||||||
|
Settings s;
|
||||||
|
s.wifi_ssid = readString("wifi_ssid");
|
||||||
|
s.wifi_pass = readString("wifi_pass");
|
||||||
|
s.immich_url = readString("immich_url", DEFAULT_IMMICH_URL);
|
||||||
|
s.immich_key = readString("immich_key");
|
||||||
|
s.interval_min = readU8("interval_m", DEFAULT_INTERVAL_MIN);
|
||||||
|
s.cycle_mode = static_cast<CycleMode>(readU8("cycle_mode", DEFAULT_CYCLE_MODE));
|
||||||
|
s.img_quality = static_cast<ImageQuality>(readU8("img_quality", DEFAULT_IMG_QUALITY));
|
||||||
|
s.meta_flags = readU8("meta_flags", DEFAULT_META_FLAGS);
|
||||||
|
s.meta_pos = static_cast<MetaPosition>(readU8("meta_pos", DEFAULT_META_POS));
|
||||||
|
s.date_fmt = readString("date_fmt", "%Y-%m-%d");
|
||||||
|
s.time_fmt = readString("time_fmt", "%H:%M:%S");
|
||||||
|
s.pipeline_mode = readU8("pipe_mode", DEFAULT_PIPELINE_MODE);
|
||||||
|
s.dither_noise = readU8("dith_noise", DEFAULT_DITHER_NOISE);
|
||||||
|
s.meta_translucent = readU8("meta_trans", 0);
|
||||||
|
s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS);
|
||||||
|
s.show_battery = readU8("show_batt", 1);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::save(const Settings& s) {
|
||||||
|
writeString("wifi_ssid", s.wifi_ssid);
|
||||||
|
writeString("wifi_pass", s.wifi_pass);
|
||||||
|
writeString("immich_url", s.immich_url);
|
||||||
|
writeString("immich_key", s.immich_key);
|
||||||
|
writeU8("interval_m", s.interval_min);
|
||||||
|
writeU8("cycle_mode", static_cast<uint8_t>(s.cycle_mode));
|
||||||
|
writeU8("img_quality", static_cast<uint8_t>(s.img_quality));
|
||||||
|
writeU8("meta_flags", s.meta_flags);
|
||||||
|
writeU8("meta_pos", static_cast<uint8_t>(s.meta_pos));
|
||||||
|
writeString("date_fmt", s.date_fmt);
|
||||||
|
writeString("time_fmt", s.time_fmt);
|
||||||
|
writeU8("pipe_mode", s.pipeline_mode);
|
||||||
|
writeU8("dith_noise", s.dither_noise);
|
||||||
|
writeU8("meta_trans", s.meta_translucent);
|
||||||
|
writeU8("led_bright", s.led_brightness);
|
||||||
|
writeU8("show_batt", s.show_battery);
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::saveField(const char* key, uint8_t value) {
|
||||||
|
writeU8(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::saveField(const char* key, uint32_t value) {
|
||||||
|
writeU32(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
delay(500);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsManager::isProvisioned() {
|
||||||
|
String ssid = readString("wifi_ssid");
|
||||||
|
return ssid.length() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
String SettingsManager::readString(const char* key, const char* defaultValue) {
|
||||||
|
return prefs.getString(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t SettingsManager::readU8(const char* key, uint8_t defaultValue) {
|
||||||
|
return prefs.getUChar(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t SettingsManager::readU32(const char* key, uint32_t defaultValue) {
|
||||||
|
return prefs.getUInt(key, defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::writeString(const char* key, const String& value) {
|
||||||
|
prefs.putString(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::writeU8(const char* key, uint8_t value) {
|
||||||
|
prefs.putUChar(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsManager::writeU32(const char* key, uint32_t value) {
|
||||||
|
prefs.putUInt(key, value);
|
||||||
|
}
|
||||||
96
src/settings.h
Normal file
96
src/settings.h
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
enum class CycleMode : uint8_t {
|
||||||
|
Random = 0,
|
||||||
|
Chronological = 1,
|
||||||
|
ReverseChronological = 2,
|
||||||
|
FavoritesWeighted = 3,
|
||||||
|
WeightedChronological = 4,
|
||||||
|
WeightedReverseChronological = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class ImageQuality : uint8_t {
|
||||||
|
Preview = 0,
|
||||||
|
Original = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class MetaPosition : uint8_t {
|
||||||
|
// Captioned mode (full-width bar)
|
||||||
|
CaptionBottom = 0,
|
||||||
|
CaptionTop = 1,
|
||||||
|
// Overlay mode (corner badge)
|
||||||
|
OverlayTopLeft = 2,
|
||||||
|
OverlayTopRight = 3,
|
||||||
|
OverlayBottomLeft = 4,
|
||||||
|
OverlayBottomRight = 5
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metadata flags bitmask
|
||||||
|
constexpr uint8_t META_DATE = 0x01;
|
||||||
|
constexpr uint8_t META_TIME = 0x20;
|
||||||
|
constexpr uint8_t META_LOCATION = 0x02;
|
||||||
|
constexpr uint8_t META_PEOPLE = 0x04;
|
||||||
|
constexpr uint8_t META_ALBUM = 0x08;
|
||||||
|
constexpr uint8_t META_CAMERA = 0x10;
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
// WiFi
|
||||||
|
String wifi_ssid;
|
||||||
|
String wifi_pass;
|
||||||
|
|
||||||
|
// Immich
|
||||||
|
String immich_url;
|
||||||
|
String immich_key;
|
||||||
|
|
||||||
|
// Slideshow
|
||||||
|
uint8_t interval_min;
|
||||||
|
CycleMode cycle_mode;
|
||||||
|
ImageQuality img_quality;
|
||||||
|
|
||||||
|
// Display
|
||||||
|
uint8_t meta_flags;
|
||||||
|
MetaPosition meta_pos;
|
||||||
|
String date_fmt; // strftime-style, default "%Y-%m-%d"
|
||||||
|
String time_fmt; // strftime-style, default "%H:%M:%S"
|
||||||
|
|
||||||
|
// Image pipeline
|
||||||
|
uint8_t pipeline_mode; // 0=dynamic, 1=balanced, 2=none
|
||||||
|
uint8_t dither_noise; // 1=blue noise dither smoothing ON, 0=OFF
|
||||||
|
uint8_t meta_translucent; // 1=checkerboard 50% opacity badge bg, 0=solid black
|
||||||
|
|
||||||
|
// Device
|
||||||
|
uint8_t led_brightness;
|
||||||
|
uint8_t show_battery; // 1 = show battery indicator on display
|
||||||
|
uint32_t queue_cursor;
|
||||||
|
String albums_json;
|
||||||
|
|
||||||
|
// Deep sleep chronological cursor (ISO datetime of last shown photo)
|
||||||
|
String timer_last_date;
|
||||||
|
|
||||||
|
// Deep sleep batch cache size
|
||||||
|
uint8_t timer_batch_size;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SettingsManager {
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
Settings get();
|
||||||
|
void save(const Settings& s);
|
||||||
|
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();
|
||||||
|
|
||||||
|
private:
|
||||||
|
String readString(const char* key, const char* defaultValue = "");
|
||||||
|
uint8_t readU8(const char* key, uint8_t defaultValue = 0);
|
||||||
|
uint32_t readU32(const char* key, uint32_t defaultValue = 0);
|
||||||
|
void writeString(const char* key, const String& value);
|
||||||
|
void writeU8(const char* key, uint8_t value);
|
||||||
|
void writeU32(const char* key, uint32_t value);
|
||||||
|
};
|
||||||
7988
src/stb_image.h
Normal file
7988
src/stb_image.h
Normal file
File diff suppressed because it is too large
Load Diff
15
src/stb_image_impl.cpp
Normal file
15
src/stb_image_impl.cpp
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#include <esp_heap_caps.h>
|
||||||
|
|
||||||
|
#define STB_IMAGE_IMPLEMENTATION
|
||||||
|
#define STBI_ONLY_JPEG
|
||||||
|
#define STBI_NO_STDIO
|
||||||
|
#define STBI_NO_HDR
|
||||||
|
#define STBI_NO_LINEAR
|
||||||
|
#define STBI_MALLOC(sz) heap_caps_malloc(sz, MALLOC_CAP_SPIRAM)
|
||||||
|
#define STBI_REALLOC(p,newsz) heap_caps_realloc(p, newsz, MALLOC_CAP_SPIRAM)
|
||||||
|
#define STBI_FREE(p) free(p)
|
||||||
|
|
||||||
|
#pragma GCC diagnostic push
|
||||||
|
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||||
|
#include "stb_image.h"
|
||||||
|
#pragma GCC diagnostic pop
|
||||||
33
src/time_utils.h
Normal file
33
src/time_utils.h
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
// Returns true if the system clock has been set to a plausible time (year >= 2020).
|
||||||
|
inline bool hasValidTime() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
return (t.tm_year + 1900) >= 2020;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the current UTC time as an ISO 8601 string (e.g., "2026-08-04T17:30:00.000Z").
|
||||||
|
inline String isoNow() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
char buf[32];
|
||||||
|
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns (now - 30 days) as an ISO 8601 UTC string.
|
||||||
|
inline String isoNowMinus30d() {
|
||||||
|
time_t now = time(nullptr);
|
||||||
|
now -= 30 * 24 * 3600; // Subtract 30 days in seconds
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
char buf[32];
|
||||||
|
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S.000Z", &t);
|
||||||
|
return String(buf);
|
||||||
|
}
|
||||||
183
src/wake_log.cpp
Normal file
183
src/wake_log.cpp
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
#include "wake_log.h"
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
#include "time_utils.h"
|
||||||
|
|
||||||
|
static const char* LOG_PATH = "/wake_log.csv";
|
||||||
|
static const char* NVS_LOG_NS = "wake_log";
|
||||||
|
static constexpr size_t MAX_LOG_SIZE = 200 * 1024; // 200KB cap
|
||||||
|
static constexpr size_t KEEP_SIZE = 100 * 1024; // Keep last 100KB on truncation
|
||||||
|
|
||||||
|
static bool _logReady = false;
|
||||||
|
static uint32_t _bootCount = 0;
|
||||||
|
static bool _rtcValidAtBoot = false;
|
||||||
|
|
||||||
|
static const char* resultStr(WakeLogResult r) {
|
||||||
|
switch (r) {
|
||||||
|
case WakeLogResult::Ok: return "ok";
|
||||||
|
case WakeLogResult::FailFetch: return "fail_fetch";
|
||||||
|
case WakeLogResult::FailDownload: return "fail_download";
|
||||||
|
case WakeLogResult::FailProcess: return "fail_process";
|
||||||
|
default: {
|
||||||
|
WakeLogResult unreachable = r;
|
||||||
|
(void)unreachable;
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void wakeLogBegin() {
|
||||||
|
// Record RTC validity before anything else might change the clock
|
||||||
|
_rtcValidAtBoot = hasValidTime();
|
||||||
|
|
||||||
|
// Mount LittleFS (idempotent — safe to call if already mounted by web server)
|
||||||
|
if (!LittleFS.begin(true)) {
|
||||||
|
Serial.println("[wake_log] LittleFS mount failed — logging disabled");
|
||||||
|
_logReady = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment boot counter in NVS (fire-and-forget)
|
||||||
|
Preferences prefs;
|
||||||
|
if (prefs.begin(NVS_LOG_NS, false)) {
|
||||||
|
_bootCount = prefs.getUInt("boot_cnt", 0) + 1;
|
||||||
|
prefs.putUInt("boot_cnt", _bootCount);
|
||||||
|
prefs.end();
|
||||||
|
} else {
|
||||||
|
Serial.println("[wake_log] NVS open failed — using boot_count=0");
|
||||||
|
_bootCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logReady = true;
|
||||||
|
Serial.printf("[wake_log] Ready (boot #%u, rtc_valid=%d)\n",
|
||||||
|
_bootCount, _rtcValidAtBoot ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate log file to keep only the last KEEP_SIZE bytes.
|
||||||
|
// Uses PSRAM buffer. Skips silently on failure.
|
||||||
|
static void truncateIfNeeded() {
|
||||||
|
File f = LittleFS.open(LOG_PATH, "r");
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
size_t fileSize = f.size();
|
||||||
|
if (fileSize <= MAX_LOG_SIZE) {
|
||||||
|
f.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[wake_log] Log at %uKB — truncating to last %uKB\n",
|
||||||
|
(unsigned)(fileSize / 1024), (unsigned)(KEEP_SIZE / 1024));
|
||||||
|
|
||||||
|
// Allocate buffer in PSRAM
|
||||||
|
uint8_t* buf = (uint8_t*)ps_malloc(KEEP_SIZE);
|
||||||
|
if (!buf) {
|
||||||
|
Serial.println("[wake_log] PSRAM alloc failed — skipping truncation");
|
||||||
|
f.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to (fileSize - KEEP_SIZE) and read the tail
|
||||||
|
f.seek(fileSize - KEEP_SIZE);
|
||||||
|
size_t bytesRead = f.read(buf, KEEP_SIZE);
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
if (bytesRead == 0) {
|
||||||
|
free(buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first newline to avoid a partial line at the start
|
||||||
|
size_t start = 0;
|
||||||
|
for (size_t i = 0; i < bytesRead; i++) {
|
||||||
|
if (buf[i] == '\n') {
|
||||||
|
start = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the file with just the tail
|
||||||
|
File out = LittleFS.open(LOG_PATH, "w");
|
||||||
|
if (!out) {
|
||||||
|
Serial.println("[wake_log] File rewrite failed — skipping truncation");
|
||||||
|
free(buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.write(buf + start, bytesRead - start);
|
||||||
|
out.close();
|
||||||
|
free(buf);
|
||||||
|
|
||||||
|
Serial.printf("[wake_log] Truncated to %uKB\n",
|
||||||
|
(unsigned)((bytesRead - start) / 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
|
||||||
|
const String& assetId, const String& photoDate,
|
||||||
|
const String& filter, WakeLogResult result, bool isDeepSleep) {
|
||||||
|
if (!_logReady) return;
|
||||||
|
|
||||||
|
// Read error tracking from NVS (defaults on failure)
|
||||||
|
uint32_t errorsTotal = 0;
|
||||||
|
String lastErrorTs = "";
|
||||||
|
|
||||||
|
Preferences prefs;
|
||||||
|
if (prefs.begin(NVS_LOG_NS, false)) {
|
||||||
|
errorsTotal = prefs.getUInt("err_total", 0);
|
||||||
|
lastErrorTs = prefs.getString("err_last_ts", "");
|
||||||
|
|
||||||
|
// Update error counters if this is a failure
|
||||||
|
if (result != WakeLogResult::Ok) {
|
||||||
|
errorsTotal++;
|
||||||
|
lastErrorTs = hasValidTime() ? isoNow() : "unknown";
|
||||||
|
prefs.putUInt("err_total", errorsTotal);
|
||||||
|
prefs.putString("err_last_ts", lastErrorTs);
|
||||||
|
}
|
||||||
|
prefs.end();
|
||||||
|
} else {
|
||||||
|
Serial.println("[wake_log] NVS read failed — using defaults");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build CSV line
|
||||||
|
uint32_t cycleMs = millis();
|
||||||
|
uint32_t freePsramKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024);
|
||||||
|
uint32_t freeHeapKb = (uint32_t)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024);
|
||||||
|
String timestamp = hasValidTime() ? isoNow() : "no_clock";
|
||||||
|
|
||||||
|
char line[512];
|
||||||
|
snprintf(line, sizeof(line),
|
||||||
|
"%s,%u,%u,%u,%d,%u,%u,%d,%u,%s,%s,%s,%s,%s,%s,%u\n",
|
||||||
|
timestamp.c_str(),
|
||||||
|
(unsigned)battPct,
|
||||||
|
(unsigned)freePsramKb,
|
||||||
|
(unsigned)freeHeapKb,
|
||||||
|
wifiRssi,
|
||||||
|
(unsigned)wifiMs,
|
||||||
|
(unsigned)cycleMs,
|
||||||
|
_rtcValidAtBoot ? 1 : 0,
|
||||||
|
(unsigned)_bootCount,
|
||||||
|
filter.c_str(),
|
||||||
|
assetId.c_str(),
|
||||||
|
photoDate.c_str(),
|
||||||
|
resultStr(result),
|
||||||
|
isDeepSleep ? "sleep" : "normal",
|
||||||
|
lastErrorTs.c_str(),
|
||||||
|
(unsigned)errorsTotal);
|
||||||
|
|
||||||
|
// Append to log file
|
||||||
|
File f = LittleFS.open(LOG_PATH, "a");
|
||||||
|
if (!f) {
|
||||||
|
Serial.println("[wake_log] File open failed — skipping log entry");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
f.print(line);
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
Serial.printf("[wake_log] Logged: %s %s (%s)\n",
|
||||||
|
resultStr(result), assetId.c_str(),
|
||||||
|
isDeepSleep ? "sleep" : "normal");
|
||||||
|
|
||||||
|
// Check if truncation is needed
|
||||||
|
truncateIfNeeded();
|
||||||
|
}
|
||||||
21
src/wake_log.h
Normal file
21
src/wake_log.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
enum class WakeLogResult : uint8_t {
|
||||||
|
Ok,
|
||||||
|
FailFetch,
|
||||||
|
FailDownload,
|
||||||
|
FailProcess
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the wake log subsystem. Call once early in boot.
|
||||||
|
// Mounts LittleFS, increments boot counter, records RTC validity.
|
||||||
|
// Fail-safe: if anything fails, logging becomes a no-op.
|
||||||
|
void wakeLogBegin();
|
||||||
|
|
||||||
|
// Append one CSV line to the persistent log.
|
||||||
|
// Fail-safe: if filesystem or NVS is unavailable, silently returns.
|
||||||
|
void wakeLogAppend(uint8_t battPct, int wifiRssi, uint32_t wifiMs,
|
||||||
|
const String& assetId, const String& photoDate,
|
||||||
|
const String& filter, WakeLogResult result, bool isDeepSleep);
|
||||||
382
src/web_server.cpp
Normal file
382
src/web_server.cpp
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
#include "web_server.h"
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <Update.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include "immich_client.h"
|
||||||
|
#include "power_manager.h"
|
||||||
|
#include "wifi_manager.h"
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
void AppWebServer::begin(SettingsManager& settings, ImmichClient& immich,
|
||||||
|
PowerManager& power, WiFiManager& wifi) {
|
||||||
|
_settings = &settings;
|
||||||
|
_immich = &immich;
|
||||||
|
_power = &power;
|
||||||
|
_wifi = &wifi;
|
||||||
|
|
||||||
|
if (!LittleFS.begin(true)) {
|
||||||
|
Serial.println("[web] LittleFS mount failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_wifi->isAPMode()) {
|
||||||
|
setupCaptivePortal();
|
||||||
|
} else {
|
||||||
|
setupAPIRoutes();
|
||||||
|
setupStaticFiles();
|
||||||
|
setupOTA();
|
||||||
|
}
|
||||||
|
|
||||||
|
_server.begin();
|
||||||
|
Serial.printf("[web] Server started (mode: %s)\n",
|
||||||
|
_wifi->isAPMode() ? "AP/captive" : "LAN");
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::setActionCallback(std::function<void(const String& action)> cb) {
|
||||||
|
_actionCb = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AppWebServer::isOTAInProgress() {
|
||||||
|
return _otaInProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::setupCaptivePortal() {
|
||||||
|
// Serve setup page for all requests (captive portal behavior)
|
||||||
|
_server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
|
||||||
|
request->send(LittleFS, "/setup.html", "text/html");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kick off initial async WiFi scan
|
||||||
|
WiFi.scanNetworks(true);
|
||||||
|
|
||||||
|
// Handle WiFi scan (non-blocking — returns cached results or "scanning" status)
|
||||||
|
_server.on("/api/wifi/scan", HTTP_GET, [](AsyncWebServerRequest* request) {
|
||||||
|
int n = WiFi.scanComplete();
|
||||||
|
if (n == WIFI_SCAN_RUNNING) {
|
||||||
|
// Scan in progress — tell client to retry
|
||||||
|
request->send(200, "application/json", "{\"scanning\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n == WIFI_SCAN_FAILED) {
|
||||||
|
// No scan started or failed — kick one off
|
||||||
|
WiFi.scanNetworks(true);
|
||||||
|
request->send(200, "application/json", "{\"scanning\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan complete — return results
|
||||||
|
JsonDocument doc;
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
JsonObject net = arr.add<JsonObject>();
|
||||||
|
net["ssid"] = WiFi.SSID(i);
|
||||||
|
net["rssi"] = WiFi.RSSI(i);
|
||||||
|
net["secure"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
|
||||||
|
}
|
||||||
|
String response;
|
||||||
|
serializeJson(doc, response);
|
||||||
|
|
||||||
|
// Start a fresh scan for next request
|
||||||
|
WiFi.scanDelete();
|
||||||
|
WiFi.scanNetworks(true);
|
||||||
|
|
||||||
|
request->send(200, "application/json", response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle setup submission
|
||||||
|
_server.on("/api/setup", HTTP_POST, [](AsyncWebServerRequest* request) {
|
||||||
|
request->send(200);
|
||||||
|
}, nullptr, [this](AsyncWebServerRequest* request, uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
if (index + len == total) {
|
||||||
|
handlePostSetup(request, data, len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Captive portal redirect for all other paths
|
||||||
|
_server.onNotFound([](AsyncWebServerRequest* request) {
|
||||||
|
request->redirect("/");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::setupAPIRoutes() {
|
||||||
|
_server.on("/api/status", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* req) { handleGetStatus(req); });
|
||||||
|
|
||||||
|
_server.on("/api/albums", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* req) { handleGetAlbums(req); });
|
||||||
|
|
||||||
|
_server.on("/api/albums/select", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) { req->send(200); },
|
||||||
|
nullptr,
|
||||||
|
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
if (index + len == total) handlePostAlbumsSelect(req, data, len);
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/settings", HTTP_GET,
|
||||||
|
[this](AsyncWebServerRequest* req) { handleGetSettings(req); });
|
||||||
|
|
||||||
|
_server.on("/api/settings", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) { req->send(200); },
|
||||||
|
nullptr,
|
||||||
|
[this](AsyncWebServerRequest* req, uint8_t* data, size_t len,
|
||||||
|
size_t index, size_t total) {
|
||||||
|
if (index + len == total) handlePostSettings(req, data, len);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Action endpoints
|
||||||
|
_server.on("/api/action/next", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
if (_actionCb) _actionCb("next");
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/random", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
if (_actionCb) _actionCb("random");
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/pause", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
if (_actionCb) _actionCb("pause");
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/play", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
if (_actionCb) _actionCb("play");
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/sleep", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
req->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
delay(100);
|
||||||
|
Settings s = _settings->get();
|
||||||
|
_power->enterDeepSleep(s.interval_min);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Immich connectivity
|
||||||
|
_server.on("/api/test-connection", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
Settings s = _settings->get();
|
||||||
|
Serial.printf("[web] Testing connection to: %s (key: %s...)\n",
|
||||||
|
s.immich_url.c_str(),
|
||||||
|
s.immich_key.substring(0, 8).c_str());
|
||||||
|
|
||||||
|
// Reinitialize immich client with current settings
|
||||||
|
_immich->begin(s.immich_url, s.immich_key);
|
||||||
|
auto albums = _immich->fetchAlbums();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
if (!albums.empty()) {
|
||||||
|
doc["ok"] = true;
|
||||||
|
doc["msg"] = String("Connected! Found ") + String(albums.size()) + " albums.";
|
||||||
|
} else {
|
||||||
|
doc["ok"] = false;
|
||||||
|
doc["msg"] = "Failed to connect. Check URL and API key.";
|
||||||
|
}
|
||||||
|
String response;
|
||||||
|
serializeJson(doc, response);
|
||||||
|
req->send(200, "application/json", response);
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/sync-time", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* req) {
|
||||||
|
_wifi->syncNTP();
|
||||||
|
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"RTC synced\"}");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wake log endpoints
|
||||||
|
_server.on("/api/log", HTTP_GET,
|
||||||
|
[](AsyncWebServerRequest* req) {
|
||||||
|
if (LittleFS.exists("/wake_log.csv")) {
|
||||||
|
req->send(LittleFS, "/wake_log.csv", "text/csv");
|
||||||
|
} else {
|
||||||
|
req->send(200, "text/csv", "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/log/clear", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {
|
||||||
|
LittleFS.remove("/wake_log.csv");
|
||||||
|
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Log cleared\"}");
|
||||||
|
});
|
||||||
|
|
||||||
|
_server.on("/api/action/reboot", HTTP_POST,
|
||||||
|
[](AsyncWebServerRequest* req) {
|
||||||
|
req->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
|
||||||
|
delay(500);
|
||||||
|
ESP.restart();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::setupStaticFiles() {
|
||||||
|
_server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::setupOTA() {
|
||||||
|
_server.on("/api/firmware", HTTP_POST,
|
||||||
|
[this](AsyncWebServerRequest* request) {
|
||||||
|
_otaInProgress = false;
|
||||||
|
bool success = !Update.hasError();
|
||||||
|
request->send(200, "application/json",
|
||||||
|
success ? "{\"ok\":true,\"msg\":\"Rebooting...\"}"
|
||||||
|
: "{\"ok\":false,\"msg\":\"Update failed\"}");
|
||||||
|
if (success) {
|
||||||
|
delay(500);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[this](AsyncWebServerRequest* request, const String& filename,
|
||||||
|
size_t index, uint8_t* data, size_t len, bool final) {
|
||||||
|
if (index == 0) {
|
||||||
|
_otaInProgress = true;
|
||||||
|
Serial.printf("[web] OTA start: %s\n", filename.c_str());
|
||||||
|
if (!Update.begin(UPDATE_SIZE_UNKNOWN, U_FLASH)) {
|
||||||
|
Serial.println("[web] OTA begin failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Update.isRunning()) {
|
||||||
|
Update.write(data, len);
|
||||||
|
}
|
||||||
|
if (final) {
|
||||||
|
if (Update.end(true)) {
|
||||||
|
Serial.printf("[web] OTA complete: %u bytes\n", index + len);
|
||||||
|
} else {
|
||||||
|
Serial.println("[web] OTA finalize failed");
|
||||||
|
Update.printError(Serial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handleGetStatus(AsyncWebServerRequest* request) {
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["battery"] = _power->getBatteryPercent();
|
||||||
|
doc["charging"] = _power->isCharging();
|
||||||
|
doc["wifi_rssi"] = _wifi->getRSSI();
|
||||||
|
doc["ip"] = _wifi->getIP();
|
||||||
|
doc["uptime"] = millis() / 1000;
|
||||||
|
doc["free_heap"] = ESP.getFreeHeap();
|
||||||
|
doc["free_psram"] = ESP.getFreePsram();
|
||||||
|
|
||||||
|
String response;
|
||||||
|
serializeJson(doc, response);
|
||||||
|
request->send(200, "application/json", response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handleGetAlbums(AsyncWebServerRequest* request) {
|
||||||
|
auto albums = _immich->fetchAlbums();
|
||||||
|
JsonDocument doc;
|
||||||
|
JsonArray arr = doc.to<JsonArray>();
|
||||||
|
for (auto& album : albums) {
|
||||||
|
JsonObject obj = arr.add<JsonObject>();
|
||||||
|
obj["id"] = album.id;
|
||||||
|
obj["title"] = album.title;
|
||||||
|
obj["assetCount"] = album.assetCount;
|
||||||
|
}
|
||||||
|
String response;
|
||||||
|
serializeJson(doc, response);
|
||||||
|
request->send(200, "application/json", response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handlePostAlbumsSelect(AsyncWebServerRequest* request,
|
||||||
|
uint8_t* data, size_t len) {
|
||||||
|
String body = String((char*)data).substring(0, len);
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, body)) {
|
||||||
|
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String albumsJson;
|
||||||
|
serializeJson(doc["album_ids"], albumsJson);
|
||||||
|
_settings->saveField("albums_json", albumsJson.c_str());
|
||||||
|
if (_actionCb) _actionCb("albums_changed");
|
||||||
|
request->send(200, "application/json", "{\"ok\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
|
||||||
|
Settings s = _settings->get();
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["interval_min"] = s.interval_min;
|
||||||
|
doc["cycle_mode"] = static_cast<uint8_t>(s.cycle_mode);
|
||||||
|
doc["img_quality"] = static_cast<uint8_t>(s.img_quality);
|
||||||
|
doc["meta_flags"] = s.meta_flags;
|
||||||
|
doc["meta_pos"] = static_cast<uint8_t>(s.meta_pos);
|
||||||
|
doc["date_fmt"] = s.date_fmt;
|
||||||
|
doc["time_fmt"] = s.time_fmt;
|
||||||
|
doc["pipeline_mode"] = s.pipeline_mode;
|
||||||
|
doc["dither_noise"] = s.dither_noise;
|
||||||
|
doc["meta_translucent"] = s.meta_translucent;
|
||||||
|
doc["led_brightness"] = s.led_brightness;
|
||||||
|
doc["show_battery"] = s.show_battery;
|
||||||
|
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);
|
||||||
|
request->send(200, "application/json", response);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handlePostSettings(AsyncWebServerRequest* request,
|
||||||
|
uint8_t* data, size_t len) {
|
||||||
|
String body = String((char*)data).substring(0, len);
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, body)) {
|
||||||
|
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings s = _settings->get();
|
||||||
|
|
||||||
|
if (!doc["interval_min"].isNull()) {
|
||||||
|
uint8_t v = doc["interval_min"];
|
||||||
|
s.interval_min = constrain(v, 1, 60);
|
||||||
|
}
|
||||||
|
if (!doc["cycle_mode"].isNull()) s.cycle_mode = static_cast<CycleMode>((uint8_t)doc["cycle_mode"]);
|
||||||
|
if (!doc["img_quality"].isNull()) s.img_quality = static_cast<ImageQuality>((uint8_t)doc["img_quality"]);
|
||||||
|
if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"];
|
||||||
|
if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast<MetaPosition>((uint8_t)doc["meta_pos"]);
|
||||||
|
if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as<String>();
|
||||||
|
if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as<String>();
|
||||||
|
if (!doc["pipeline_mode"].isNull()) s.pipeline_mode = doc["pipeline_mode"];
|
||||||
|
if (!doc["dither_noise"].isNull()) s.dither_noise = doc["dither_noise"];
|
||||||
|
if (!doc["meta_translucent"].isNull()) s.meta_translucent = doc["meta_translucent"];
|
||||||
|
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppWebServer::handlePostSetup(AsyncWebServerRequest* request,
|
||||||
|
uint8_t* data, size_t len) {
|
||||||
|
String body = String((char*)data).substring(0, len);
|
||||||
|
JsonDocument doc;
|
||||||
|
if (deserializeJson(doc, body)) {
|
||||||
|
request->send(400, "application/json", "{\"error\":\"Invalid JSON\"}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings s = _settings->get();
|
||||||
|
s.wifi_ssid = doc["wifi_ssid"].as<String>();
|
||||||
|
s.wifi_pass = doc["wifi_pass"].as<String>();
|
||||||
|
s.immich_url = doc["immich_url"] | DEFAULT_IMMICH_URL;
|
||||||
|
s.immich_key = doc["immich_key"].as<String>();
|
||||||
|
_settings->save(s);
|
||||||
|
|
||||||
|
request->send(200, "application/json", "{\"ok\":true,\"msg\":\"Rebooting...\"}");
|
||||||
|
delay(1000);
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
40
src/web_server.h
Normal file
40
src/web_server.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
#include <functional>
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
class ImmichClient;
|
||||||
|
class PowerManager;
|
||||||
|
class WiFiManager;
|
||||||
|
|
||||||
|
class AppWebServer {
|
||||||
|
public:
|
||||||
|
void begin(SettingsManager& settings, ImmichClient& immich,
|
||||||
|
PowerManager& power, WiFiManager& wifi);
|
||||||
|
void setActionCallback(std::function<void(const String& action)> cb);
|
||||||
|
bool isOTAInProgress();
|
||||||
|
|
||||||
|
private:
|
||||||
|
AsyncWebServer _server{80};
|
||||||
|
SettingsManager* _settings = nullptr;
|
||||||
|
ImmichClient* _immich = nullptr;
|
||||||
|
PowerManager* _power = nullptr;
|
||||||
|
WiFiManager* _wifi = nullptr;
|
||||||
|
std::function<void(const String& action)> _actionCb;
|
||||||
|
bool _otaInProgress = false;
|
||||||
|
|
||||||
|
void setupCaptivePortal();
|
||||||
|
void setupAPIRoutes();
|
||||||
|
void setupStaticFiles();
|
||||||
|
void setupOTA();
|
||||||
|
|
||||||
|
void handleGetStatus(AsyncWebServerRequest* request);
|
||||||
|
void handleGetAlbums(AsyncWebServerRequest* request);
|
||||||
|
void handlePostAlbumsSelect(AsyncWebServerRequest* request, uint8_t* data, size_t len);
|
||||||
|
void handleGetSettings(AsyncWebServerRequest* request);
|
||||||
|
void handlePostSettings(AsyncWebServerRequest* request, uint8_t* data, size_t len);
|
||||||
|
void handlePostAction(AsyncWebServerRequest* request);
|
||||||
|
void handlePostSetup(AsyncWebServerRequest* request, uint8_t* data, size_t len);
|
||||||
|
};
|
||||||
138
src/wifi_manager.cpp
Normal file
138
src/wifi_manager.cpp
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#include "wifi_manager.h"
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <ESPmDNS.h>
|
||||||
|
#include <M5Unified.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <sys/time.h>
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
void WiFiManager::begin(SettingsManager& settings, bool minimal) {
|
||||||
|
_settings = &settings;
|
||||||
|
|
||||||
|
if (!_settings->isProvisioned()) {
|
||||||
|
Serial.println("[wifi] Not provisioned — starting AP");
|
||||||
|
startAP();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startStation(minimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WiFiManager::isConnected() {
|
||||||
|
return WiFi.status() == WL_CONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WiFiManager::isAPMode() {
|
||||||
|
return _apMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
String WiFiManager::getIP() {
|
||||||
|
if (_apMode) {
|
||||||
|
return WiFi.softAPIP().toString();
|
||||||
|
}
|
||||||
|
return WiFi.localIP().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
int WiFiManager::getRSSI() {
|
||||||
|
if (_apMode) return 0;
|
||||||
|
return WiFi.RSSI();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WiFiManager::startAP() {
|
||||||
|
WiFi.disconnect(true);
|
||||||
|
WiFi.mode(WIFI_AP);
|
||||||
|
WiFi.softAP("PaperColor-Setup");
|
||||||
|
_apMode = true;
|
||||||
|
Serial.printf("[wifi] AP started: PaperColor-Setup, IP: %s\n",
|
||||||
|
WiFi.softAPIP().toString().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void WiFiManager::startStation(bool minimal) {
|
||||||
|
Settings s = _settings->get();
|
||||||
|
WiFi.disconnect(true);
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
_apMode = false;
|
||||||
|
|
||||||
|
Serial.printf("[wifi] Connecting to: %s%s\n", s.wifi_ssid.c_str(),
|
||||||
|
minimal ? " (minimal)" : "");
|
||||||
|
|
||||||
|
for (_retryCount = 0; _retryCount < WIFI_MAX_RETRIES; _retryCount++) {
|
||||||
|
if (attemptConnection(s.wifi_ssid, s.wifi_pass)) {
|
||||||
|
Serial.printf("[wifi] Connected! IP: %s, RSSI: %d\n",
|
||||||
|
WiFi.localIP().toString().c_str(), WiFi.RSSI());
|
||||||
|
|
||||||
|
if (minimal) {
|
||||||
|
// Deep sleep wake path: skip blocking NTP wait and mDNS to
|
||||||
|
// minimize active time. Still call configTime() to initialize
|
||||||
|
// the DNS resolver (required for HTTPS hostname resolution).
|
||||||
|
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WiFi.setSleep(WIFI_PS_MIN_MODEM);
|
||||||
|
syncNTP();
|
||||||
|
setupMDNS("papercolor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Serial.printf("[wifi] Attempt %d/%d failed\n", _retryCount + 1, WIFI_MAX_RETRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[wifi] All attempts failed — falling back to AP mode");
|
||||||
|
startAP();
|
||||||
|
}
|
||||||
|
|
||||||
|
void WiFiManager::setupMDNS(const char* hostname) {
|
||||||
|
if (MDNS.begin(hostname)) {
|
||||||
|
MDNS.addService("http", "tcp", 80);
|
||||||
|
Serial.printf("[wifi] mDNS: %s.local\n", hostname);
|
||||||
|
} else {
|
||||||
|
Serial.println("[wifi] mDNS failed to start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WiFiManager::attemptConnection(const String& ssid, const String& pass) {
|
||||||
|
WiFi.begin(ssid.c_str(), pass.c_str());
|
||||||
|
|
||||||
|
unsigned long start = millis();
|
||||||
|
while (WiFi.status() != WL_CONNECTED) {
|
||||||
|
if (millis() - start > WIFI_CONNECT_TIMEOUT_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
delay(100);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WiFiManager::syncNTP() {
|
||||||
|
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
|
||||||
|
|
||||||
|
// Wait up to 5 seconds for NTP sync
|
||||||
|
unsigned long start = millis();
|
||||||
|
time_t now = 0;
|
||||||
|
while (now < 1600000000 && (millis() - start) < 5000) {
|
||||||
|
delay(100);
|
||||||
|
time(&now);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now < 1600000000) {
|
||||||
|
Serial.println("[wifi] NTP sync timed out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write synced time to hardware RTC
|
||||||
|
struct tm t;
|
||||||
|
gmtime_r(&now, &t);
|
||||||
|
|
||||||
|
m5::rtc_datetime_t dt;
|
||||||
|
dt.date.year = t.tm_year + 1900;
|
||||||
|
dt.date.month = t.tm_mon + 1;
|
||||||
|
dt.date.date = t.tm_mday;
|
||||||
|
dt.time.hours = t.tm_hour;
|
||||||
|
dt.time.minutes = t.tm_min;
|
||||||
|
dt.time.seconds = t.tm_sec;
|
||||||
|
M5.Rtc.setDateTime(dt);
|
||||||
|
|
||||||
|
Serial.printf("[wifi] NTP synced: %04d-%02d-%02d %02d:%02d:%02d UTC, written to RTC\n",
|
||||||
|
dt.date.year, dt.date.month, dt.date.date,
|
||||||
|
dt.time.hours, dt.time.minutes, dt.time.seconds);
|
||||||
|
}
|
||||||
24
src/wifi_manager.h
Normal file
24
src/wifi_manager.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include "settings.h"
|
||||||
|
|
||||||
|
class WiFiManager {
|
||||||
|
public:
|
||||||
|
void begin(SettingsManager& settings, bool minimal = false);
|
||||||
|
bool isConnected();
|
||||||
|
bool isAPMode();
|
||||||
|
String getIP();
|
||||||
|
int getRSSI();
|
||||||
|
void startAP();
|
||||||
|
void startStation(bool minimal = false);
|
||||||
|
void setupMDNS(const char* hostname);
|
||||||
|
void syncNTP();
|
||||||
|
|
||||||
|
private:
|
||||||
|
SettingsManager* _settings = nullptr;
|
||||||
|
bool _apMode = false;
|
||||||
|
int _retryCount = 0;
|
||||||
|
|
||||||
|
bool attemptConnection(const String& ssid, const String& pass);
|
||||||
|
};
|
||||||
44
test/README.md
Normal file
44
test/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Native Tests (host machine)
|
||||||
|
|
||||||
|
Run algorithm tests that don't require hardware:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio test -e native
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `test_photo_queue.cpp` — queue cycling, favorites weighting
|
||||||
|
- `test_image_pipeline.cpp` — nearest color, dithering correctness
|
||||||
|
|
||||||
|
## On-Device Testing
|
||||||
|
|
||||||
|
Flash and monitor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -e m5stack-papercolor -t upload && pio device monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification Checklist
|
||||||
|
|
||||||
|
- [ ] Device boots and prints version to serial
|
||||||
|
- [ ] AP mode activates on first boot
|
||||||
|
- [ ] Captive portal serves setup page at 192.168.4.1
|
||||||
|
- [ ] WiFi credentials save and device reboots to station mode
|
||||||
|
- [ ] mDNS resolves at papercolor.local
|
||||||
|
- [ ] Web UI dashboard shows battery/WiFi/uptime
|
||||||
|
- [ ] Albums page lists Immich albums
|
||||||
|
- [ ] Album selection persists across reboots
|
||||||
|
- [ ] Photos download and display correctly
|
||||||
|
- [ ] Portrait photos pair side-by-side
|
||||||
|
- [ ] Slideshow advances at configured interval
|
||||||
|
- [ ] BTN_UP: next photo (LED flashes white)
|
||||||
|
- [ ] BTN_TOP: random photo (LED flashes white)
|
||||||
|
- [ ] BTN_DOWN: pause/play toggle
|
||||||
|
- [ ] BTN_TOP+BTN_DOWN 3s: deep sleep
|
||||||
|
- [ ] BTN_UP 5s: factory reset
|
||||||
|
- [ ] Battery LED: orange at 25%, red at 10%
|
||||||
|
- [ ] OTA upload succeeds via web UI
|
||||||
|
- [ ] Device recovers from WiFi disconnect (reconnect on next cycle)
|
||||||
294
test/test_native/test_image_pipeline.cpp
Normal file
294
test/test_native/test_image_pipeline.cpp
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <climits>
|
||||||
|
|
||||||
|
// Calibrated Spectra 6 palette (matches PALETTE_CALIBRATED in image_pipeline.cpp)
|
||||||
|
struct Color { uint8_t r, g, b; };
|
||||||
|
|
||||||
|
static const Color PALETTE[6] = {
|
||||||
|
{0x1F, 0x22, 0x26}, // Black -> dark gray
|
||||||
|
{0xB9, 0xC7, 0xC9}, // White -> light gray-blue
|
||||||
|
{0x62, 0x20, 0x1E}, // Red -> dark red/brown
|
||||||
|
{0x35, 0x56, 0x3A}, // Green -> dark forest green
|
||||||
|
{0x23, 0x3F, 0x8E}, // Blue -> dark navy
|
||||||
|
{0xC1, 0xBB, 0x1E} // Yellow -> olive/mustard
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find nearest palette color (Rec. 709 luminance-weighted RGB distance)
|
||||||
|
uint8_t findNearestColor(int r, int g, int b) {
|
||||||
|
uint8_t best = 0;
|
||||||
|
int32_t bestDist = INT32_MAX;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
int dr = r - PALETTE[i].r;
|
||||||
|
int dg = g - PALETTE[i].g;
|
||||||
|
int db = b - PALETTE[i].b;
|
||||||
|
int32_t dist = 2126 * dr * dr + 7152 * dg * dg + 722 * db * db;
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
best = static_cast<uint8_t>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floyd-Steinberg dithering on a small test buffer
|
||||||
|
void ditherBuffer(uint8_t* rgb, int width, int height, uint8_t* output) {
|
||||||
|
// Working buffer with int16_t to handle error diffusion overflow
|
||||||
|
int16_t* work = (int16_t*)malloc(width * height * 3 * sizeof(int16_t));
|
||||||
|
for (int i = 0; i < width * height * 3; i++) {
|
||||||
|
work[i] = rgb[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int idx = (y * width + x) * 3;
|
||||||
|
int r = work[idx];
|
||||||
|
int g = work[idx + 1];
|
||||||
|
int b = work[idx + 2];
|
||||||
|
|
||||||
|
// Clamp
|
||||||
|
r = r < 0 ? 0 : (r > 255 ? 255 : r);
|
||||||
|
g = g < 0 ? 0 : (g > 255 ? 255 : g);
|
||||||
|
b = b < 0 ? 0 : (b > 255 ? 255 : b);
|
||||||
|
|
||||||
|
uint8_t nearest = findNearestColor(r, g, b);
|
||||||
|
output[y * width + x] = nearest;
|
||||||
|
|
||||||
|
// Error
|
||||||
|
int errR = r - PALETTE[nearest].r;
|
||||||
|
int errG = g - PALETTE[nearest].g;
|
||||||
|
int errB = b - PALETTE[nearest].b;
|
||||||
|
|
||||||
|
// Distribute error (Floyd-Steinberg weights: 7/16, 3/16, 5/16, 1/16)
|
||||||
|
if (x + 1 < width) {
|
||||||
|
int ni = (y * width + (x + 1)) * 3;
|
||||||
|
work[ni] += errR * 7 / 16;
|
||||||
|
work[ni + 1] += errG * 7 / 16;
|
||||||
|
work[ni + 2] += errB * 7 / 16;
|
||||||
|
}
|
||||||
|
if (y + 1 < height) {
|
||||||
|
if (x > 0) {
|
||||||
|
int ni = ((y + 1) * width + (x - 1)) * 3;
|
||||||
|
work[ni] += errR * 3 / 16;
|
||||||
|
work[ni + 1] += errG * 3 / 16;
|
||||||
|
work[ni + 2] += errB * 3 / 16;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
int ni = ((y + 1) * width + x) * 3;
|
||||||
|
work[ni] += errR * 5 / 16;
|
||||||
|
work[ni + 1] += errG * 5 / 16;
|
||||||
|
work[ni + 2] += errB * 5 / 16;
|
||||||
|
}
|
||||||
|
if (x + 1 < width) {
|
||||||
|
int ni = ((y + 1) * width + (x + 1)) * 3;
|
||||||
|
work[ni] += errR * 1 / 16;
|
||||||
|
work[ni + 1] += errG * 1 / 16;
|
||||||
|
work[ni + 2] += errB * 1 / 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(work);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_black() {
|
||||||
|
TEST_ASSERT_EQUAL(0, findNearestColor(0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_white() {
|
||||||
|
TEST_ASSERT_EQUAL(1, findNearestColor(255, 255, 255));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_red() {
|
||||||
|
TEST_ASSERT_EQUAL(2, findNearestColor(180, 20, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_green() {
|
||||||
|
TEST_ASSERT_EQUAL(3, findNearestColor(20, 140, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_blue() {
|
||||||
|
TEST_ASSERT_EQUAL(4, findNearestColor(20, 20, 180));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_yellow() {
|
||||||
|
TEST_ASSERT_EQUAL(5, findNearestColor(200, 180, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_dither_solid_black() {
|
||||||
|
const int W = 4, H = 4;
|
||||||
|
uint8_t rgb[W * H * 3] = {0}; // All black
|
||||||
|
uint8_t output[W * H];
|
||||||
|
ditherBuffer(rgb, W, H, output);
|
||||||
|
for (int i = 0; i < W * H; i++) {
|
||||||
|
TEST_ASSERT_EQUAL(0, output[i]); // All should be black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_dither_solid_white() {
|
||||||
|
const int W = 4, H = 4;
|
||||||
|
uint8_t rgb[W * H * 3];
|
||||||
|
memset(rgb, 255, sizeof(rgb)); // All white
|
||||||
|
uint8_t output[W * H];
|
||||||
|
ditherBuffer(rgb, W, H, output);
|
||||||
|
for (int i = 0; i < W * H; i++) {
|
||||||
|
TEST_ASSERT_EQUAL(1, output[i]); // All should be white
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_dither_produces_valid_indices() {
|
||||||
|
const int W = 8, H = 8;
|
||||||
|
uint8_t rgb[W * H * 3];
|
||||||
|
// Fill with mid-gray
|
||||||
|
for (int i = 0; i < W * H * 3; i++) rgb[i] = 128;
|
||||||
|
uint8_t output[W * H];
|
||||||
|
ditherBuffer(rgb, W, H, output);
|
||||||
|
for (int i = 0; i < W * H; i++) {
|
||||||
|
TEST_ASSERT_TRUE(output[i] < 6); // Valid palette index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the actual blue noise texture for testing
|
||||||
|
#include "../../src/blue_noise.h"
|
||||||
|
|
||||||
|
static constexpr float TEST_BLUE_NOISE_STRENGTH = 32.0f;
|
||||||
|
|
||||||
|
// Floyd-Steinberg dither with blue noise threshold modulation
|
||||||
|
void ditherBufferWithBlueNoise(uint8_t* rgb, int width, int height, uint8_t* output) {
|
||||||
|
int16_t* work = (int16_t*)malloc(width * height * 3 * sizeof(int16_t));
|
||||||
|
for (int i = 0; i < width * height * 3; i++) {
|
||||||
|
work[i] = rgb[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
int idx = (y * width + x) * 3;
|
||||||
|
int r = work[idx];
|
||||||
|
int g = work[idx + 1];
|
||||||
|
int b = work[idx + 2];
|
||||||
|
|
||||||
|
r = r < 0 ? 0 : (r > 255 ? 255 : r);
|
||||||
|
g = g < 0 ? 0 : (g > 255 ? 255 : g);
|
||||||
|
b = b < 0 ? 0 : (b > 255 ? 255 : b);
|
||||||
|
|
||||||
|
// Apply blue noise perturbation
|
||||||
|
uint8_t noise = pgm_read_byte(&BLUE_NOISE_64[(y & 63) * 64 + (x & 63)]);
|
||||||
|
float nf = ((float)noise - 128.0f) * (TEST_BLUE_NOISE_STRENGTH / 128.0f);
|
||||||
|
int rn = r + (int)nf;
|
||||||
|
int gn = g + (int)nf;
|
||||||
|
int bn = b + (int)nf;
|
||||||
|
rn = rn < 0 ? 0 : (rn > 255 ? 255 : rn);
|
||||||
|
gn = gn < 0 ? 0 : (gn > 255 ? 255 : gn);
|
||||||
|
bn = bn < 0 ? 0 : (bn > 255 ? 255 : bn);
|
||||||
|
|
||||||
|
uint8_t nearest = findNearestColor(rn, gn, bn);
|
||||||
|
output[y * width + x] = nearest;
|
||||||
|
|
||||||
|
// Error uses original r,g,b (energy conservation)
|
||||||
|
int errR = r - PALETTE[nearest].r;
|
||||||
|
int errG = g - PALETTE[nearest].g;
|
||||||
|
int errB = b - PALETTE[nearest].b;
|
||||||
|
|
||||||
|
if (x + 1 < width) {
|
||||||
|
int ni = (y * width + (x + 1)) * 3;
|
||||||
|
work[ni] += errR * 7 / 16;
|
||||||
|
work[ni + 1] += errG * 7 / 16;
|
||||||
|
work[ni + 2] += errB * 7 / 16;
|
||||||
|
}
|
||||||
|
if (y + 1 < height) {
|
||||||
|
if (x > 0) {
|
||||||
|
int ni = ((y + 1) * width + (x - 1)) * 3;
|
||||||
|
work[ni] += errR * 3 / 16;
|
||||||
|
work[ni + 1] += errG * 3 / 16;
|
||||||
|
work[ni + 2] += errB * 3 / 16;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
int ni = ((y + 1) * width + x) * 3;
|
||||||
|
work[ni] += errR * 5 / 16;
|
||||||
|
work[ni + 1] += errG * 5 / 16;
|
||||||
|
work[ni + 2] += errB * 5 / 16;
|
||||||
|
}
|
||||||
|
if (x + 1 < width) {
|
||||||
|
int ni = ((y + 1) * width + (x + 1)) * 3;
|
||||||
|
work[ni] += errR * 1 / 16;
|
||||||
|
work[ni + 1] += errG * 1 / 16;
|
||||||
|
work[ni + 2] += errB * 1 / 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(work);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_blue_noise_breaks_pattern_regularity() {
|
||||||
|
// Use a 16x16 dark gray patch — just above the black palette value.
|
||||||
|
// Standard F-S produces highly regular dot patterns in this region.
|
||||||
|
const int W = 16, H = 16;
|
||||||
|
uint8_t rgb[W * H * 3];
|
||||||
|
memset(rgb, 45, sizeof(rgb)); // Dark gray (45,45,45) — triggers error accumulation
|
||||||
|
|
||||||
|
uint8_t output_standard[W * H];
|
||||||
|
uint8_t output_blue_noise[W * H];
|
||||||
|
|
||||||
|
// Copy rgb since ditherBuffer modifies working buffer
|
||||||
|
uint8_t rgb_copy[W * H * 3];
|
||||||
|
memcpy(rgb_copy, rgb, sizeof(rgb));
|
||||||
|
|
||||||
|
ditherBuffer(rgb, W, H, output_standard);
|
||||||
|
ditherBufferWithBlueNoise(rgb_copy, W, H, output_blue_noise);
|
||||||
|
|
||||||
|
// Both should produce valid palette indices
|
||||||
|
for (int i = 0; i < W * H; i++) {
|
||||||
|
TEST_ASSERT_TRUE(output_standard[i] < 6);
|
||||||
|
TEST_ASSERT_TRUE(output_blue_noise[i] < 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count unique rows in each output to measure pattern regularity.
|
||||||
|
// Standard F-S in uniform areas tends to produce repeating row patterns.
|
||||||
|
// Blue noise should produce more unique rows (less periodic).
|
||||||
|
int unique_standard = 0;
|
||||||
|
int unique_blue_noise = 0;
|
||||||
|
|
||||||
|
for (int y = 0; y < H; y++) {
|
||||||
|
bool is_duplicate = false;
|
||||||
|
for (int prev = 0; prev < y; prev++) {
|
||||||
|
if (memcmp(&output_standard[y * W], &output_standard[prev * W], W) == 0) {
|
||||||
|
is_duplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_duplicate) unique_standard++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int y = 0; y < H; y++) {
|
||||||
|
bool is_duplicate = false;
|
||||||
|
for (int prev = 0; prev < y; prev++) {
|
||||||
|
if (memcmp(&output_blue_noise[y * W], &output_blue_noise[prev * W], W) == 0) {
|
||||||
|
is_duplicate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_duplicate) unique_blue_noise++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue noise version should have at least as many unique rows as standard.
|
||||||
|
// In practice it should have more (the whole point of the feature).
|
||||||
|
TEST_ASSERT_GREATER_OR_EQUAL(unique_standard, unique_blue_noise);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_blue_noise_dither_valid_indices() {
|
||||||
|
const int W = 16, H = 16;
|
||||||
|
uint8_t rgb[W * H * 3];
|
||||||
|
// Test across different gray levels
|
||||||
|
for (int level = 0; level < 256; level += 32) {
|
||||||
|
memset(rgb, level, sizeof(rgb));
|
||||||
|
uint8_t output[W * H];
|
||||||
|
ditherBufferWithBlueNoise(rgb, W, H, output);
|
||||||
|
for (int i = 0; i < W * H; i++) {
|
||||||
|
TEST_ASSERT_TRUE(output[i] < 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
test/test_native/test_photo_queue.cpp
Normal file
129
test/test_native/test_photo_queue.cpp
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#include <unity.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
// Minimal test of queue cycling logic (extracted, platform-independent)
|
||||||
|
// We test the shuffling and cycling algorithms without hardware dependencies
|
||||||
|
|
||||||
|
struct QueueState {
|
||||||
|
std::vector<std::string> ids;
|
||||||
|
size_t cursor;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate random cycling: advance through shuffled list
|
||||||
|
std::string advanceRandom(QueueState& state) {
|
||||||
|
if (state.ids.empty()) return "";
|
||||||
|
if (state.cursor >= state.ids.size()) {
|
||||||
|
state.cursor = 0; // Wrap around (would reshuffle in real impl)
|
||||||
|
}
|
||||||
|
return state.ids[state.cursor++];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate chronological: just advance sequentially (assumes pre-sorted)
|
||||||
|
std::string advanceChrono(QueueState& state) {
|
||||||
|
if (state.ids.empty()) return "";
|
||||||
|
if (state.cursor >= state.ids.size()) {
|
||||||
|
state.cursor = 0;
|
||||||
|
}
|
||||||
|
return state.ids[state.cursor++];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites weighting: insert duplicates
|
||||||
|
std::vector<std::string> applyFavoritesWeight(
|
||||||
|
const std::vector<std::string>& all,
|
||||||
|
const std::vector<std::string>& favorites,
|
||||||
|
int weight) {
|
||||||
|
|
||||||
|
std::vector<std::string> result = all;
|
||||||
|
for (const auto& fav : favorites) {
|
||||||
|
for (int i = 1; i < weight; i++) {
|
||||||
|
result.push_back(fav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_advance_random_no_repeats_until_wrap() {
|
||||||
|
QueueState state;
|
||||||
|
state.ids = {"a", "b", "c", "d", "e"};
|
||||||
|
state.cursor = 0;
|
||||||
|
|
||||||
|
std::vector<std::string> seen;
|
||||||
|
for (size_t i = 0; i < state.ids.size(); i++) {
|
||||||
|
std::string id = advanceRandom(state);
|
||||||
|
// Should not have seen this one yet
|
||||||
|
TEST_ASSERT_TRUE(std::find(seen.begin(), seen.end(), id) == seen.end());
|
||||||
|
seen.push_back(id);
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL(5, seen.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_advance_wraps_at_end() {
|
||||||
|
QueueState state;
|
||||||
|
state.ids = {"a", "b", "c"};
|
||||||
|
state.cursor = 0;
|
||||||
|
|
||||||
|
advanceRandom(state); // a
|
||||||
|
advanceRandom(state); // b
|
||||||
|
advanceRandom(state); // c
|
||||||
|
std::string wrapped = advanceRandom(state); // wraps to a
|
||||||
|
TEST_ASSERT_EQUAL_STRING("a", wrapped.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_empty_queue_returns_empty() {
|
||||||
|
QueueState state;
|
||||||
|
state.cursor = 0;
|
||||||
|
std::string result = advanceRandom(state);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("", result.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_favorites_weighting() {
|
||||||
|
std::vector<std::string> all = {"a", "b", "c"};
|
||||||
|
std::vector<std::string> favs = {"b"};
|
||||||
|
auto weighted = applyFavoritesWeight(all, favs, 3);
|
||||||
|
// "b" should appear 3 times total (1 original + 2 extra)
|
||||||
|
int count = 0;
|
||||||
|
for (const auto& id : weighted) {
|
||||||
|
if (id == "b") count++;
|
||||||
|
}
|
||||||
|
TEST_ASSERT_EQUAL(3, count);
|
||||||
|
TEST_ASSERT_EQUAL(5, weighted.size()); // 3 original + 2 extra
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_nearest_color_black();
|
||||||
|
void test_nearest_color_white();
|
||||||
|
void test_nearest_color_red();
|
||||||
|
void test_nearest_color_green();
|
||||||
|
void test_nearest_color_blue();
|
||||||
|
void test_nearest_color_yellow();
|
||||||
|
void test_dither_solid_black();
|
||||||
|
void test_dither_solid_white();
|
||||||
|
void test_dither_produces_valid_indices();
|
||||||
|
void test_blue_noise_breaks_pattern_regularity();
|
||||||
|
void test_blue_noise_dither_valid_indices();
|
||||||
|
|
||||||
|
void setUp() {}
|
||||||
|
void tearDown() {}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
UNITY_BEGIN();
|
||||||
|
RUN_TEST(test_advance_random_no_repeats_until_wrap);
|
||||||
|
RUN_TEST(test_advance_wraps_at_end);
|
||||||
|
RUN_TEST(test_empty_queue_returns_empty);
|
||||||
|
RUN_TEST(test_favorites_weighting);
|
||||||
|
RUN_TEST(test_nearest_color_black);
|
||||||
|
RUN_TEST(test_nearest_color_white);
|
||||||
|
RUN_TEST(test_nearest_color_red);
|
||||||
|
RUN_TEST(test_nearest_color_green);
|
||||||
|
RUN_TEST(test_nearest_color_blue);
|
||||||
|
RUN_TEST(test_nearest_color_yellow);
|
||||||
|
RUN_TEST(test_dither_solid_black);
|
||||||
|
RUN_TEST(test_dither_solid_white);
|
||||||
|
RUN_TEST(test_dither_produces_valid_indices);
|
||||||
|
RUN_TEST(test_blue_noise_breaks_pattern_regularity);
|
||||||
|
RUN_TEST(test_blue_noise_dither_valid_indices);
|
||||||
|
UNITY_END();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
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