Files
uv-k5-v3-emulator/tools/keypad_test.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

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())