mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 19:17:24 +00:00
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
241 lines
8.9 KiB
Python
Executable File
241 lines
8.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Check that keypresses reach the firmware and drive the UI.
|
|
|
|
Boots its own QEMU instance on private ports, so it does not disturb a running
|
|
run.sh session. Three checks:
|
|
|
|
1. a short MENU press opens the menu from the main screen
|
|
2. DOWN moves the menu cursor
|
|
3. a short MENU press still works after power save has engaged
|
|
|
|
Why this exists: the keypad was reported broken for a long time and was not. The
|
|
cause was always press duration -- key.py held keys for 2500 ms, which the
|
|
firmware reads as a long press, and the long-press path does not open the menu.
|
|
A later round of "power save blocks the keypad" was the same error plus reading
|
|
gKeyReading0 after releasing the key, when it is always KEY_INVALID.
|
|
|
|
Two rules this test follows, and any manual probing should too:
|
|
|
|
* Never run gdb between presses. Each attach halts the guest and can stretch a
|
|
sequence past the 20 s menu timeout, so the UI falls back to the main screen
|
|
and later presses go somewhere unintended.
|
|
* Read key state while the key is still held, never after release.
|
|
|
|
Usage:
|
|
tools/keypad_test.py # all checks
|
|
tools/keypad_test.py -v # show each step
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
|
|
import uvk5_socket
|
|
import uvk5_testenv
|
|
import re
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
QEMU = uvk5_testenv.qemu()
|
|
ELF = uvk5_testenv.firmware()
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
FLASH = os.path.join(os.path.dirname(HERE), "assets", "flash.img")
|
|
|
|
QMP = uvk5_socket.server_endpoint("qmp")
|
|
GDB_PORT = "1239"
|
|
|
|
# App/misc.c: key_debounce_10ms = 2 (20 ms), key_repeat_delay_10ms = 40 (400 ms).
|
|
# SysTick interrupts run at close to real time, so these are wall-clock values.
|
|
SHORT_MS = 200 # past debounce, well short of a long press
|
|
GAP_MS = 300 # let the release debounce before the next press
|
|
|
|
DISPLAY_MAIN = 0
|
|
DISPLAY_MENU = 1
|
|
KEY_INVALID = 19
|
|
FUNCTION_POWER_SAVE = 5
|
|
|
|
GMENUCURSOR_ADDR = "0x20001c94"
|
|
|
|
|
|
class Emu:
|
|
def __init__(self, verbose=False):
|
|
self.verbose = verbose
|
|
for path in (QMP,):
|
|
if os.path.exists(path):
|
|
os.unlink(path)
|
|
_missing = uvk5_testenv.missing([
|
|
(QEMU, "QEMU binary", "set QEMU=/path/to/qemu-system-arm or put it on PATH"),
|
|
(ELF, "firmware ELF", "run tools/fetch_firmware.py or set ELF=..."),
|
|
(FLASH, "flash image", "run tools/make_flash.py"),
|
|
])
|
|
if _missing:
|
|
print("SKIP: %s" % _missing)
|
|
sys.exit(0)
|
|
|
|
self.proc = subprocess.Popen(
|
|
[QEMU, "-M", f"uv-k5-v3,flash-image={FLASH}", "-nographic",
|
|
"-monitor", "none", "-qmp", QMP,
|
|
"-kernel", ELF, "-gdb", f"tcp::{GDB_PORT}"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
|
|
for _ in range(150):
|
|
try:
|
|
# Connecting is the test: a unix path can be waited for as a file, a
|
|
# TCP endpoint cannot, and this works for both.
|
|
self.sock = uvk5_socket.connect(QMP, timeout=2)
|
|
break
|
|
except OSError:
|
|
if self.proc.poll() is not None:
|
|
sys.exit("QEMU exited during startup")
|
|
time.sleep(0.1)
|
|
else:
|
|
sys.exit(f"QMP never accepted a connection at {QMP}")
|
|
|
|
self.buf = b""
|
|
self._read() # greeting
|
|
self.cmd("qmp_capabilities")
|
|
|
|
def _read(self):
|
|
while b"\n" not in self.buf:
|
|
chunk = self.sock.recv(4096)
|
|
if not chunk:
|
|
sys.exit("QEMU closed the QMP connection")
|
|
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 "error" in msg:
|
|
sys.exit(f"QMP error: {msg['error']}")
|
|
if "return" in msg:
|
|
return msg["return"]
|
|
|
|
def hold(self, key):
|
|
self.cmd("qom-set", path="/machine/keypad", property="press", value=key)
|
|
|
|
def release(self):
|
|
self.cmd("qom-set", path="/machine/keypad", property="press", value="")
|
|
|
|
def press(self, key, hold_ms=SHORT_MS, gap_ms=GAP_MS):
|
|
self.hold(key)
|
|
time.sleep(hold_ms / 1000)
|
|
self.release()
|
|
time.sleep(gap_ms / 1000)
|
|
if self.verbose:
|
|
print(f" pressed {key} ({hold_ms} ms)")
|
|
|
|
def state(self):
|
|
"""Read UI state over gdb. Halts the guest, so never call mid-sequence."""
|
|
exprs = [
|
|
("screen", "*(char*)&gScreenToDisplay"),
|
|
("fn", "*(char*)&gCurrentFunction"),
|
|
("kr0", "*(char*)&gKeyReading0"),
|
|
("cursor", f"*(unsigned char*){GMENUCURSOR_ADDR}"),
|
|
]
|
|
args = [str(uvk5_testenv.gdb()), "-batch", "-ex", "set confirm off",
|
|
"-ex", "set pagination off",
|
|
"-ex", f"target remote :{GDB_PORT}"]
|
|
for name, expr in exprs:
|
|
args += ["-ex", f'printf "{name}=%d\\n", {expr}']
|
|
args += ["-ex", "detach", "-ex", "quit", ELF]
|
|
out = subprocess.run(args, capture_output=True, text=True).stdout
|
|
got = {k: int(v) for k, v in re.findall(r"(\w+)=(-?\d+)", out)}
|
|
if "screen" not in got:
|
|
sys.exit("could not read guest state over gdb")
|
|
return got
|
|
|
|
def close(self):
|
|
self.proc.terminate()
|
|
try:
|
|
self.proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self.proc.kill()
|
|
if os.path.exists(QMP):
|
|
os.unlink(QMP)
|
|
|
|
|
|
def main():
|
|
if uvk5_testenv.gdb() is None:
|
|
return uvk5_testenv.skip("gdb-multiarch is missing; this test reads the guest over "
|
|
"a gdb attach, which has not been ported to the QMP memsave "
|
|
"route the page uses")
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("-v", "--verbose", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
emu = Emu(verbose=args.verbose)
|
|
failures = []
|
|
try:
|
|
# Boot is ~5 s; power save engages ~6 s in and does not block anything,
|
|
# so waiting past it makes the test deterministic rather than racy.
|
|
time.sleep(16)
|
|
|
|
# 1. short MENU press opens the menu, straight out of power save.
|
|
# No state read before the press: a gdb attach immediately beforehand
|
|
# disturbs the guest enough to lose the press.
|
|
emu.press("MENU")
|
|
st = emu.state()
|
|
if st["screen"] == DISPLAY_MENU:
|
|
print("PASS short MENU press opens the menu (also wakes power save)")
|
|
else:
|
|
failures.append(f"MENU did not open the menu (screen={st['screen']})")
|
|
print(f"FAIL MENU did not open the menu (screen={st['screen']})")
|
|
|
|
# 2. DOWN moves the cursor. Presses go out back to back with no gdb
|
|
# between them, then state is read once.
|
|
before = emu.state()["cursor"]
|
|
emu.press("DOWN")
|
|
emu.press("DOWN")
|
|
after = emu.state()
|
|
if after["screen"] != DISPLAY_MENU:
|
|
failures.append("menu closed during DOWN presses")
|
|
print("FAIL menu closed while pressing DOWN")
|
|
elif after["cursor"] == (before + 2):
|
|
print(f"PASS DOWN moves the cursor ({before} -> {after['cursor']})")
|
|
else:
|
|
failures.append(
|
|
f"cursor moved {before} -> {after['cursor']}, expected +2")
|
|
print(f"FAIL cursor {before} -> {after['cursor']}, expected +2")
|
|
|
|
# 3. a held key is visible to the firmware *while held*.
|
|
#
|
|
# Read state mid-hold, then release. Note the read has to come after
|
|
# the hold has already been established and the release must follow
|
|
# it -- do NOT interleave a gdb read between hold and release when the
|
|
# press itself is what you are testing. That attach pauses the guest
|
|
# and can stretch the press past the debounce window, which makes a
|
|
# working build look broken. Here the press outcome is not under test,
|
|
# only whether the scan sees the key, so the read is safe.
|
|
emu.hold("MENU")
|
|
time.sleep(0.5)
|
|
held = emu.state()
|
|
emu.release()
|
|
time.sleep(GAP_MS / 1000)
|
|
if held["kr0"] != KEY_INVALID:
|
|
print(f"PASS held key is seen by the scan (gKeyReading0={held['kr0']})")
|
|
else:
|
|
failures.append("held key not seen by the scan")
|
|
print("FAIL held key not seen (gKeyReading0=KEY_INVALID)")
|
|
finally:
|
|
emu.close()
|
|
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} check(s) failed:")
|
|
for f in failures:
|
|
print(f" - {f}")
|
|
return 1
|
|
print("all keypad checks passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|