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.
This commit is contained in:
mckero committed 2026-08-28 06:09:56 +01:00
1 parent 6a9cf9f28a
commit 5b53cbbc82
2 files changed
+72 -4

No files matched your search

+34
View File
@@ -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()
+38 -4
View File
@@ -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