diff --git a/tools/test_webui.py b/tools/test_webui.py new file mode 100644 index 0000000..6fc40be --- /dev/null +++ b/tools/test_webui.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Unit tests for the web UI. Stubs the QMP client, so no emulator needed.""" +import unittest + +import webui + + +class StubClient: + """Records commands; writes the files memsave would write.""" + + def __init__(self): + self.sent = [] + + def command(self, name, **args): + self.sent.append((name, args)) + if name == "query-status": + return {"status": "running", "running": True} + if name == "memsave": + with open(args["filename"], "wb") as fh: + fh.write(bytes(args["size"])) + return {} + if name == "pmemsave": + raise AssertionError("pmemsave reads physical addresses; use memsave") + return {} + + def presses(self): + """The sequence of values written to the keypad press property.""" + return [a["value"] for n, a in self.sent if n == "qom-set"] + + +def make_app(): + client = StubClient() + app = webui.create_app(client, frame_addr=0x1000, status_addr=0x2000) + app.config.update(TESTING=True) + return client, app.test_client() + + +class TestStatus(unittest.TestCase): + def setUp(self): + self.client, self.http = make_app() + + def test_status_reports_running(self): + resp = self.http.get("/api/status") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()["status"], "running") + + def test_index_serves_html_with_the_screen_and_keypad(self): + resp = self.http.get("/") + self.assertEqual(resp.status_code, 200) + body = resp.get_data(as_text=True) + self.assertIn("/stream", body) + self.assertIn("MENU", body) + + +class TestKeyEndpoint(unittest.TestCase): + def setUp(self): + self.client, self.http = make_app() + + def test_down_holds_the_key(self): + resp = self.http.post("/api/key", json={"key": "MENU", "action": "down"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.client.presses(), ["MENU"]) + + def test_up_releases(self): + self.http.post("/api/key", json={"key": "MENU", "action": "up"}) + self.assertEqual(self.client.presses(), [""]) + + def test_tap_holds_then_releases(self): + resp = self.http.post("/api/key", json={"key": "UP", "action": "tap"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.client.presses(), ["UP", ""]) + + def test_tap_duration_is_a_short_press(self): + """Must land above the 20 ms debounce and below the 400 ms hold.""" + self.assertGreater(webui.TAP_MS, 20) + self.assertLess(webui.TAP_MS, 400) + + def test_writes_the_keypad_press_property(self): + self.http.post("/api/key", json={"key": "MENU", "action": "down"}) + name, args = self.client.sent[-1] + self.assertEqual(name, "qom-set") + self.assertEqual(args["path"], "/machine/keypad") + self.assertEqual(args["property"], "press") + + def test_rejects_unknown_key(self): + resp = self.http.post("/api/key", json={"key": "PTT", "action": "tap"}) + self.assertEqual(resp.status_code, 400) + self.assertEqual(self.client.presses(), []) + + def test_rejects_unknown_action(self): + resp = self.http.post("/api/key", json={"key": "UP", "action": "wiggle"}) + self.assertEqual(resp.status_code, 400) + self.assertEqual(self.client.presses(), []) + + def test_accepts_lowercase_key(self): + resp = self.http.post("/api/key", json={"key": "menu", "action": "down"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.client.presses(), ["MENU"]) + + def test_release_all_clears_every_key(self): + self.http.post("/api/key", json={"key": "MENU", "action": "down"}) + resp = self.http.post("/api/release-all") + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.client.presses()[-1], "") + + +class TestStream(unittest.TestCase): + def setUp(self): + self.client, self.http = make_app() + + def test_stream_is_multipart_and_yields_a_png_part(self): + resp = self.http.get("/stream?limit=1") + self.assertEqual(resp.status_code, 200) + self.assertIn("multipart/x-mixed-replace", resp.headers["Content-Type"]) + body = resp.get_data() + self.assertIn(b"Content-Type: image/png", body) + self.assertIn(b"\x89PNG\r\n\x1a\n", body) + + def test_single_frame_endpoint_returns_a_png(self): + resp = self.http.get("/frame.png") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers["Content-Type"], "image/png") + self.assertTrue(resp.get_data().startswith(b"\x89PNG\r\n\x1a\n")) + + def test_stream_reads_frames_with_memsave(self): + self.http.get("/stream?limit=1") + self.assertIn("memsave", [n for n, _ in self.client.sent]) + + +class TestFrontEnd(unittest.TestCase): + def setUp(self): + _, http = make_app() + self.body = http.get("/").get_data(as_text=True) + + def test_every_model_key_has_a_button(self): + for key in webui.KEYS: + self.assertIn(f'data-key="{key}"', self.body) + + def test_sends_down_and_up_not_just_tap(self): + """Real press duration must come from the browser. + + The firmware distinguishes a short press from a held key at 400 ms, so + the front end has to send the two edges separately rather than asking the + server for a fixed-length tap. + """ + self.assertIn("send(key, 'down')", self.body) + self.assertIn("send(key, 'up')", self.body) + + def test_binds_pointer_and_keyboard_input(self): + self.assertIn("pointerdown", self.body) + self.assertIn("keydown", self.body) + + def test_releases_on_blur_so_keys_cannot_stick(self): + self.assertIn("blur", self.body) + + def test_does_not_offer_ptt(self): + self.assertNotIn('data-key="PTT"', self.body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/webui.py b/tools/webui.py new file mode 100644 index 0000000..7c5591e --- /dev/null +++ b/tools/webui.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Web remote control for the UV-K5 emulator. + +Serves the LCD as a live stream and maps on-screen buttons to the keypad model, +so the emulated radio can be driven from a browser. + +Start the emulator first (tools/run.sh), then: + + python3 tools/webui.py --frame-addr 0x200013DC --status-addr 0x2000175C + +Then open http://127.0.0.1:8080/ + +Two things worth knowing: + + * The QMP socket takes a single client, so tools/key.py cannot talk to the same + emulator while this server is running. + * There is no authentication. It binds loopback and anyone who reaches the port + has full control of the emulated radio. Do not expose it. +""" +import argparse +import json +import time + +from flask import Flask, Response, jsonify, request + +from uvk5_keys import KEYS, is_valid, normalise +from uvk5_lcd import FrameGrabber, encode_png, unpack + +KEYPAD_PATH = "/machine/keypad" + +# Firmware thresholds, from App/misc.c: +# key_debounce_10ms = 2 -> 20 ms to register a press +# key_repeat_delay_10ms = 40 -> 400 ms counts as HELD, a different event +# A tap has to sit between those. Real down/up events from the browser carry +# their own duration, which is why they are preferred over tap. +TAP_MS = 200 + +BOUNDARY = "uvk5frame" +TARGET_FPS = 15 + +# Physical layout of the UV-K5 keypad, for the on-screen grid. +KEY_GRID = [ + ["MENU", "UP", "DOWN", "EXIT"], + ["1", "2", "3", "STAR"], + ["4", "5", "6", "0"], + ["7", "8", "9", "F"], +] +SIDE_KEYS = ["SIDE1", "SIDE2"] + +# Keyboard shortcuts -> radio keys. +KEY_BINDINGS = { + "ArrowUp": "UP", "ArrowDown": "DOWN", + "Enter": "MENU", "Escape": "EXIT", + "KeyF": "F", "KeyM": "MENU", + "BracketLeft": "SIDE1", "BracketRight": "SIDE2", + "Digit0": "0", "Digit1": "1", "Digit2": "2", "Digit3": "3", "Digit4": "4", + "Digit5": "5", "Digit6": "6", "Digit7": "7", "Digit8": "8", "Digit9": "9", +} + + +def create_app(client, frame_addr: int, status_addr: int, scale: int = 4): + app = Flask(__name__) + grabber = FrameGrabber(client, frame_addr, status_addr) + + def set_press(value: str): + client.command("qom-set", path=KEYPAD_PATH, property="press", value=value) + + @app.get("/") + def index(): + return Response(render_index(scale), mimetype="text/html") + + @app.get("/api/status") + def api_status(): + return jsonify(client.command("query-status")) + + @app.post("/api/key") + def api_key(): + body = request.get_json(silent=True) or {} + key = normalise(body.get("key", "")) + action = (body.get("action") or "tap").strip().lower() + + if not is_valid(key): + return jsonify(error=f"unknown key {body.get('key')!r}", + valid=list(KEYS)), 400 + if action not in ("down", "up", "tap"): + return jsonify(error=f"unknown action {action!r}", + valid=["down", "up", "tap"]), 400 + + if action == "down": + set_press(key) + elif action == "up": + set_press("") + else: + set_press(key) + time.sleep(TAP_MS / 1000) + set_press("") + return jsonify(ok=True, key=key, action=action) + + @app.post("/api/release-all") + def api_release_all(): + """Safety valve: an empty press clears every key in the model.""" + set_press("") + return jsonify(ok=True) + + @app.get("/frame.png") + def frame_png(): + return Response(grabber.png(scale), mimetype="image/png", + headers={"Cache-Control": "no-store"}) + + @app.get("/stream") + def stream(): + # limit exists for tests; unset means stream until the client leaves. + limit = request.args.get("limit", type=int) + interval = 1.0 / TARGET_FPS + + def frames(): + sent, last = 0, None + while limit is None or sent < limit: + started = time.monotonic() + status, frame = grabber.raw() + current = (status, frame) + # Only re-encode when the screen actually changed: the LCD is + # static most of the time, so idle CPU stays near zero. + if current != last or limit is not None: + last = current + png = encode_png(unpack(status, frame), scale) + yield (b"--" + BOUNDARY.encode() + b"\r\n" + b"Content-Type: image/png\r\n" + b"Content-Length: " + str(len(png)).encode() + + b"\r\n\r\n" + png + b"\r\n") + sent += 1 + slack = interval - (time.monotonic() - started) + if slack > 0: + time.sleep(slack) + + return Response(frames(), + mimetype=f"multipart/x-mixed-replace; boundary={BOUNDARY}", + headers={"Cache-Control": "no-store", + "X-Accel-Buffering": "no"}) + + return app + + +def render_index(scale: int) -> str: + grid = "\n".join( + "
Hold a key to send a long press: over 400 ms the firmware + treats it as held, which is a different event. Arrows move, Enter is MENU, + Esc is EXIT, digits map straight through. No PTT button -- the keypad model + has no PTT line.
+