Add an On/Off/Reset bar above the screen

Buttons sit above the LCD as asked, with a state label that turns green when the
guest is up. Powered off dims the panel via a screen-off class, so a dark screen
is the signal rather than a frozen last frame.

Off asks for confirmation: it ends the guest, and a stray click should not do that
silently. Buttons disable while a power action is in flight, since On and Off take
a couple of seconds and a double click would race.

The stream is restarted after every power action. The old multipart response ends
when the emulator goes away, so without a fresh src the image would stay blank
after On.

Refusals are surfaced in the status line rather than swallowed -- that is how the
409 for an adopted emulator becomes visible instead of looking like a dead button.

49 unit tests, and the generated page passes node --check.
This commit is contained in:
mckero committed 2026-08-28 06:02:30 +01:00
1 parent 699561794a
commit 6a9cf9f28a
2 files changed
+81 -2

No files matched your search

+26
View File
@@ -411,5 +411,31 @@ class TestKeyWhenPoweredOff(unittest.TestCase):
self.assertEqual(resp.status_code, 409)
class TestPowerBar(unittest.TestCase):
def setUp(self):
_, http = make_app()
self.body = http.get("/").get_data(as_text=True)
def test_has_power_buttons(self):
for action in ("on", "off", "reset"):
self.assertIn(f'data-power="{action}"', self.body)
def test_power_bar_is_above_the_screen(self):
"""The user asked for it on top."""
self.assertLess(self.body.index('data-power="on"'),
self.body.index('id="screen"'))
def test_power_off_asks_for_confirmation(self):
"""Off kills the emulator; a stray click should not do that silently."""
self.assertIn("confirm(", self.body)
def test_shows_the_powered_state(self):
self.assertIn("powerstate", self.body)
def test_dims_the_screen_when_off(self):
"""A dark screen is the signal that the machine is off."""
self.assertIn("screen-off", self.body)
if __name__ == "__main__":
unittest.main()
+55 -2
View File
@@ -284,9 +284,24 @@ def render_index(scale: int) -> str:
.side {{ width:76px; }}
.hint {{ color:#6e7681; font-size:12px; text-align:center; max-width:430px; }}
#status {{ font-size:12px; color:#6e7681; }}
.powerbar {{ display:flex; gap:8px; align-items:center; align-self:stretch; }}
.pwr {{ padding:6px 14px; font:inherit; color:#c9d1d9; background:#2b3138;
border:1px solid #3a424b; border-radius:6px; cursor:pointer; }}
.pwr:hover:not(:disabled) {{ background:#343b44; }}
.pwr:disabled {{ opacity:0.5; cursor:default; }}
#powerstate {{ font-size:12px; color:#6e7681; margin-left:auto; }}
#powerstate.on {{ color:#3fb950; }}
/* Powered off is a dark panel, not a frozen last frame. */
.screen-off {{ background:#0b0d10 !important; }}
</style>
</head><body>
<div class="radio">
<div class="powerbar">
<button class="pwr" data-power="on">On</button>
<button class="pwr" data-power="off">Off</button>
<button class="pwr" data-power="reset">Reset</button>
<span id="powerstate">-</span>
</div>
<img id="screen" src="/stream" alt="radio LCD"
width="{128 * scale}" height="{64 * scale}">
<div class="body">
@@ -366,14 +381,52 @@ addEventListener('blur', () => {{
fetch('/api/release-all', {{method: 'POST'}}).catch(() => {{}});
}});
document.querySelectorAll('.pwr').forEach(btn => {{
btn.addEventListener('click', async () => {{
const action = btn.dataset.power;
// Off ends the guest. A stray click should not do that silently.
if (action === 'off' &&
!confirm('Power off the emulator? Guest state is lost.')) {{
return;
}}
document.querySelectorAll('.pwr').forEach(b => b.disabled = true);
try {{
const r = await fetch('/api/power/' + action, {{method: 'POST'}});
if (!r.ok) {{
const j = await r.json().catch(() => ({{}}));
document.getElementById('status').textContent =
'power ' + action + ' refused: ' + (j.error || r.status);
}}
}} catch (err) {{
document.getElementById('status').textContent = 'power failed: ' + err;
}} finally {{
document.querySelectorAll('.pwr').forEach(b => b.disabled = false);
poll();
// Restart the stream: the old one ends when the emulator goes away.
const img = document.getElementById('screen');
img.src = '/stream?t=' + Date.now();
}}
}});
}});
function showPower(powered) {{
const label = document.getElementById('powerstate');
label.textContent = powered ? 'on' : 'off';
label.classList.toggle('on', powered);
document.getElementById('screen').classList.toggle('screen-off', !powered);
}}
async function poll() {{
try {{
const r = await fetch('/api/status');
const s = await r.json();
showPower(!!s.powered);
document.getElementById('status').textContent =
'guest: ' + (s.status || 'unknown');
s.powered ? ('guest: ' + (s.status || 'unknown'))
: 'powered off -- press On to boot';
}} catch (err) {{
document.getElementById('status').textContent = 'emulator unreachable';
showPower(false);
document.getElementById('status').textContent = 'server unreachable';
}}
}}
poll();