mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
Adds a QEMU machine for the Puya PY32F071 (Cortex-M0+) so Quansheng UV-K5 V3 firmware can run on a PC. The firmware boots to its main loop in about five seconds and the LCD contents are readable. Register layouts come from the vendor CMSIS header shipped with the firmware rather than guesswork. Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1 and the PY25Q16 flash; everything else answers through a logging catch-all, which is how the next thing worth modelling gets identified. Seven things had to be right before it would boot, each found by watching where the firmware stopped: flash aliased at the application offset, clock ready bits, self-clearing ADC calibration, SPI transfer flags, DMA-driven flash reads, SysTick poll acceleration, and the bit-banged transceiver bus idling low. SysTick needs explanation. SYSTICK_DelayUs polls the counter and accumulates differences; under emulation a register read costs far more relative to guest time, so a measured 120 ms delay would have taken about 7.7 hours. Lowering the clock does not help because the bottleneck is loop iterations, not counter speed. Reporting a value that runs ahead of the real counter does, via a new poll-boost property on SysTick. Guest time therefore runs fast during delays: fine for exercising menus and control flow, wrong for judging signal timing. Also includes the host build of the CW timing chain (harness, stubs, shim, tests), which compiles app/cwkeyer.c and app/cwmacro.c unmodified against stub drivers with a virtual clock and scripted paddle input. Known gap: keypad rows reach the firmware's scan and KEYBOARD_Poll returns the right key code, but the UI does not react yet. Not modelled, and not intended to be: radio behaviour. The transceiver chip has no public datasheet, so keying envelopes and emissions need real hardware.
122 lines
4.0 KiB
Python
122 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Press keys on the emulated radio through its QMP socket.
|
|
|
|
The keypad model exposes a "press" property: writing a key name holds that key,
|
|
writing an empty string releases it. The firmware debounces over several 10 ms
|
|
polls, so a press has to be held for a while to register -- see HOLD_MS.
|
|
|
|
Usage:
|
|
key.py MENU # one short press
|
|
key.py MENU UP UP EXIT # a sequence
|
|
key.py --long F # long press
|
|
key.py --list # show key names
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import socket
|
|
import sys
|
|
import time
|
|
|
|
QMP_SOCKET = "/tmp/uvk5-qmp.sock"
|
|
KEYPAD_PATH = "/machine/keypad"
|
|
|
|
# App/app/app.c debounces with key_debounce_10ms = 2 and treats
|
|
# key_repeat_delay_10ms = 40 as a long press. Guest time runs fast under
|
|
# emulation, so these are generous rather than exact.
|
|
# Guest time runs fast under emulation (SysTick reads are accelerated so busy-wait
|
|
# delays converge), so a press has to be held far longer in wall-clock terms than
|
|
# on real hardware for the firmware's debounce to complete. Measured: 400 ms was
|
|
# too short to register at all.
|
|
HOLD_MS = 2500
|
|
LONG_HOLD_MS = 6000
|
|
GAP_MS = 1200
|
|
|
|
KEYS = [
|
|
"MENU", "UP", "DOWN", "EXIT", "F", "STAR",
|
|
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
|
"SIDE1", "SIDE2",
|
|
]
|
|
|
|
|
|
class Qmp:
|
|
"""Minimal QMP client: connect, negotiate, send commands."""
|
|
|
|
def __init__(self, path: str):
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
self.sock.connect(path)
|
|
except (FileNotFoundError, ConnectionRefusedError) as exc:
|
|
raise SystemExit(
|
|
f"cannot reach the emulator at {path}: {exc}\n"
|
|
"Start it with sim/tools/run.sh first."
|
|
) from exc
|
|
self.buf = b""
|
|
self._read_json() # greeting
|
|
self.command("qmp_capabilities")
|
|
|
|
def _read_json(self) -> dict:
|
|
while b"\n" not in self.buf:
|
|
chunk = self.sock.recv(4096)
|
|
if not chunk:
|
|
raise SystemExit("emulator closed the QMP connection")
|
|
self.buf += chunk
|
|
line, self.buf = self.buf.split(b"\n", 1)
|
|
return json.loads(line)
|
|
|
|
def command(self, name: str, **args) -> dict:
|
|
payload = {"execute": name}
|
|
if args:
|
|
payload["arguments"] = args
|
|
self.sock.sendall(json.dumps(payload).encode() + b"\n")
|
|
|
|
while True:
|
|
msg = self._read_json()
|
|
if "error" in msg:
|
|
raise SystemExit(f"QMP error: {msg['error'].get('desc', msg['error'])}")
|
|
if "return" in msg:
|
|
return msg["return"]
|
|
# Events (RESET, STOP, ...) arrive interleaved; keep reading.
|
|
|
|
def set_key(self, value: str) -> None:
|
|
self.command("qom-set", path=KEYPAD_PATH, property="press", value=value)
|
|
|
|
|
|
def press(qmp: Qmp, key: str, hold_ms: int) -> None:
|
|
qmp.set_key(key)
|
|
time.sleep(hold_ms / 1000)
|
|
qmp.set_key("")
|
|
time.sleep(GAP_MS / 1000)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("keys", nargs="*", help="key names to press in order")
|
|
ap.add_argument("--long", action="store_true", help="hold each key longer")
|
|
ap.add_argument("--hold", type=int, help="hold time in ms, overrides --long")
|
|
ap.add_argument("--list", action="store_true", help="list key names and exit")
|
|
ap.add_argument("--socket", default=QMP_SOCKET)
|
|
args = ap.parse_args()
|
|
|
|
if args.list:
|
|
print(" ".join(KEYS))
|
|
return 0
|
|
if not args.keys:
|
|
ap.error("no keys given (try --list)")
|
|
|
|
unknown = [k for k in args.keys if k.upper() not in KEYS]
|
|
if unknown:
|
|
raise SystemExit(f"unknown key(s): {', '.join(unknown)}\nKnown: {' '.join(KEYS)}")
|
|
|
|
hold = args.hold if args.hold else (LONG_HOLD_MS if args.long else HOLD_MS)
|
|
qmp = Qmp(args.socket)
|
|
|
|
for key in args.keys:
|
|
press(qmp, key.upper(), hold)
|
|
print(f"pressed {key.upper()} ({hold} ms)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|