Root cause: Panel_ED2208's epd_quality mode applies _dither_row_rgb_pair (diagonal bias pattern with dither=140) during _exec_transfer(). Since our ImagePipeline already does proper Floyd-Steinberg dithering, the driver's additional dithering was creating visible diagonal artifacts. Fix: setEpdMode(epd_fastest) selects _dither_row_none (clean nearest-color lookup, no spatial bias). On Panel_ED2208 this only affects the dither function — NOT refresh quality or waveform. Also: default dither_noise setting to OFF since error scatter was targeting the wrong layer (it made gradient areas fuzzier without fixing the real problem in the display driver). Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
13 KiB
JavaScript
280 lines
13 KiB
JavaScript
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 intervals = [1, 5, 15, 30, 60];
|
|
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted',
|
|
'Weighted Recent', 'Weighted Oldest'];
|
|
el.innerHTML = `
|
|
<div class="card">
|
|
<h2>Interval</h2>
|
|
<div class="preset-btns">
|
|
${intervals.map(i => `<button class="${settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
|
|
</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="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('saveSlideshow').onclick = async () => {
|
|
const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5');
|
|
const mode = parseInt(document.getElementById('cycleMode').value);
|
|
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) });
|
|
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>
|
|
</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),
|
|
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 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>`;
|
|
|
|
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';
|
|
}
|
|
};
|
|
}
|
|
|
|
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');
|