Files
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

198 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""The battery level must follow the ADC, including the low-battery warning.
Written after an honest audit of what the emulator actually reproduces. The ADC was
modelled but returned a hardcoded 2200 forever, so an entire firmware behaviour --
gBatteryDisplayLevel, gLowBattery, and the warning popup -- was unreachable. A
peripheral that answers reads is not the same as a peripheral that is reproduced.
Checked here:
1. a mid-scale reading gives a normal, non-zero battery level
2. a low reading drops that level
3. a low reading raises gLowBattery
4. raising the reading again clears it
Point 4 matters: a latching flag that never clears would pass 1-3 and still be wrong.
"""
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"
BOOT_SECONDS = 24
ADC_PATH = "/machine/soc/adc"
# The firmware samples the battery on a timer, so a change needs a few seconds to be
# picked up and turned into a level.
SETTLE = 6
class Qmp:
def __init__(self, path):
self.s = uvk5_socket.connect(path, timeout=25)
self.buf = b""
self._read()
self.cmd("qmp_capabilities")
def _read(self):
while b"\n" not in self.buf:
chunk = self.s.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.s.sendall(json.dumps(msg).encode() + b"\n")
while True:
reply = self._read()
if "return" in reply or "error" in reply:
return reply
def set_adc(self, value):
return self.cmd("qom-set", path=ADC_PATH, property="adc-result",
value=value)
def firmware_state(port):
"""gBatteryDisplayLevel and gLowBattery, over gdb.
Stopping the guest is fine here: the question is which state it settled in, not
anything timing-dependent.
"""
out = subprocess.run(
[str(uvk5_testenv.gdb()), "-batch",
"-ex", "set confirm off", "-ex", "set pagination off",
"-ex", f"target remote :{port}",
"-ex", 'printf "LEVEL=%d LOW=%d\\n",'
' *(unsigned char*)&gBatteryDisplayLevel,'
' *(unsigned char*)&gLowBattery',
"-ex", "detach", "-ex", "quit", str(ELF)],
capture_output=True, text=True, timeout=90)
for line in out.stdout.splitlines():
if line.startswith("LEVEL="):
parts = dict(p.split("=") for p in line.split())
return int(parts["LEVEL"]), int(parts["LOW"])
return None, None
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 = 1262
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
reply = qmp.set_adc(2200)
if "error" in reply:
print(f"FAIL adc-result not settable: {reply['error']}")
return 1
time.sleep(SETTLE)
level_ok, low_ok = firmware_state(port)
print(f"adc=2200 (normal): level={level_ok} low={low_ok}")
if level_ok is None:
print("FAIL could not read the firmware's battery state")
return 1
if low_ok:
print("FAIL a mid-scale reading was treated as low battery")
failures += 1
else:
print("PASS a normal reading is not low battery")
# Well under any sane threshold, but not zero: zero could plausibly be
# special-cased as "no reading".
qmp.set_adc(1200)
time.sleep(SETTLE)
level_low, low_low = firmware_state(port)
print(f"adc=1200 (flat): level={level_low} low={low_low}")
if level_low < level_ok:
print(f"PASS the level followed the ADC down "
f"({level_ok} -> {level_low})")
else:
print(f"FAIL the level did not drop ({level_ok} -> {level_low})")
failures += 1
if low_low:
print("PASS low battery was raised")
else:
print("FAIL a flat battery did not raise gLowBattery")
failures += 1
# Recovery: the level must come back. gLowBattery deliberately is NOT
# asserted to clear here.
#
# helper/battery.c:190-204 only clears gLowBattery when the level lands
# exactly on 2; above that it clears gLowBatteryConfirmed and leaves
# gLowBattery alone. So going 4 -> 0 -> 4 legitimately leaves the flag set,
# and an earlier version of this test called that a failure. It was the
# test that was wrong, not the model -- the emulator reproduces the firmware,
# including behaviour that looks like a bug.
qmp.set_adc(2200)
time.sleep(SETTLE)
level_back, low_again = firmware_state(port)
print(f"adc=2200 (charged): level={level_back} low={low_again}")
if level_back > level_low:
print(f"PASS the level recovered ({level_low} -> {level_back})")
else:
print(f"FAIL the level stayed down ({level_low} -> {level_back})")
failures += 1
if failures:
return 1
print("\nbattery level tracks the ADC")
return 0
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
if __name__ == "__main__":
sys.exit(main())