Files
uv-k5-v3-emulator/tools/test_ptt.py
T
mckero 2667e046e8 Emulator: multiboot slots from the page, flash controller, portable tests
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting

slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
2026-10-01 14:54:34 +08:00

200 lines
7.0 KiB
Python
Executable File

#!/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
import uvk5_socket
import uvk5_testenv
SIM = pathlib.Path(__file__).resolve().parent.parent
QEMU = uvk5_testenv.qemu()
ELF = uvk5_testenv.firmware()
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, endpoint):
# A socket or an endpoint: QEMU's QMP takes one client, so the caller that
# waited for it hands its connection in.
if hasattr(endpoint, "recv"):
self.sock = endpoint
else:
self.sock = uvk5_socket.connect(endpoint, timeout=30)
self.sock.settimeout(25)
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(
[str(uvk5_testenv.gdb()), "-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():
if uvk5_testenv.gdb() is None:
return uvk5_testenv.skip("gdb-multiarch is missing; this test reads firmware "
"globals over a gdb attach, which has not been ported to "
"the QMP memsave route the page uses")
for tool, what in ((QEMU, "QEMU"), (ELF, "firmware"), (PRISTINE, "pristine flash image")):
if tool is None or not tool.exists():
return uvk5_testenv.skip("%s is missing (%s); see the README Quick start"
% (what, tool or "not found"))
port = 1261
with tempfile.TemporaryDirectory() as tmp:
img = pathlib.Path(tmp) / "flash.img"
img.write_bytes(gzip.decompress(PRISTINE.read_bytes()))
sock = uvk5_socket.server_endpoint("qmp", directory=str(tmp))
proc = subprocess.Popen(
[str(QEMU), "-M", f"uv-k5-v3,flash-image={img}",
"-nographic", "-monitor", "none",
"-qmp", sock,
"-kernel", str(ELF), "-gdb", f"tcp::{port}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
# Qmp() below connects, and uvk5_socket retries until QEMU's QMP answers, so
# there is no socket path to wait for -- on Windows there would not be one.
time.sleep(BOOT_SECONDS)
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())