Files
immich-frame/data/app.js
cottongin 7fa74c03c1 feat: add deep sleep batch caching, wake log, and log viewer tools
- Batch-fetch asset IDs in deep sleep mode with NVS caching to reduce
  network calls and improve battery life (configurable batch size 1-50)
- Add wake_log module: persistent CSV log of each photo cycle surviving
  reboots and deep sleep, with download/clear via web UI
- Add browser-based log viewer and log comparison tools (tools/)
- Update .gitignore to exclude .cursor/, chat-summaries/, wake_*.csv,
  and .DS_Store

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:59:03 -04:00

334 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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');