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.
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Check whether a held key actually pulls a GPIOB row line low.
|
|
|
|
Holds a key over QMP, then reads GPIOB's input data register through the GDB
|
|
stub. The row pins are 15..12; with a key held and its column pulled low, the
|
|
matching row bit must read 0.
|
|
|
|
This isolates two failure modes that look identical from the firmware's side:
|
|
the keypad model not registering the press, and the row lines not reaching the
|
|
GPIO port.
|
|
|
|
Usage: gpio_watch.py [KEY]
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
QMP_SOCKET = "/tmp/uvk5-qmp.sock"
|
|
GPIOB_BASE = 0x50000400
|
|
GPIO_IDR = 0x10
|
|
GPIO_ODR = 0x14
|
|
ELF = "/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf"
|
|
|
|
|
|
class Qmp:
|
|
def __init__(self, path):
|
|
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
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(4096)
|
|
if not chunk:
|
|
raise SystemExit("QMP closed")
|
|
self.buf += chunk
|
|
line, self.buf = self.buf.split(b"\n", 1)
|
|
return json.loads(line)
|
|
|
|
def cmd(self, name, **args):
|
|
payload = {"execute": name}
|
|
if args:
|
|
payload["arguments"] = args
|
|
self.sock.sendall(json.dumps(payload).encode() + b"\n")
|
|
while True:
|
|
msg = self._read()
|
|
if "return" in msg:
|
|
return msg["return"]
|
|
if "error" in msg:
|
|
raise SystemExit("QMP error: " + msg["error"].get("desc", "?"))
|
|
|
|
def press(self, key):
|
|
self.cmd("qom-set", path="/machine/keypad", property="press", value=key)
|
|
|
|
|
|
def read_words(addresses):
|
|
"""Reads several 32-bit words through the GDB stub in one session."""
|
|
lines = ["set confirm off", "set pagination off", "target remote :1234"]
|
|
lines += [f"x/1xw {a:#x}" for a in addresses]
|
|
lines += ["detach", "quit", ""]
|
|
|
|
with tempfile.NamedTemporaryFile("w", suffix=".gdb", delete=False) as fh:
|
|
fh.write("\n".join(lines))
|
|
script = fh.name
|
|
|
|
out = subprocess.run(["gdb-multiarch", "-batch", "-x", script, ELF],
|
|
capture_output=True, text=True, timeout=60).stdout
|
|
# gdb prints "0x50000410 <optional symbol>:\t0xffff". Match the address at
|
|
# line start and the first hex value after the colon; an earlier pattern that
|
|
# required no colon before the value silently matched nothing and every read
|
|
# came back as zero.
|
|
values = {}
|
|
for match in re.finditer(r"^(0x[0-9a-fA-F]+)[^:\n]*:\s*(0x[0-9a-fA-F]+)", out, re.M):
|
|
values[int(match.group(1), 16)] = int(match.group(2), 16)
|
|
return values
|
|
|
|
|
|
def describe(idr, odr):
|
|
rows = [(15 - r, r) for r in range(4)]
|
|
cols = [(6 - (c - 1), c) for c in range(1, 5)]
|
|
row_txt = " ".join(f"row{r}(p{p})={'LOW' if not (idr >> p) & 1 else 'high'}"
|
|
for p, r in rows)
|
|
col_txt = " ".join(f"col{c}(p{p})={'LOW' if not (odr >> p) & 1 else 'high'}"
|
|
for p, c in cols)
|
|
return row_txt, col_txt
|
|
|
|
|
|
def main():
|
|
key = sys.argv[1] if len(sys.argv) > 1 else "MENU"
|
|
qmp = Qmp(QMP_SOCKET)
|
|
|
|
qmp.press("")
|
|
time.sleep(0.2)
|
|
base = read_words([GPIOB_BASE + GPIO_IDR, GPIOB_BASE + GPIO_ODR])
|
|
idr0 = base.get(GPIOB_BASE + GPIO_IDR, 0)
|
|
odr0 = base.get(GPIOB_BASE + GPIO_ODR, 0)
|
|
|
|
qmp.press(key)
|
|
time.sleep(0.2)
|
|
held = read_words([GPIOB_BASE + GPIO_IDR, GPIOB_BASE + GPIO_ODR])
|
|
idr1 = held.get(GPIOB_BASE + GPIO_IDR, 0)
|
|
odr1 = held.get(GPIOB_BASE + GPIO_ODR, 0)
|
|
qmp.press("")
|
|
|
|
print(f"released: IDR={idr0:#06x} ODR={odr0:#06x}")
|
|
print(f" {describe(idr0, odr0)[0]}")
|
|
print(f"held {key}: IDR={idr1:#06x} ODR={odr1:#06x}")
|
|
print(f" {describe(idr1, odr1)[0]}")
|
|
print(f" {describe(idr1, odr1)[1]}")
|
|
|
|
if idr0 == idr1:
|
|
print("\nno change in IDR: the row lines are not reaching the GPIO port, "
|
|
"or the scan had every column high at sample time")
|
|
else:
|
|
print(f"\nIDR changed (bits {idr0 ^ idr1:#06x}) -- the matrix is wired through")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|