mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
Add the web remote control: live LCD plus clickable keypad
Flask app on the existing QMP socket. GET / serves the page, GET /stream is a multipart PNG sequence at up to 15 fps, POST /api/key drives the keypad model. The keypad takes discrete down/up rather than a fixed-duration tap, because the firmware reads anything past 400 ms as a held key and dispatches it differently. Letting the browser own the timing is what makes both short and long presses reachable; tap stays available for scripting and is pinned between the 20 ms debounce and the 400 ms hold, with a test asserting that range. The stream only re-encodes when the framebuffer bytes change. The LCD is static most of the time, so idle CPU stays near zero. Unknown key names are rejected with 400 before reaching QMP, so a PTT button cannot be added by accident. The front end releases on pointerleave, pointercancel and blur, and /api/release-all is the safety valve for a key that somehow stays down. Verified against a live emulator over HTTP: /frame.png renders the dual-VFO main screen, a MENU tap opens the menu at 01/79, and two DOWN presses reach 03/79. 19 unit tests, 41 across the whole suite.
This commit is contained in:
1 parent
6d19a33f45
commit
b4f3b28a39
2 files changed
+459
No files matched your search
@@ -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()
|
||||
+298
@@ -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(
|
||||
"<div class='row'>" + "".join(
|
||||
f'<button class="key" data-key="{k}">{"*" if k == "STAR" else k}</button>'
|
||||
for k in row) + "</div>"
|
||||
for row in KEY_GRID
|
||||
)
|
||||
sides = "".join(
|
||||
f'<button class="key side" data-key="{k}">{k}</button>' for k in SIDE_KEYS
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>UV-K5 remote</title>
|
||||
<style>
|
||||
:root {{ color-scheme: dark; }}
|
||||
body {{ margin:0; min-height:100vh; display:grid; place-items:center;
|
||||
background:#15171a; color:#c9d1d9;
|
||||
font:14px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; }}
|
||||
.radio {{ display:flex; flex-direction:column; align-items:center; gap:14px;
|
||||
padding:20px; background:#1e2126; border:1px solid #2d333b;
|
||||
border-radius:14px; }}
|
||||
/* pixelated keeps the 128x64 LCD crisp when scaled up */
|
||||
#screen {{ display:block; image-rendering:pixelated; background:#c8d6b9;
|
||||
border:3px solid #0d1117; border-radius:4px; }}
|
||||
.body {{ display:flex; gap:14px; align-items:flex-start; }}
|
||||
.sides {{ display:flex; flex-direction:column; gap:8px; }}
|
||||
.pad {{ display:flex; flex-direction:column; gap:8px; }}
|
||||
.row {{ display:flex; gap:8px; }}
|
||||
.key {{ width:62px; height:44px; font:inherit; font-weight:600; color:#c9d1d9;
|
||||
background:#2b3138; border:1px solid #3a424b; border-radius:8px;
|
||||
cursor:pointer; user-select:none; -webkit-user-select:none;
|
||||
touch-action:manipulation; }}
|
||||
.key:hover {{ background:#343b44; }}
|
||||
.key.active {{ background:#4b8bf5; border-color:#4b8bf5; color:#fff; }}
|
||||
.side {{ width:76px; }}
|
||||
.hint {{ color:#6e7681; font-size:12px; text-align:center; max-width:430px; }}
|
||||
#status {{ font-size:12px; color:#6e7681; }}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="radio">
|
||||
<img id="screen" src="/stream" alt="radio LCD"
|
||||
width="{128 * scale}" height="{64 * scale}">
|
||||
<div class="body">
|
||||
<div class="sides">{sides}</div>
|
||||
<div class="pad">{grid}</div>
|
||||
</div>
|
||||
<div id="status">connecting...</div>
|
||||
<p class="hint">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.</p>
|
||||
</div>
|
||||
<script>
|
||||
const BINDINGS = {json.dumps(KEY_BINDINGS)};
|
||||
const held = new Set();
|
||||
|
||||
async function send(key, action) {{
|
||||
try {{
|
||||
await fetch('/api/key', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{key: key, action: action}})
|
||||
}});
|
||||
}} catch (err) {{
|
||||
document.getElementById('status').textContent = 'send failed: ' + err;
|
||||
}}
|
||||
}}
|
||||
|
||||
function mark(key, on) {{
|
||||
document.querySelectorAll('[data-key="' + key + '"]')
|
||||
.forEach(el => el.classList.toggle('active', on));
|
||||
}}
|
||||
|
||||
// Press duration is decided by the browser, not the server.
|
||||
function down(key) {{
|
||||
if (held.has(key)) return;
|
||||
held.add(key);
|
||||
mark(key, true);
|
||||
send(key, 'down');
|
||||
}}
|
||||
function up(key) {{
|
||||
if (!held.has(key)) return;
|
||||
held.delete(key);
|
||||
mark(key, false);
|
||||
send(key, 'up');
|
||||
}}
|
||||
|
||||
document.querySelectorAll('.key').forEach(btn => {{
|
||||
const key = btn.dataset.key;
|
||||
btn.addEventListener('pointerdown', ev => {{ ev.preventDefault(); down(key); }});
|
||||
btn.addEventListener('pointerup', ev => {{ ev.preventDefault(); up(key); }});
|
||||
btn.addEventListener('pointerleave', () => up(key));
|
||||
btn.addEventListener('pointercancel', () => up(key));
|
||||
btn.addEventListener('contextmenu', ev => ev.preventDefault());
|
||||
}});
|
||||
|
||||
addEventListener('keydown', ev => {{
|
||||
const key = BINDINGS[ev.code];
|
||||
if (!key || ev.repeat) return;
|
||||
ev.preventDefault();
|
||||
down(key);
|
||||
}});
|
||||
addEventListener('keyup', ev => {{
|
||||
const key = BINDINGS[ev.code];
|
||||
if (!key) return;
|
||||
ev.preventDefault();
|
||||
up(key);
|
||||
}});
|
||||
// Release on blur, so losing focus mid-press cannot leave a key stuck down.
|
||||
addEventListener('blur', () => {{
|
||||
[...held].forEach(up);
|
||||
fetch('/api/release-all', {{method: 'POST'}}).catch(() => {{}});
|
||||
}});
|
||||
|
||||
async function poll() {{
|
||||
try {{
|
||||
const r = await fetch('/api/status');
|
||||
const s = await r.json();
|
||||
document.getElementById('status').textContent =
|
||||
'guest: ' + (s.status || 'unknown');
|
||||
}} catch (err) {{
|
||||
document.getElementById('status').textContent = 'emulator unreachable';
|
||||
}}
|
||||
}}
|
||||
poll();
|
||||
setInterval(poll, 3000);
|
||||
</script>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--qmp", default="/tmp/uvk5-qmp.sock")
|
||||
ap.add_argument("--frame-addr", type=lambda v: int(v, 0), required=True,
|
||||
help="address of gFrameBuffer (moves between builds)")
|
||||
ap.add_argument("--status-addr", type=lambda v: int(v, 0), required=True,
|
||||
help="address of gStatusLine")
|
||||
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)
|
||||
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)")
|
||||
app.run(host=args.host, port=args.port, threaded=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user