- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce network calls and improve battery life (configurable batch size 1-50) - Add wake_log module: persistent CSV log of each photo cycle surviving reboots and deep sleep, with download/clear via web UI - Add browser-based log viewer and log comparison tools (tools/) - Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv, and .DS_Store Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
16 KiB
JavaScript
334 lines
16 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 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');
|