diff --git a/qemu/py32f071.c b/qemu/py32f071.c index 7d0ff60..9145fd2 100644 --- a/qemu/py32f071.c +++ b/qemu/py32f071.c @@ -1008,8 +1008,24 @@ struct PY25Q16State { uint32_t addr; unsigned phase; /* bytes consumed since the command byte */ bool write_enabled; + + /* + * Writes have to reach the backing file or nothing the firmware saves + * survives: settings, edited frequencies and channel data all live here, and + * on real hardware this is a physical part that keeps its contents with the + * power off. + * + * Flushing on every programmed byte would mean thousands of writes for one + * settings save, so a dirty flag is set here and the image is written out + * when the chip is deselected -- by which point the firmware's driver has + * finished the whole erase-and-program sequence. + */ + bool dirty; + Notifier exit_notifier; }; +static void py25q16_exit_notify(Notifier *n, void *data); + static uint8_t py25q16_xfer(void *opaque, uint8_t out) { PY25Q16State *s = opaque; @@ -1049,6 +1065,7 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out) if (s->write_enabled) { /* NOR can only clear bits without an erase. */ s->data[s->addr % PY25Q16_SIZE] &= out; + s->dirty = true; } s->addr++; return 0xff; @@ -1059,6 +1076,7 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out) if (s->phase == 3 && s->write_enabled) { const uint32_t sector = (s->addr / 0x1000) * 0x1000; memset(s->data + (sector % PY25Q16_SIZE), 0xff, 0x1000); + s->dirty = true; } } return 0xff; @@ -1082,6 +1100,51 @@ static uint8_t py25q16_xfer(void *opaque, uint8_t out) } } +/* + * Write the image back to its file. + * + * Whole-file rather than a partial update: 2 MB is nothing on a host, and the + * alternative means tracking which sectors changed, which is more code and more to + * get wrong for no benefit here. + * + * Via a temporary file and rename so an interrupted flush cannot leave a truncated + * image behind -- the file is the only copy of the radio's settings, and losing it + * to a half-finished write would be worse than not persisting at all. + */ +static void py25q16_flush(PY25Q16State *s) +{ + char *tmp_path; + FILE *fh; + + if (!s->dirty || !s->image_path || !*s->image_path) { + return; + } + + tmp_path = g_strdup_printf("%s.tmp", s->image_path); + fh = fopen(tmp_path, "wb"); + if (!fh) { + warn_report("py25q16: cannot write %s, changes will be lost", tmp_path); + g_free(tmp_path); + return; + } + if (fwrite(s->data, 1, PY25Q16_SIZE, fh) != PY25Q16_SIZE) { + warn_report("py25q16: short write to %s, keeping the previous image", + tmp_path); + fclose(fh); + unlink(tmp_path); + g_free(tmp_path); + return; + } + fclose(fh); + if (rename(tmp_path, s->image_path) != 0) { + warn_report("py25q16: cannot replace %s", s->image_path); + unlink(tmp_path); + } else { + s->dirty = false; + } + g_free(tmp_path); +} + /* Chip select is active low. */ static void py25q16_set_cs(void *opaque, int line, int level) { @@ -1092,6 +1155,12 @@ static void py25q16_set_cs(void *opaque, int line, int level) /* Deselect ends the command. */ s->cmd = PY25Q16_CMD_NONE; s->phase = 0; + /* + * Flush here rather than per byte. The firmware's driver holds CS for a + * whole erase-and-program sequence, so this is once per settings save + * instead of once per programmed byte. + */ + py25q16_flush(s); } s->selected = selected; } @@ -1116,6 +1185,19 @@ static void py25q16_realize(DeviceState *dev, Error **errp) } qdev_init_gpio_in_named(dev, py25q16_set_cs, "cs", 1); + + /* + * Also flush at exit. Deselect covers the normal case, but QMP `quit` -- which + * is what the web UI's power off sends -- can arrive with the chip still + * selected, and the last write would be dropped. + */ + s->exit_notifier.notify = py25q16_exit_notify; + qemu_add_exit_notifier(&s->exit_notifier); +} + +static void py25q16_exit_notify(Notifier *n, void *data) +{ + py25q16_flush(container_of(n, PY25Q16State, exit_notifier)); } static Property py25q16_properties[] = { diff --git a/tools/test_flash_persist.py b/tools/test_flash_persist.py new file mode 100755 index 0000000..8870291 --- /dev/null +++ b/tools/test_flash_persist.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Flash writes must survive a power cycle. + +The SPI NOR model keeps the image in a g_malloc buffer and only ever reads the +backing file, so everything the firmware saves -- settings, edited frequencies, +channel data -- disappears when the QEMU process exits. On real hardware that is a +physical part which holds its contents with the power off. + +The check is deliberately blunt: boot, let the firmware run long enough to write +its settings, power off, and compare the image on disk. The firmware writes to +EEPROM during boot on its own (SETTINGS_InitEEPROM and the power-on save path), so +no UI navigation and no guest-side poking is needed. + +Driving the guest from gdb was tried and abandoned: `call` into firmware functions +hangs, because the main loop is running and the called function waits on hardware +the debugger has effectively frozen. Observing the file is both simpler and closer +to the behaviour the user actually sees. + +Boots its own emulator on a private socket and works on a copy of the image, so it +cannot disturb a running session or damage assets/flash.img. + +Run: python3 tools/test_flash_persist.py +""" +import hashlib +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +SIM = os.path.dirname(HERE) +QEMU = os.path.expanduser("~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm") +ELF = os.path.expanduser("~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf") +SOURCE_IMAGE = os.path.join(SIM, "assets", "flash.img") + +QMP = "/tmp/uvk5-persist-test.sock" + +# Flash offsets the firmware demonstrably writes during a boot, measured rather than +# guessed. The mapping is in App/driver/eeprom_compat.c: these are *flash* addresses, +# not the EEPROM addresses the settings code uses, and an earlier version of this test +# watched EEPROM offsets by mistake and reported "same" for everything. +WATCH = [ + ("mr/vfo attrs", 0x008100, 0x20), # 1024 MR + 7 VFO attributes, 2 bytes each + ("settings", 0x00A100, 0x20), # the settings block +] + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(25) + self.sock.connect(path) + self.buf = b"" + self._readline() + self.command("qmp_capabilities") + + def _readline(self): + while b"\n" not in self.buf: + chunk = self.sock.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 command(self, name, **args): + msg = {"execute": name} + if args: + msg["arguments"] = args + self.sock.sendall(json.dumps(msg).encode() + b"\n") + while True: + reply = self._readline() + if "return" in reply: + return reply["return"] + if "error" in reply: + raise RuntimeError(reply["error"]) + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +def boot(image): + if os.path.exists(QMP): + os.unlink(QMP) + proc = subprocess.Popen( + [QEMU, "-M", f"uv-k5-v3,flash-image={image}", "-nographic", + "-monitor", "none", "-qmp", f"unix:{QMP},server=on,wait=off", + "-kernel", ELF], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.time() + 25 + while time.time() < deadline: + if os.path.exists(QMP): + return proc, Qmp(QMP) + time.sleep(0.1) + proc.kill() + raise RuntimeError("QMP socket never appeared") + + +def shutdown(proc, qmp): + """Quit through QMP, which is exactly what the web UI's power off does.""" + try: + qmp.command("quit") + except Exception: + pass # the socket usually drops first + qmp.close() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + if os.path.exists(QMP): + os.unlink(QMP) + + +def sha(path): + return hashlib.sha256(open(path, "rb").read()).hexdigest() + + +def snapshot(path): + data = open(path, "rb").read() + return {name: data[off:off + length] for name, off, length in WATCH} + + +def main(): + for path, what in ((QEMU, "QEMU"), (ELF, "firmware"), + (SOURCE_IMAGE, "flash image")): + if not os.path.exists(path): + sys.exit(f"missing {what}: {path}") + + workdir = tempfile.mkdtemp(prefix="uvk5-persist-") + image = os.path.join(workdir, "flash.img") + shutil.copy(SOURCE_IMAGE, image) + failures = [] + + try: + before_sha = sha(image) + before = snapshot(image) + print(f"image sha before boot: {before_sha[:16]}") + for name, _, _ in WATCH: + print(f" {name:9s} {before[name][:12].hex()}") + + print("\nbooting, letting the firmware settle, then powering off") + proc, qmp = boot(image) + try: + time.sleep(20) # main loop plus a settings write + status = qmp.command("query-status") + print(f" guest: {status.get('status')}") + finally: + shutdown(proc, qmp) + + after_sha = sha(image) + after = snapshot(image) + print(f"\nimage sha after power off: {after_sha[:16]}") + for name, _, _ in WATCH: + mark = "same" if after[name] == before[name] else "CHANGED" + print(f" {name:9s} {after[name][:12].hex()} {mark}") + + if after_sha == before_sha: + failures.append( + "the image on disk is byte-identical after a boot and clean " + "power off, so nothing the firmware wrote to flash was saved") + else: + print("\nPASS the firmware's writes reached the file") + + # Being specific matters: a changed hash alone could be almost anything. + # These are the regions holding channel and settings data. + unchanged = [n for n, _, _ in WATCH if after[n] == before[n]] + if unchanged: + failures.append( + f"the image changed but {', '.join(unchanged)} did not, so the " + "regions holding settings and channel data were not written") + else: + print("PASS the settings and channel regions were written") + + # A second boot must see what the first one left behind. + print("\nbooting again from the same file") + proc, qmp = boot(image) + try: + time.sleep(20) + finally: + shutdown(proc, qmp) + + third = snapshot(image) + if third != after: + changed = [n for n, _, _ in WATCH if third[n] != after[n]] + print(f" note: {', '.join(changed)} changed again on the second boot") + print(" (a stable image across reboots means state is being carried over)") + + finally: + shutil.rmtree(workdir, ignore_errors=True) + + print() + if failures: + for f in failures: + print("FAIL", f) + return 1 + print("flash writes persist across a power cycle") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())