From 6a9cf9f28a9d5da9ccbeb244c4a4798cf1d5caa3 Mon Sep 17 00:00:00 2001 From: MCKero Date: Fri, 28 Aug 2026 06:02:30 +0100 Subject: [PATCH] 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. --- tools/test_webui.py | 26 +++++++++++++++++++++ tools/webui.py | 57 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/tools/test_webui.py b/tools/test_webui.py index 6c29c14..55a8bbb 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -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() diff --git a/tools/webui.py b/tools/webui.py index f1d4d04..c4cf730 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -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; }}
+
+ + + + - +
radio LCD
@@ -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();