From 88994b1bd024d1eb5c27837720e2c1d8c0479d10 Mon Sep 17 00:00:00 2001 From: MCKero Date: Sat, 29 Aug 2026 05:16:42 +0100 Subject: [PATCH] Add PTT, which brings the transmit level bar with it PTT was the one input the model never had, so the radio could not be keyed and the mic level bar was unreachable. It is not a matrix key -- GPIO_IsPttPressed reads PB10 directly (driver/gpio.h:31, active low) -- so it gets its own GPIO line rather than a column/row intersection. With it the transmit screen is complete: TX annunciator, a running timer, and a level bar at roughly 80% of scale, fed from REG_64 via BK4819_GetVoiceAmplitudeOut. app/app.c:1700 draws that only while gCurrentFunction == FUNCTION_TRANSMIT with gSetting_mic_bar set; the setting is Data[7] bit 4 at flash 0xA0A8, and blank flash reads 0xFF, so it is already on. Exposed as a boolean on the keypad device and as POST /api/ptt with an explicit held flag, plus a button in the browser UI. Held rather than tapped, because transmitting is a state the operator stays in and a fixed duration would be wrong for it. Releasing is treated as the important half: - pointerleave and pointercancel release, so dragging off the button cannot leave the radio keyed - pagehide releases, so closing the tab cannot either - /api/release-all clears PTT too, since it is outside the matrix and an empty press does not touch it - non-boolean bodies are rejected, so {"held": "false"} cannot key the transmitter by truthiness Two things the test suite caught, both real: The browser wired '.key' handlers over every styled button, and the PTT button carries no data-key, so it would have sent the key "undefined". Narrowed to '.key[data-key]'. StubClient.presses() collected every qom-set regardless of property, so PTT's booleans landed among the key names and presses()[-1] reported False after a release-all. Now filtered by property, with a matching ptts() accessor. tools/test_ptt.py covers the path end to end and asserts the release as well as the press: a PTT that stuck would leave every later test running against a transmitting radio. --- qemu/py32f071.c | 46 +++++++++++ tools/test_ptt.py | 194 ++++++++++++++++++++++++++++++++++++++++++++ tools/test_webui.py | 60 +++++++++++++- tools/webui.py | 75 ++++++++++++++++- 4 files changed, 371 insertions(+), 4 deletions(-) create mode 100755 tools/test_ptt.py diff --git a/qemu/py32f071.c b/qemu/py32f071.c index ec8ba27..51b1bbc 100644 --- a/qemu/py32f071.c +++ b/qemu/py32f071.c @@ -433,6 +433,14 @@ struct UVK5KeypadState { * emitted. See AGENTS.md. */ qemu_irq volatile row_out[KEYPAD_ROWS]; + + /* + * PTT, which is not part of the matrix: GPIO_IsPttPressed reads its own pin + * (PB10, active low), so it needs its own line. volatile for the same reason as + * row_out -- the board fills this in after init, invisibly to the compiler. + */ + qemu_irq volatile ptt_out; + bool ptt; }; /* @@ -494,6 +502,9 @@ static void keypad_reset(DeviceState *dev) { UVK5KeypadState *s = UVK5_KEYPAD(dev); + s->ptt = false; + qemu_set_irq(s->ptt_out, 1); /* released: idle high */ + memset(s->pressed, 0, sizeof(s->pressed)); for (int c = 0; c < KEYPAD_COLS; c++) { s->col_high[c] = true; @@ -517,6 +528,12 @@ static void keypad_init(Object *obj) * keeps -Wdiscarded-qualifiers quiet. */ qdev_init_gpio_out_named(dev, (qemu_irq *)s->row_out, "row", KEYPAD_ROWS); + + /* + * PTT is not part of the matrix. GPIO_IsPttPressed reads its own pin, so it gets + * its own line rather than a column/row intersection. + */ + qdev_init_gpio_out_named(dev, (qemu_irq *)&s->ptt_out, "ptt", 1); } /* @@ -584,6 +601,20 @@ static char *keypad_get_press(Object *obj, Error **errp) return g_strdup(""); } +static bool keypad_get_ptt(Object *obj, Error **errp) +{ + return UVK5_KEYPAD(obj)->ptt; +} + +static void keypad_set_ptt(Object *obj, bool value, Error **errp) +{ + UVK5KeypadState *s = UVK5_KEYPAD(obj); + + s->ptt = value; + /* Active low: pressed pulls the pin down. */ + qemu_set_irq(s->ptt_out, value ? 0 : 1); +} + static void keypad_class_init(ObjectClass *klass, void *data) { DeviceClass *dc = DEVICE_CLASS(klass); @@ -596,6 +627,11 @@ static void keypad_class_init(ObjectClass *klass, void *data) object_class_property_set_description(klass, "press", "hold the named key (MENU, UP, DOWN, EXIT, F, STAR, 0-9, SIDE1, SIDE2); " "empty string releases"); + + object_class_property_add_bool(klass, "ptt", + keypad_get_ptt, keypad_set_ptt); + object_class_property_set_description(klass, "ptt", + "hold the push-to-talk key, which puts the radio into transmit"); } /* ---------------------------------------------------- BK4819 transceiver */ @@ -2562,6 +2598,16 @@ static void uvk5_machine_init(MachineState *machine) KEYPAD_ROW_PIN(r))); } + /* + * PTT on PB10, active low. Not a matrix key: GPIO_IsPttPressed reads the pin + * directly, so it is wired straight to the port. + */ + qdev_connect_gpio_out_named(DEVICE(&s->keypad), "ptt", 0, + qdev_get_gpio_in_named(DEVICE(&s->soc.gpio[1]), + "pin-in", 10)); + /* Released, now that the line exists to carry it. */ + qemu_set_irq(qdev_get_gpio_in_named(DEVICE(&s->soc.gpio[1]), "pin-in", 10), 1); + /* * Drive the initial row levels now that the lines exist. The device reset * ran before wiring, so its qemu_set_irq calls went nowhere; without this diff --git a/tools/test_ptt.py b/tools/test_ptt.py new file mode 100755 index 0000000..e8e586c --- /dev/null +++ b/tools/test_ptt.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Holding PTT must put the radio into transmit, and draw the mic level bar. + +PTT was the one input the model never had. It is not a matrix key -- GPIO_IsPttPressed +reads PB10 directly (driver/gpio.h:31, active low) -- so it needed its own line rather +than a column/row intersection. + +With it, the transmit audio bar becomes reachable. app/app.c:1700 draws it only while +gCurrentFunction == FUNCTION_TRANSMIT and gSetting_mic_bar is set; that setting is +Data[7] bit 4 at flash 0xA0A8 (settings.c:423), and blank flash reads 0xFF, so it is on +by default. The level itself comes from REG_64 via BK4819_GetVoiceAmplitudeOut. + +Checked here: + 1. the radio is not transmitting to begin with + 2. holding PTT reaches FUNCTION_TRANSMIT + 3. the screen changes while transmitting + 4. releasing PTT leaves transmit again + +Point 4 matters as much as the rest: a PTT that sticks would leave the emulated radio +keyed forever, and every later test would run against a transmitting radio. +""" + +import gzip +import json +import os +import pathlib +import socket +import subprocess +import sys +import tempfile +import time + +SIM = pathlib.Path(__file__).resolve().parent.parent +QEMU = pathlib.Path(os.environ.get( + "QEMU", "/root/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")) +ELF = pathlib.Path(os.environ.get( + "ELF", "/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")) +PRISTINE = SIM / "assets/pristine/flash-pristine.img.gz" + +FRAME_ADDR = 0x200013DC +FRAME_BYTES = 1024 +BOOT_SECONDS = 24 + +FUNCTION_TRANSMIT = 1 + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(25) + self.sock.connect(path) + self.buf = b"" + self._read() + self.cmd("qmp_capabilities") + + def _read(self): + while b"\n" not in self.buf: + chunk = self.sock.recv(65536) + if not chunk: + raise RuntimeError("QMP closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def cmd(self, name, **args): + msg = {"execute": name} + if args: + msg["arguments"] = args + self.sock.sendall(json.dumps(msg).encode() + b"\n") + while True: + reply = self._read() + if "return" in reply or "error" in reply: + return reply + + def set_ptt(self, held): + return self.cmd("qom-set", path="/machine/keypad", + property="ptt", value=held) + + def ink(self, tmp): + """Lit pixels in the framebuffer. + + memsave, not pmemsave: the latter takes a physical address and silently + returns zeros here, which looks like a blank screen with no error. + """ + out = pathlib.Path(tmp) / "f.bin" + self.cmd("memsave", val=FRAME_ADDR, size=FRAME_BYTES, filename=str(out)) + return sum(bin(b).count("1") for b in out.read_bytes()) + + def read_u8(self, addr): + """Not available over QMP; callers use gdb for firmware globals.""" + raise NotImplementedError + + +def function_value(elf, port): + """Read gCurrentFunction over gdb. + + Stopping the guest is acceptable here: the question is which state it settled in, + not anything timing-dependent. Key injection would be a different matter. + """ + out = subprocess.run( + ["gdb-multiarch", "-batch", + "-ex", "set confirm off", "-ex", "set pagination off", + "-ex", f"target remote :{port}", + "-ex", 'printf "FN=%d\\n", *(unsigned char*)&gCurrentFunction', + "-ex", "detach", "-ex", "quit", str(elf)], + capture_output=True, text=True, timeout=60) + for line in out.stdout.splitlines(): + if line.startswith("FN="): + return int(line[3:]) + return -1 + + +def main(): + for tool in (QEMU, ELF, PRISTINE): + if not tool.exists(): + print(f"SKIP missing {tool}") + return 0 + + port = 1261 + with tempfile.TemporaryDirectory() as tmp: + img = pathlib.Path(tmp) / "flash.img" + img.write_bytes(gzip.decompress(PRISTINE.read_bytes())) + sock = pathlib.Path(tmp) / "qmp.sock" + + proc = subprocess.Popen( + [str(QEMU), "-M", f"uv-k5-v3,flash-image={img}", + "-nographic", "-monitor", "none", + "-qmp", f"unix:{sock},server=on,wait=off", + "-kernel", str(ELF), "-gdb", f"tcp::{port}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(BOOT_SECONDS * 4): + if sock.exists(): + break + time.sleep(0.25) + else: + print("FAIL QMP socket never appeared") + return 1 + time.sleep(BOOT_SECONDS) + + qmp = Qmp(str(sock)) + failures = 0 + + idle_fn = function_value(ELF, port) + idle_ink = qmp.ink(tmp) + print(f"idle: fn={idle_fn}, {idle_ink} lit pixels") + if idle_fn == FUNCTION_TRANSMIT: + print("FAIL already transmitting before PTT was touched") + failures += 1 + else: + print("PASS not transmitting to begin with") + + qmp.set_ptt(True) + time.sleep(3) + tx_fn = function_value(ELF, port) + tx_ink = qmp.ink(tmp) + print(f"PTT held: fn={tx_fn}, {tx_ink} lit pixels") + + if tx_fn == FUNCTION_TRANSMIT: + print("PASS PTT put the radio into transmit") + else: + print(f"FAIL expected fn={FUNCTION_TRANSMIT}, got {tx_fn}") + failures += 1 + + if tx_ink != idle_ink: + print(f"PASS the display changed ({idle_ink} -> {tx_ink})") + else: + print("FAIL the display did not change while transmitting") + failures += 1 + + qmp.set_ptt(False) + time.sleep(3) + rel_fn = function_value(ELF, port) + print(f"PTT released: fn={rel_fn}") + if rel_fn != FUNCTION_TRANSMIT: + print("PASS releasing PTT left transmit") + else: + print("FAIL still transmitting after release; PTT is stuck") + failures += 1 + + if failures: + return 1 + print("\nPTT keys the radio and releases cleanly") + return 0 + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_webui.py b/tools/test_webui.py index e531584..58cc071 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -25,8 +25,19 @@ class StubClient: 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"] + """The sequence of values written to the keypad press property. + + Filtered by property name, because the keypad device also carries "ptt". An + earlier version collected every qom-set and so mixed PTT's booleans in with the + key names, which made presses()[-1] report False after a release-all. + """ + return [a["value"] for n, a in self.sent + if n == "qom-set" and a.get("property") == "press"] + + def ptts(self): + """The sequence of values written to the keypad ptt property.""" + return [a["value"] for n, a in self.sent + if n == "qom-set" and a.get("property") == "ptt"] def make_app(): @@ -104,6 +115,51 @@ class TestKeyEndpoint(unittest.TestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(self.client.presses()[-1], "") + def test_release_all_also_releases_ptt(self): + """PTT is not in the matrix, so an empty press does not clear it. + + Without this, a client that vanished mid-transmission would leave the emulated + radio keyed indefinitely. + """ + self.http.post("/api/ptt", json={"held": True}) + resp = self.http.post("/api/release-all") + self.assertEqual(resp.status_code, 200) + self.assertEqual(self.client.ptts()[-1], False) + + +class TestPttEndpoint(unittest.TestCase): + def setUp(self): + self.client, self.http = make_app() + + def test_holding_ptt_sets_the_property(self): + resp = self.http.post("/api/ptt", json={"held": True}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()["ptt"], True) + self.assertEqual(self.client.ptts(), [True]) + + def test_releasing_ptt_clears_the_property(self): + self.http.post("/api/ptt", json={"held": True}) + self.http.post("/api/ptt", json={"held": False}) + self.assertEqual(self.client.ptts(), [True, False]) + + def test_non_boolean_is_rejected(self): + """A string "true" must not be taken as held. + + Truthiness would make {"held": "false"} key the transmitter, which is the + wrong way round for the one control that puts a signal on the air. + """ + for value in ("true", 1, None, "yes"): + resp = self.http.post("/api/ptt", json={"held": value}) + self.assertEqual(resp.status_code, 400, f"accepted {value!r}") + self.assertEqual(self.client.ptts(), []) + + def test_ptt_is_logged_with_the_client_ip(self): + self.http.post("/api/ptt", json={"held": True}, + headers={"X-Forwarded-For": "172.21.91.140"}) + entries = self.http.get("/api/logs?since=0").get_json()["entries"] + self.assertTrue(any("PTT down" in e["text"] and e["ip"] == "172.21.91.140" + for e in entries), entries) + class TestStream(unittest.TestCase): def setUp(self): diff --git a/tools/webui.py b/tools/webui.py index 2b0088f..ba79141 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -154,6 +154,18 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, raise LookupError("emulator is off") target.command("qom-set", path=KEYPAD_PATH, property="press", value=value) + def set_ptt(held: bool): + """Hold or release PTT. + + Separate from set_press because PTT is not a matrix key: the firmware reads + PB10 directly, so the model exposes it as its own boolean rather than a name + in the key table. + """ + target = active_client() + if target is None: + raise LookupError("emulator is off") + target.command("qom-set", path=KEYPAD_PATH, property="ptt", value=held) + @app.get("/") def index(): return Response(render_index(scale), mimetype="text/html") @@ -272,11 +284,38 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, return jsonify(error="emulator is off; press On first"), 409 return jsonify(ok=True, key=key, action=action, hold_ms=hold_ms) + @app.post("/api/ptt") + def api_ptt(): + """Hold or release PTT. + + Explicit down/up rather than a timed tap: transmitting is a state the operator + chooses to stay in, and a fixed duration would be wrong for it. The trade-off + is that a client which never sends the release leaves the radio keyed, so + /api/release-all clears this too. + """ + body = request.get_json(silent=True) or {} + held = body.get("held") + if not isinstance(held, bool): + return jsonify(error="held must be true or false"), 400 + try: + set_ptt(held) + except LookupError: + log.add("key", "PTT ignored: emulator is off", ip=client_ip()) + return jsonify(error="emulator is off; press On first"), 409 + log.add("key", f"PTT {'down' if held else 'up'}", ip=client_ip()) + return jsonify(ok=True, ptt=held) + @app.post("/api/release-all") def api_release_all(): - """Safety valve: an empty press clears every key in the model.""" + """Safety valve: clears every key in the model, PTT included. + + PTT needs releasing explicitly -- it is not part of the key matrix, so an empty + press does not touch it, and a client that vanished mid-transmission would + otherwise leave the radio keyed indefinitely. + """ try: set_press("") + set_ptt(False) except LookupError: return jsonify(error="emulator is off"), 409 return jsonify(ok=True) @@ -344,6 +383,9 @@ def render_index(scale: int) -> str: sides = "".join( f'' for k in SIDE_KEYS ) + # PTT is its own button, not a data-key one: it latches on press and releases on + # let-go rather than sending a measured tap, because transmitting is a state. + sides += '' return f""" @@ -371,6 +413,8 @@ def render_index(scale: int) -> str: .key:hover {{ background:#343b44; }} .key.active {{ background:#4b8bf5; border-color:#4b8bf5; color:#fff; }} .side {{ width:76px; }} + /* Red while keyed, so it is obvious the radio is transmitting. */ + .key.ptt.active {{ background:#da3633; border-color:#da3633; }} .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; }} @@ -478,7 +522,9 @@ function up(key) {{ sendKey(key, Math.max(performance.now() - started, MIN_HOLD_MS)); }} -document.querySelectorAll('.key').forEach(btn => {{ +// '.key[data-key]', not '.key': the PTT button shares the styling but carries no +// data-key, and would otherwise register handlers that send the key "undefined". +document.querySelectorAll('.key[data-key]').forEach(btn => {{ const key = btn.dataset.key; btn.addEventListener('pointerdown', ev => {{ ev.preventDefault(); down(key); }}); btn.addEventListener('pointerup', ev => {{ ev.preventDefault(); up(key); }}); @@ -487,6 +533,31 @@ document.querySelectorAll('.key').forEach(btn => {{ btn.addEventListener('contextmenu', ev => ev.preventDefault()); }}); +// PTT latches for as long as the button is held, rather than sending a measured +// duration. Transmitting is a state the operator stays in, so there is nothing to +// measure -- and the release matters more than the press: pointerleave and +// pointercancel are wired up so dragging off the button, or the browser stealing the +// pointer, cannot leave the radio keyed. +const pttBtn = document.getElementById('ptt'); +let pttHeld = false; +function setPtt(held) {{ + if (held === pttHeld) return; + pttHeld = held; + pttBtn.classList.toggle('active', held); + fetch('/api/ptt', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{held: held}}), + }}).catch(() => {{}}); +}} +pttBtn.addEventListener('pointerdown', ev => {{ ev.preventDefault(); setPtt(true); }}); +pttBtn.addEventListener('pointerup', ev => {{ ev.preventDefault(); setPtt(false); }}); +pttBtn.addEventListener('pointerleave', () => setPtt(false)); +pttBtn.addEventListener('pointercancel', () => setPtt(false)); +pttBtn.addEventListener('contextmenu', ev => ev.preventDefault()); +// A closing tab must not leave it transmitting. +addEventListener('pagehide', () => {{ if (pttHeld) setPtt(false); }}); + addEventListener('keydown', ev => {{ const key = BINDINGS[ev.code]; if (!key || ev.repeat) return;