Files
espresso_frame/server/app/templates/manage.html
T
tfaour 683e3881b1 Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.

Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.

The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.

One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
2026-07-21 23:44:22 -04:00

117 lines
4.0 KiB
HTML

{% extends "base.html" %}
{% block subtitle %}
<p class="sub">{{ frame.name or "Frame" }} &mdash; quick controls</p>
{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Up next</h2>
<p class="sub">Tap "Show next" to move a photo to the front. The frame
picks it up on its next refresh. <a href="/login">Log in</a> for full
settings.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
<div style="display: flex; gap: 8px;">
<button type="button" class="secondary" id="btn-back">&larr; Previous</button>
<button type="button" class="secondary" id="btn-advance">Next &rarr;</button>
</div>
<p class="sub" style="margin-top: 8px;">Changes what the frame shows on
its next wake.</p>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>
const TOKEN = {{ manage_token | tojson }};
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function post(path, body) {
const resp = await fetch(`/api/m/${TOKEN}/${path}`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
async function loadQueue() {
const currentEl = document.getElementById('current-thumb');
const grid = document.getElementById('upcoming-grid');
try {
const resp = await fetch(`/api/m/${TOKEN}/queue`);
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">This frame isn\'t set up yet.</p>';
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
currentEl.appendChild(img);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
grid.innerHTML = '';
for (const item of data.upcoming) {
const card = document.createElement('div');
card.className = 'photo-card';
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
img.draggable = false;
card.appendChild(img);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'show-next';
btn.textContent = 'Show next';
btn.addEventListener('click', async () => {
try {
await post('promote', { asset_id: item.id });
showStatus(true, 'Moved to the front.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
card.appendChild(btn);
grid.appendChild(card);
}
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
document.getElementById('btn-advance').addEventListener('click', async () => {
try { await post('advance'); showStatus(true, 'Advanced.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
document.getElementById('btn-back').addEventListener('click', async () => {
try { await post('back'); showStatus(true, 'Went back.'); loadQueue(); }
catch (e) { showStatus(false, e.message); }
});
loadQueue();
setInterval(loadQueue, 15000);
</script>
{% endblock %}