Add FastAPI server: pulls from Immich, pre-processes for the panel
Implements the server side of the architecture decided on: the ESP32-C6 has no PSRAM and a tight RAM budget, so all the heavy lifting (JPEG decode, resize, Floyd-Steinberg dithering, 6-color quantization, 4bpp packing) happens here instead of on-device. The frame just does a single GET and streams the response straight to SPI. - GET /frame/image: looks up the current cursor's asset in the configured Immich album, downloads its preview thumbnail, and returns it packed into the panel's exact 800x480/4bpp/2px-per-byte format (application/octet-stream, always exactly 192,000 bytes). - GET / + POST /api/config + GET /api/albums: a small web UI for entering the Immich URL/API key and picking an album, rather than cramming that into the ESP32's captive portal form. - Config (Immich creds, selected album, cursor) persists to a JSON file via a docker-compose volume mount. Verified locally with a venv (Docker isn't available in this environment): unit-tested image_pipeline against a synthetic image (exact byte count, valid panel color codes only), and ran a full end-to-end pass against a mock Immich HTTP server exercising the real /frame/image path. Pinned dependency versions in requirements.txt after hitting a real bug with unpinned floors: the latest starlette (1.3.1) resolved by `pip install fastapi` breaks Jinja2Templates outright. Not yet wired to the ESP32 side (task 6) or authenticated -- /frame/image is unauthenticated for now, fine on a trusted LAN but worth revisiting once the firmware sends a shared device token.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESPresso Frame</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #222; }
|
||||
h1 { font-size: 20px; }
|
||||
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
||||
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
||||
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
button.secondary { background: #6b7280; margin-right: 8px; }
|
||||
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
||||
.status.ok { background: #dcfce7; color: #166534; }
|
||||
.status.err { background: #fee2e2; color: #991b1b; }
|
||||
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ESPresso Frame</h1>
|
||||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||||
|
||||
<form id="config-form">
|
||||
<label>Immich URL
|
||||
<input type="text" id="immich_url" value="{{ cfg.immich_url }}" placeholder="http://192.168.1.10:2283" required>
|
||||
</label>
|
||||
<label>Immich API Key
|
||||
<input type="password" id="immich_api_key" value="{{ cfg.immich_api_key }}" required>
|
||||
</label>
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Order
|
||||
<select id="order">
|
||||
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
|
||||
<script>
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
function showStatus(ok, message) {
|
||||
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const body = new URLSearchParams({
|
||||
immich_url: document.getElementById('immich_url').value,
|
||||
immich_api_key: document.getElementById('immich_api_key').value,
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
});
|
||||
const resp = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||||
try {
|
||||
await saveConfig();
|
||||
|
||||
const resp = await fetch('/api/albums');
|
||||
if (!resp.ok) {
|
||||
throw new Error(await resp.text());
|
||||
}
|
||||
const albums = await resp.json();
|
||||
|
||||
const select = document.getElementById('album_id');
|
||||
select.innerHTML = '';
|
||||
for (const a of albums) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.name} (${a.count})`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user