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,
|
||||
app0, app, ota_0, 0x10000, 0x680000,
|
||||
app1, app, ota_1, 0x690000, 0x680000,
|
||||
littlefs, data, spiffs, 0xD10000, 0x80000,
|
||||
spiffs, data, spiffs, 0xD10000, 0x80000,
|
||||
coredump, data, coredump, 0xD90000, 0x10000,
|
||||
|
||||
|
@@ -16,12 +16,14 @@ build_flags =
|
||||
-DCORE_DEBUG_LEVEL=3
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DARDUINO_LOOP_STACK_SIZE=32768
|
||||
lib_deps =
|
||||
m5stack/M5Unified @ ^0.2.2
|
||||
m5stack/M5GFX @ ^0.2.5
|
||||
https://github.com/m5stack/M5PM1.git
|
||||
mathieucarbou/ESP Async WebServer @ ^3.0.6
|
||||
bblanchon/ArduinoJson @ ^7.0.0
|
||||
ricmoo/QRCode @ ^0.0.1
|
||||
lib_ignore =
|
||||
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 ---
|
||||
// Buttons
|
||||
#define PIN_BTN_TOP 1 // G1 - Random photo
|
||||
#define PIN_BTN_UP 9 // G9 - Next photo
|
||||
#define PIN_BTN_DOWN 10 // G10 - Play/Pause
|
||||
#define PIN_BTN_UP 10 // G10 - Next photo
|
||||
#define PIN_BTN_DOWN 9 // G9 - Play/Pause
|
||||
|
||||
// RGB LEDs (NeoPixel)
|
||||
#define PIN_RGB_LED 21
|
||||
@@ -38,6 +38,9 @@
|
||||
#define DEFAULT_META_FLAGS 0x00
|
||||
#define DEFAULT_META_POS 0 // 0=bottom
|
||||
#define DEFAULT_LED_BRIGHTNESS 50
|
||||
#define DEFAULT_PIPELINE_MODE 0 // 0=dynamic, 1=balanced, 2=none
|
||||
#define DEFAULT_DITHER_NOISE 0 // 1=error scatter ON, 0=OFF (driver dither fix makes this unnecessary)
|
||||
#define DEFAULT_TIMER_BATCH 10 // Deep sleep batch cache size (1-50)
|
||||
#define DEFAULT_IMMICH_URL "https://photos.example.com"
|
||||
|
||||
// --- Timing ---
|
||||
@@ -62,23 +65,23 @@
|
||||
#define PORTRAIT_GAP_PX 8
|
||||
#define PORTRAIT_WIDTH 267 // (DISPLAY_HEIGHT * 2) / 3
|
||||
|
||||
// --- Dither Palette (Spectra 6) ---
|
||||
// Initial values — calibrate on real hardware
|
||||
#define PALETTE_BLACK_R 0
|
||||
#define PALETTE_BLACK_G 0
|
||||
#define PALETTE_BLACK_B 0
|
||||
#define PALETTE_WHITE_R 255
|
||||
#define PALETTE_WHITE_G 255
|
||||
#define PALETTE_WHITE_B 255
|
||||
#define PALETTE_RED_R 200
|
||||
#define PALETTE_RED_G 30
|
||||
#define PALETTE_RED_B 30
|
||||
#define PALETTE_GREEN_R 30
|
||||
#define PALETTE_GREEN_G 160
|
||||
#define PALETTE_GREEN_B 30
|
||||
#define PALETTE_BLUE_R 30
|
||||
#define PALETTE_BLUE_G 30
|
||||
#define PALETTE_BLUE_B 200
|
||||
#define PALETTE_YELLOW_R 220
|
||||
#define PALETTE_YELLOW_G 200
|
||||
#define PALETTE_YELLOW_B 30
|
||||
// --- Dither Palette (Spectra 6 calibrated, from epdoptimize "spectra6") ---
|
||||
// Measured display appearance — what the panel actually shows
|
||||
#define PALETTE_BLACK_R 0x1F
|
||||
#define PALETTE_BLACK_G 0x22
|
||||
#define PALETTE_BLACK_B 0x26
|
||||
#define PALETTE_WHITE_R 0xB9
|
||||
#define PALETTE_WHITE_G 0xC7
|
||||
#define PALETTE_WHITE_B 0xC9
|
||||
#define PALETTE_RED_R 0x62
|
||||
#define PALETTE_RED_G 0x20
|
||||
#define PALETTE_RED_B 0x1E
|
||||
#define PALETTE_GREEN_R 0x35
|
||||
#define PALETTE_GREEN_G 0x56
|
||||
#define PALETTE_GREEN_B 0x3A
|
||||
#define PALETTE_BLUE_R 0x23
|
||||
#define PALETTE_BLUE_G 0x3F
|
||||
#define PALETTE_BLUE_B 0x8E
|
||||
#define PALETTE_YELLOW_R 0xC1
|
||||
#define PALETTE_YELLOW_G 0xBB
|
||||
#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 <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 "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() {
|
||||
auto cfg = M5.config();
|
||||
cfg.clear_display = false; // Don't clear/refresh e-ink on init
|
||||
M5.begin(cfg);
|
||||
|
||||
// Prevent any automatic e-ink refreshes until we explicitly call display()
|
||||
M5.Display.setAutoDisplay(false);
|
||||
|
||||
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
|
||||
pinMode(PIN_AUDIO_PWR_EN, OUTPUT);
|
||||
digitalWrite(PIN_AUDIO_PWR_EN, LOW);
|
||||
pinMode(PIN_SPK_EN, OUTPUT);
|
||||
digitalWrite(PIN_SPK_EN, LOW);
|
||||
// Seed system clock from hardware RTC (RX8130CE) for immediate time availability
|
||||
{
|
||||
auto rtcDt = M5.Rtc.getDateTime();
|
||||
struct tm t = {};
|
||||
t.tm_year = rtcDt.date.year - 1900;
|
||||
t.tm_mon = rtcDt.date.month - 1;
|
||||
t.tm_mday = rtcDt.date.date;
|
||||
t.tm_hour = rtcDt.time.hours;
|
||||
t.tm_min = rtcDt.time.minutes;
|
||||
t.tm_sec = rtcDt.time.seconds;
|
||||
time_t rtcTime = mktime(&t);
|
||||
if (rtcTime > 1600000000) { // Plausible (post-2020)
|
||||
struct timeval tv = { .tv_sec = rtcTime, .tv_usec = 0 };
|
||||
settimeofday(&tv, nullptr);
|
||||
Serial.printf("[main] System clock seeded from RTC: %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||
rtcDt.date.year, rtcDt.date.month, rtcDt.date.date,
|
||||
rtcDt.time.hours, rtcDt.time.minutes, rtcDt.time.seconds);
|
||||
} else {
|
||||
Serial.println("[main] RTC has no valid time — will sync from NTP after WiFi");
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[main] Free heap: %d, PSRAM: %d\n",
|
||||
ESP.getFreeHeap(), ESP.getFreePsram());
|
||||
Serial.println("[main] Boot complete (skeleton)");
|
||||
// Initialize core subsystems needed for both wake paths
|
||||
powerManager.begin();
|
||||
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() {
|
||||
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