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.
117 lines
4.0 KiB
HTML
117 lines
4.0 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block subtitle %}
|
|
<p class="sub">{{ frame.name or "Frame" }} — 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">← Previous</button>
|
|
<button type="button" class="secondary" id="btn-advance">Next →</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 %}
|