From 5b53cbbc82c3ba43a49e1cff86857507d5c5bb50 Mon Sep 17 00:00:00 2001 From: MCKero Date: Fri, 28 Aug 2026 06:09:56 +0100 Subject: [PATCH] Start powered off; the user presses On The server now owns the QEMU process by default but does not launch it. You open the page to a dark screen and press On, which is the behaviour asked for: like walking up to a machine rather than finding it already booted. This inverts the flag from the plan. Owning the process has to be the default, since it is the only way On/Off can work at all; --attach is the opt-in for joining a run.sh instance, where Off is refused. Two tests guard the intent rather than the wiring: one asserts main() has --attach and not --own-emulator, another asserts main() never calls power_on(), so a future edit cannot quietly restore auto-boot. Verified on the live server with no QEMU running beforehand: startup 0 QEMU processes, powered=false, frame.png 503, page 200 On 1 QEMU process, powered=true, frame.png 200 (2920 bytes) Off 0 QEMU processes, frame.png 503, page still 200, keys 409 On again 1 QEMU process, powered=true, frame.png 200 The web server stays up across Off, which is what you asked for: the screen goes dark and waits for the next person to press On. --- tools/test_webui.py | 34 ++++++++++++++++++++++++++++++++++ tools/webui.py | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/tools/test_webui.py b/tools/test_webui.py index 55a8bbb..53800cd 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -437,5 +437,39 @@ class TestPowerBar(unittest.TestCase): self.assertIn("screen-off", self.body) +class TestStartsPoweredOff(unittest.TestCase): + """The emulator must not be running until the user asks for it.""" + + def test_app_with_no_client_serves_a_dark_screen(self): + app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000) + app.config.update(TESTING=True) + http = app.test_client() + + self.assertEqual(http.get("/").status_code, 200) + self.assertFalse(http.get("/api/status").get_json()["powered"]) + # No frame yet, and that is a state rather than an error. + self.assertEqual(http.get("/frame.png").status_code, 503) + + def test_page_offers_an_on_button_while_off(self): + app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000) + app.config.update(TESTING=True) + body = app.test_client().get("/").get_data(as_text=True) + self.assertIn('data-power="on"', body) + + def test_main_has_an_attach_flag_not_an_own_flag(self): + """Owning the process is the default; attaching is the opt-in.""" + import inspect + src = inspect.getsource(webui.main) + self.assertIn("--attach", src) + self.assertNotIn("--own-emulator", src) + + def test_main_does_not_power_on_at_startup(self): + """Arriving at a dark screen is the point; do not boot it for them.""" + import inspect + src = inspect.getsource(webui.main) + # power_on may only appear under the attach branch, never unconditionally. + self.assertNotIn("supervisor.power_on()", src) + + if __name__ == "__main__": unittest.main() diff --git a/tools/webui.py b/tools/webui.py index c4cf730..11f085d 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -19,6 +19,7 @@ Two things worth knowing: """ import argparse import json +import os import time from flask import Flask, Response, jsonify, request @@ -445,13 +446,46 @@ def main() -> int: ap.add_argument("--host", default="127.0.0.1") ap.add_argument("--port", type=int, default=8080) ap.add_argument("--scale", type=int, default=4) + ap.add_argument("--attach", action="store_true", + help="attach to an emulator started elsewhere (run.sh) " + "instead of managing one. Off is then refused, since " + "this server did not start that process.") + ap.add_argument("--qemu", default=os.path.expanduser( + "~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")) + ap.add_argument("--elf", default=os.path.expanduser( + "~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")) + ap.add_argument("--flash", default=os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "assets", "flash.img")) + ap.add_argument("--gdb-port", type=int, default=1234) args = ap.parse_args() from uvk5_qmp import QmpClient - client = QmpClient(args.qmp) - app = create_app(client, args.frame_addr, args.status_addr, args.scale) - print(f"serving on http://{args.host}:{args.port}/ " - f"(no authentication; loopback only unless you changed --host)") + from uvk5_supervisor import Supervisor, default_launcher, wait_for_socket + + def connect(): + if not wait_for_socket(args.qmp, timeout=15): + raise RuntimeError(f"QMP socket never appeared at {args.qmp}") + return QmpClient(args.qmp) + + supervisor = Supervisor( + launch=default_launcher(args.qemu, args.flash, args.elf, args.qmp, + gdb_port=args.gdb_port), + connect=connect) + + if args.attach: + # Someone else owns the process; adopt it so the screen works, but Off + # will refuse. + supervisor.adopt(connect()) + # Otherwise the emulator stays OFF on purpose. The user presses On, so the + # page behaves like walking up to a machine rather than finding it booted. + + app = create_app(supervisor.client(), args.frame_addr, args.status_addr, + args.scale, supervisor=supervisor) + print(f"serving on http://{args.host}:{args.port}/") + print("attached to a running emulator" if args.attach + else "emulator is OFF; press On in the browser to boot it") + print("no authentication: anyone who can reach this port controls the radio") app.run(host=args.host, port=args.port, threaded=True) return 0