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;