
@@ -621,7 +910,13 @@ document.querySelectorAll('.pwr').forEach(btn => {{
}}
document.querySelectorAll('.pwr').forEach(b => b.disabled = true);
try {{
- const r = await fetch('/api/power/' + action, {{method: 'POST'}});
+ // A boot mode needs the key held *from reset*, which only the server can do:
+ // the firmware samples the keypad in the first milliseconds after reset.
+ const body = btn.dataset.boot ? {{ boot_key: btn.dataset.boot }} : {{}};
+ const r = await fetch('/api/power/' + action, {{
+ method: 'POST',
+ headers: {{ 'Content-Type': 'application/json' }},
+ body: JSON.stringify(body) }});
if (!r.ok) {{
const j = await r.json().catch(() => ({{}}));
document.getElementById('status').textContent =
@@ -635,6 +930,31 @@ document.querySelectorAll('.pwr').forEach(btn => {{
// Restart the stream: the old one ends when the emulator goes away.
const img = document.getElementById('screen');
img.src = '/stream?t=' + Date.now();
+// The screen is a long-lived multipart stream. If the server is restarted underneath
+// it -- which happens whenever a firmware or slot write restarts the emulator -- the
+//
![]()
keeps showing the last frame it received, and then nothing on the page appears
+// to work, because the picture never changes. Reconnect, and fall back to fetching
+// single frames if the stream will not come back.
+let streamRetries = 0;
+const screenEl = document.getElementById('screen');
+function connectStream() {{
+ screenEl.src = '/stream?t=' + Date.now();
+}}
+screenEl.addEventListener('error', () => {{
+ streamRetries += 1;
+ if (streamRetries <= 2) {{
+ setTimeout(connectStream, 1000);
+ }} else {{
+ // Single frames: one plain request each, which always recovers.
+ setInterval(() => {{ screenEl.src = '/frame.png?t=' + Date.now(); }}, 250);
+ }}
+}});
+setInterval(() => {{
+ // A stream that is connected but silent (a stopped guest) still counts as up.
+ // Restarting after every power action is already handled above; this only covers
+ // the server having been replaced, which shows up as a request that never lands.
+ if (screenEl.complete && screenEl.naturalWidth === 0) connectStream();
+}}, 5000);
}}
}});
}});
@@ -643,6 +963,15 @@ function showSpeaker(on) {{
document.getElementById('speaker').classList.toggle('on', !!on);
}}
+function showPanel(panel) {{
+ const el = document.getElementById('panel');
+ if (!panel) {{ el.textContent = ''; return; }}
+ const bits = ['CTR ' + panel.contrast];
+ if (panel.invert) bits.push('INV');
+ if (!panel.display) bits.push('PANEL OFF');
+ el.textContent = bits.join(' · ');
+}}
+
function showPower(powered) {{
const label = document.getElementById('powerstate');
label.textContent = powered ? 'on' : 'off';
@@ -657,12 +986,14 @@ async function poll() {{
const s = await r.json();
showPower(!!s.powered);
showSpeaker(s.speaker);
+ showPanel(s.panel);
document.getElementById('status').textContent =
s.powered ? ('guest: ' + (s.status || 'unknown'))
: 'powered off -- press On to boot';
}} catch (err) {{
showPower(false);
showSpeaker(false);
+ showPanel(null);
document.getElementById('status').textContent = 'server unreachable';
}}
}}
@@ -710,6 +1041,125 @@ async function pollLogs() {{
}}
pollLogs();
setInterval(pollLogs, 2000);
+
+// Firmware slots. These are the multi-system firmware's own slots in the
+// external flash (App/driver/mb_flash.h): slot 0 is the backup of the running
+// image, 1..4 are the switchable ones. Writing one rewrites the flash image and
+// restarts the emulator, because the image is chosen when it is spawned.
+async function loadSlots() {{
+ const tb = document.querySelector('#slottable tbody');
+ const state = document.getElementById('flashstate');
+ try {{
+ const j = await (await fetch('/api/slots')).json();
+ if (j.error) {{ state.textContent = j.error; return; }}
+ const base = j.name || j.image || 'flash image';
+ state.textContent = base + ' (' + Math.round(j.size / 1024) + ' KiB)';
+ tb.innerHTML = '';
+ for (const s of j.slots) {{
+ const tr = document.createElement('tr');
+ const label = s.empty ? '
empty'
+ : (s.name || '?') + ' ' + (s.fw_version || '');
+ const size = s.empty ? ''
+ : s.image_size + ' B' + (s.crc_ok ? '' : ' CRC MISMATCH');
+ tr.innerHTML = '
slot ' + s.slot +
+ (s.slot === 0 ? ' (Main)' : '') + ' | ' + label +
+ ' | ' + size + ' | | ';
+ const td = tr.lastElementChild;
+ const inp = document.createElement('input');
+ inp.type = 'file';
+ inp.accept = '.bin';
+ inp.addEventListener('change', async () => {{
+ const f = inp.files[0];
+ if (!f) return;
+ tr.querySelectorAll('input,button').forEach(b => b.disabled = true);
+ const r = await fetch('/api/slots/' + s.slot + '?name=' +
+ encodeURIComponent(f.name),
+ {{ method: 'POST', body: f }});
+ const jj = await r.json();
+ if (jj.error) alert('slot ' + s.slot + ': ' + jj.error);
+ await loadSlots(); poll();
+ }});
+ const er = document.createElement('button');
+ er.textContent = 'Erase';
+ er.addEventListener('click', async () => {{
+ if (!confirm('Erase slot ' + s.slot + '?')) return;
+ tr.querySelectorAll('input,button').forEach(b => b.disabled = true);
+ const r = await fetch('/api/slots/' + s.slot + '/erase',
+ {{ method: 'POST' }});
+ const jj = await r.json();
+ if (jj.error) alert('slot ' + s.slot + ': ' + jj.error);
+ await loadSlots(); poll();
+ }});
+ td.append(inp, er);
+ tb.appendChild(tr);
+ }}
+ }} catch (err) {{ state.textContent = 'slots unavailable: ' + err; }}
+}}
+const flashInput = document.getElementById('flashfile');
+if (flashInput) flashInput.addEventListener('change', async () => {{
+ const f = flashInput.files[0];
+ if (!f) return;
+ if (!confirm('Use ' + f.name + ' as the external flash image? Slots, settings',
+ ' and calibration come from it.')) return;
+ const r = await fetch('/api/flash?name=' + encodeURIComponent(f.name),
+ {{ method: 'POST', body: f }});
+ const j = await r.json();
+ if (j.error) alert(j.error);
+ loadSlots(); poll();
+}});
+loadSlots();
+setInterval(loadSlots, 15000);
+// Firmware upload. The file *is* the request body, so the server reads the
+// vector table itself and decides the load offset: an application image and a
+// full-flash image need different ones, and the wrong one fails silently.
+const fwstate = document.getElementById('fwstate');
+const fwfile = document.getElementById('fwfile');
+async function loadFirmware(file) {{
+ fwstate.textContent = 'uploading ' + file.name + ' ...';
+ try {{
+ const res = await fetch('/api/firmware?name=' + encodeURIComponent(file.name), {{
+ method: 'POST',
+ headers: {{ 'Content-Type': 'application/octet-stream' }},
+ body: file }});
+ const info = await res.json();
+ if (!res.ok) {{ fwstate.textContent = info.error || 'upload failed'; return; }}
+ fwstate.textContent = fwLabel(info.firmware) +
+ (info.restarted ? ' - rebooting' : ' - press On');
+ poll();
+ }} catch (err) {{
+ fwstate.textContent = 'upload failed: ' + err;
+ }}
+}}
+if (fwfile) fwfile.addEventListener('change', () => {{
+ if (fwfile.files && fwfile.files[0]) loadFirmware(fwfile.files[0]);
+}});
+document.addEventListener('dragover', (e) => e.preventDefault());
+document.addEventListener('drop', (e) => {{
+ e.preventDefault();
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+ if (f) loadFirmware(f);
+}});
+// Say when a build has no multi-system boot menu: the Multiboot button then does
+// nothing at all, which reads as the emulator being broken rather than the
+// firmware not containing that menu.
+function fwLabel(fw) {{
+ let s = fw.name + ' (' + fw.kind + ')';
+ if (fw.multiboot === false) s += ' - no multi-system menu';
+ return s;
+}}
+async function pollFirmware() {{
+ try {{
+ const r = await fetch('/api/firmware');
+ const info = await r.json();
+ if (info.firmware) {{
+ fwstate.textContent = fwLabel(info.firmware);
+ }} else {{
+ fwstate.textContent = 'none loaded - drop a .bin here';
+ }}
+ }} catch (err) {{ /* the rest of the page works without this */ }}
+}}
+pollFirmware();
+