From b855b0b047aeab7c15f1c514789a9824ae0e4646 Mon Sep 17 00:00:00 2001 From: QIU SHENGMING Date: Thu, 1 Oct 2026 17:25:32 +0800 Subject: [PATCH] Let the page ask the radio whether it sees an installed app GET /api/apps/radio opens the firmware's serial port and sends 0x0730 for all sixteen slots, so the answer comes from the running firmware rather than from our reading of the file -- which is the check that matters, because the bytes can be right and the firmware still refuse a slot. Measured through the page after installing Beam.app into slot 0: slot 0 -> Beam 1.0, 1100 B, crc 0xd976058, shortcut beam, committed; slots 1..3 -> status 2 with unrelated data, the resource-block overlap the install guard refuses. A button beside the table asks it and shows the answer in its own column. The server gives its own emulator a serial port (--serial-port, default 4445) and uvk5_slots_serial.Radio gained a public app_info(slot), so nothing reaches into a private helper. uvk5_apps.parse_radio_reply decodes the answer and is tested without a radio. Also recorded: QEMU needs the mingw64 DLLs on PATH, and started by hand without them it exits before opening QMP, which surfaces only as 'QMP socket never appeared'. --- AGENTS.md | 16 +++++++++ AGENTS.zh-CN.md | 14 ++++++++ README.md | 1 + README.zh-CN.md | 1 + tools/test_uvk5_apps.py | 28 +++++++++++++++ tools/uvk5_apps.py | 27 +++++++++++++++ tools/uvk5_slots_serial.py | 11 ++++++ tools/uvk5_supervisor.py | 6 ++-- tools/webui.py | 71 +++++++++++++++++++++++++++++++++++--- 9 files changed, 169 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ee03a1..d0ed878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -609,6 +609,22 @@ That is the header this page installed, echoed by the running firmware, so the r offset, the layout and the bytes are right. Slots 1 and 2 answered `status 2` with unrelated data, which is the overlap the install guard exists for. +The page can also **ask the radio**. `GET /api/apps/radio` opens the firmware's serial +port and sends `0x0730` for all sixteen slots, so the answer comes from the running firmware +rather than from our reading of the file -- the bytes can be right and the firmware still +refuse a slot. Measured after installing `Beam.app` into slot 0, through the page: + + slot 0 -> Beam 1.0 · 1100 B · crc 0xd976058 · shortcut beam · committed true + slots 1..3 -> status 2 with unrelated data (the resource-block overlap the guard refuses) + +A button beside the table asks it and shows the answer in its own column. + +One thing that cost a round here: the server gives the emulator it starts a serial port +(`--serial-port`, default 4445), and QEMU needs the mingw64 DLLs on `PATH`. Started from +`work/run-webui.ps1` they are added; started by hand they are not, and a missing DLL makes +QEMU exit **before** it opens QMP -- which surfaces only as "QMP socket never appeared", with +nothing else naming the cause. QEMU's option errors go to stdout, which the launcher discards. + ## The keypad: two real bugs, both fixed The old note here said "keys reach the firmware but the UI does not react" and diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md index 5c9598a..023fa30 100644 --- a/AGENTS.zh-CN.md +++ b/AGENTS.zh-CN.md @@ -493,6 +493,20 @@ UVStudio **只用槽 0..7 并显示为 1..8**;而且它**最后才写头** — 这就是本页写进去的那个头,被运行中的固件原样念了回来 —— 所以区域、偏移、布局、字节都是对的。 槽 1 和槽 2 回答 `status 2` 加无关数据,那正是"安装守护"要防的那块重叠区。 +页面还能**直接问电台**。`GET /api/apps/radio` 会连上固件的串口,对全部十六个槽发 `0x0730`, +于是答案来自**正在运行的固件**,而不是我们读文件的结果 —— 字节可能对,而固件仍然拒绝某个槽。 +实测(经由页面,把 `Beam.app` 装进槽 0 之后): + + slot 0 -> Beam 1.0 · 1100 B · crc 0xd976058 · shortcut beam · committed true + slots 1..3 -> 返回 status 2 与无关数据 (就是守护要拒绝的那块资源区重叠) + +表格旁的按钮会去问它,并把答案显示在自己那一列。 + +这里有一处花了整整一轮:服务器会给它启动的模拟器一个串口(`--serial-port`,默认 4445), +而 QEMU 需要 mingw64 的 DLL 在 `PATH` 上。从 `work/run-webui.ps1` 启动会加上;手动启动不会 —— +缺 DLL 会让 QEMU **在打开 QMP 之前**就退出,而这只表现为"QMP socket never appeared", +没有别的线索。QEMU 的参数错误走的是 stdout,而启动器把它丢掉了。 + ## 键盘:两个真 bug,都已修复 这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个 diff --git a/README.md b/README.md index 64bf444..4b0990e 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,7 @@ Endpoints, if you want to script it: | `GET /api/apps` | the Labs edition's overlay-app slots in the same flash image | | `POST /api/apps/` | body is a `.app`; installs it into app slot `n` (add `?force=1` to overwrite data that is not an app) | | `POST /api/apps//erase` | clear app slot `n` | +| `GET /api/apps/radio` | ask the running firmware what it sees in each app slot (`0x0730`) | | `POST /api/flash` | body is a flash image; use it from now on | Frames now come from the display controller's own memory: a QMP `qom-get` on the diff --git a/README.zh-CN.md b/README.zh-CN.md index 29dcad8..0b46bf4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -302,6 +302,7 @@ uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本 | `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 | | `POST /api/apps/` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) | | `POST /api/apps//erase` | 清空应用槽 `n` | +| `GET /api/apps/radio` | 问正在运行的固件它在每个应用槽里看到了什么(`0x0730`) | | `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 | 画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。 diff --git a/tools/test_uvk5_apps.py b/tools/test_uvk5_apps.py index 0278ec0..e0072c7 100644 --- a/tools/test_uvk5_apps.py +++ b/tools/test_uvk5_apps.py @@ -166,3 +166,31 @@ class TestRealFile(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestRadioReply(unittest.TestCase): + """The firmware's own answer about a slot, decoded without a radio.""" + + def test_a_reply_carrying_an_app_header_decodes(self): + blob = A.build(b"\x01" * 32, "Beam", "1.0", shortcut="beam") + reply = bytes([2, 0]) + blob[:A.HDR_SIZE] + info = A.parse_radio_reply(reply) + self.assertEqual(info["slot"], 2) + self.assertEqual(info["status"], 0) + self.assertEqual(info["name"], "Beam") + self.assertEqual(info["version"], "1.0") + self.assertEqual(info["code_size"], 32) + self.assertEqual(info["shortcut"], "beam") + self.assertTrue(info["committed"]) + + def test_a_reply_from_a_slot_holding_something_else_is_not_an_app(self): + """Measured on a real image: slots 1..3 answer status 2 with unrelated data.""" + info = A.parse_radio_reply(bytes([1, 2]) + b"\xd4\x56\xd1\xf4" + b"\x00" * 60) + self.assertEqual(info["status"], 2) + self.assertNotEqual(info.get("magic"), "FAP1") + self.assertNotIn("name", info) + + def test_a_short_reply_says_so_instead_of_inventing_a_header(self): + info = A.parse_radio_reply(bytes([0])) + self.assertIsNone(info["status"]) + self.assertIn("short", info["note"]) diff --git a/tools/uvk5_apps.py b/tools/uvk5_apps.py index a0f7422..8af3145 100644 --- a/tools/uvk5_apps.py +++ b/tools/uvk5_apps.py @@ -279,6 +279,33 @@ def erase_file(path: str, slot: int) -> int: +def parse_radio_reply(raw: bytes) -> dict: + """The firmware's 0x0731 answer: [slot, status, app_header_t]. + + A pure function so the decoding can be tested without a radio. The header comes back + as the same 64-byte structure this module writes, which is the point: the firmware + reading its own region and answering is what proves an install, not the file we wrote. + """ + raw = bytes(raw) + if len(raw) < 2: + return dict(status=None, note="short reply") + out = dict(slot=raw[0], status=raw[1]) + if len(raw) >= 2 + HDR_SIZE: + header = raw[2:2 + HDR_SIZE] + if header[:4] == b"FAP1": + try: + info = parse(header, strict=False) # no code here, so no CRC to check + except AppError as exc: + out["note"] = str(exc) + return out + out.update(name=info["name"], version=info["version"], code_size=info["code_size"], + crc32=info["crc32"], shortcut=info["shortcut"], + committed=info["committed"], magic="FAP1") + return out + out["magic"] = header[:4].decode("latin1", "replace") + return out + + def _read(path: str) -> bytearray: with open(path, "rb") as fh: return bytearray(fh.read()) diff --git a/tools/uvk5_slots_serial.py b/tools/uvk5_slots_serial.py index e9339e6..54af016 100644 --- a/tools/uvk5_slots_serial.py +++ b/tools/uvk5_slots_serial.py @@ -38,6 +38,8 @@ import uvk5_slots as slots # the header layout, in one place MSG_SLOT_INFO = 0x0720 MSG_SLOT_INFO_ACK = 0x0721 +MSG_APP_INFO = 0x0730 +MSG_APP_INFO_ACK = 0x0731 MSG_SLOT_ERASE = 0x0722 MSG_SLOT_ERASE_ACK = 0x0723 MSG_SLOT_WRITE = 0x0724 @@ -191,6 +193,15 @@ class Radio: pass return None + def app_info(self, slot: int): + """The firmware's own app header for @slot (0x0730 -> 0x0731), or None. + + The Labs edition answers this; a build without overlay apps does not answer at + all, which is how UVStudio decides whether the Apps tab applies, and it is the + honest way to ask "does the radio see this app" after writing one. + """ + return self._command(MSG_APP_INFO, bytes([slot]), MSG_APP_INFO_ACK, wait=4.0) + def info(self, slot: int): """(status, header bytes) for @slot, without a CRC pass.""" ack = self._command(MSG_SLOT_INFO, bytes([slot]), MSG_SLOT_INFO_ACK) diff --git a/tools/uvk5_supervisor.py b/tools/uvk5_supervisor.py index fcc5c65..57923d1 100644 --- a/tools/uvk5_supervisor.py +++ b/tools/uvk5_supervisor.py @@ -85,7 +85,7 @@ def resolve_image(image): def default_launcher(qemu: str, flash: str, elf, boot_key=None, qmp_path: str = DEFAULT_QMP, gdb_port: int = 1234, - capture_stderr: bool = True): + capture_stderr: bool = True, serial_port: int = None): """Reproduces the command line in tools/run.sh. `elf` may be a path, an ImageInfo, or an ImageSlot -- the last is what the web UI @@ -124,7 +124,9 @@ def default_launcher(qemu: str, flash: str, elf, boot_key=None, [qemu, "-M", machine, "-nographic", "-monitor", "none", "-qmp", qmp_argument(qmp_path), - "-kernel", path, "-gdb", "tcp::%d" % gdb_port], + "-kernel", path, "-gdb", "tcp::%d" % gdb_port] + + (["-serial", "tcp:127.0.0.1:%d,server=on,wait=off" % serial_port] + if serial_port else []), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL) diff --git a/tools/webui.py b/tools/webui.py index 3b2043c..fcd44f4 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -146,7 +146,8 @@ def image_has_multiboot(path): def create_app(client, frame_addr: int = None, status_addr: int = None, scale: int = 4, - supervisor=None, log=None, image=None, boot_key=None, flash=None): + supervisor=None, log=None, image=None, boot_key=None, flash=None, + serial_port=None): app = Flask(__name__) if log is None: @@ -378,6 +379,40 @@ def create_app(client, frame_addr: int = None, status_addr: int = None, scale: i info = dict(info, multiboot=image_has_multiboot(image.path)) return jsonify(loaded=info is not None, firmware=info, running=running_firmware()) + @app.get("/api/apps/radio") + def api_apps_radio(): + """Ask the running firmware what it sees in each app slot. + + The firmware answers 0x0730 with the header it reads from its own region (see + App/apps/app_overlay.h), so this is the radio's answer rather than our reading of + the file -- and that is the check that matters after an install, because the bytes + can be right and the firmware still refuse the slot. It needs the emulator to have + a serial port, which this server gives it when it starts one itself. + """ + if serial_port is None: + return jsonify(error="this server started the emulator without a serial port, " + "so the radio cannot be asked"), 409 + try: + import uvk5_slots_serial + radio = uvk5_slots_serial.Radio("127.0.0.1:%d" % serial_port, timeout=8.0, + log=lambda *a: None) + except Exception as exc: + return jsonify(error="cannot reach the radio's serial port: %s" % exc), 503 + slots = [] + try: + radio.session() + for slot in range(uvk5_apps.SLOT_COUNT): + slots.append(uvk5_apps.parse_radio_reply(radio.app_info(slot)) | {"slot": slot}) + except Exception as exc: + return jsonify(error="the radio stopped answering: %s" % exc, slots=slots), 503 + finally: + try: + radio.close() + except Exception: + pass + log.add("apps", "asked the radio about %d app slots" % len(slots), ip=client_ip()) + return jsonify(slots=slots) + @app.post("/api/firmware") def api_firmware_upload(): """Boot an uploaded firmware image. @@ -941,6 +976,7 @@ def render_index(scale: int) -> str:
- + the Labs edition's apps live in the same external flash (16 slots from 0x102000; the firmware's menu lists the first eight, numbered 1..8). Pick a .app for a slot to install it — no serial port and no browser permission are involved @@ -1300,10 +1336,14 @@ async function loadApps() {{ : s.state === 'empty' ? 'Empty' : '' + s.state + ' — not an app'; const size = s.state === 'app' ? s.code_size + ' B' : ''; + const said = radioApps && radioApps[s.slot] + ? (radioApps[s.slot].status === 0 ? 'radio: ' + radioApps[s.slot].name + : 'radio: status ' + radioApps[s.slot].status) + : ''; // Upstream and the firmware's own menu number the eight usable slots 1..8, while the // region has sixteen: slot 0 is "1" there, so show them the same way. tr.innerHTML = 'app ' + (s.slot + 1) + (s.slot < 8 ? '' : ' (after the menu)') + - '' + what + '' + size + ''; + '' + what + '' + size + '' + said + ''; const td = tr.lastElementChild; const inp = document.createElement('input'); inp.type = 'file'; @@ -1348,6 +1388,25 @@ async function loadApps() {{ }} loadApps(); setInterval(loadApps, 15000); +// "Ask the radio": the firmware answers 0x0730 with the header it reads from its own +// region, which is the check that matters after an install -- the bytes can be right and +// the firmware still refuse the slot. It needs a serial port, which this server gives its +// own emulator when it starts one. +let radioApps = null; +const appAsk = document.getElementById('appask'); +if (appAsk) appAsk.addEventListener('click', async () => {{ + appAsk.disabled = true; + appAsk.textContent = 'asking...'; + try {{ + const j = await (await fetch('/api/apps/radio')).json(); + radioApps = j.error ? {{ error: j.error }} : j.slots; + }} catch (err) {{ + radioApps = {{ error: String(err) }}; + }} + appAsk.disabled = false; + appAsk.textContent = 'Ask the radio'; + loadApps(); +}}); // Firmware upload. The file *is* the request body, so the server reads the // vector table itself and decides the load offset: an application image and a // full-flash image need different ones, and the wrong one fails silently. @@ -1461,6 +1520,9 @@ def main() -> int: ap.add_argument("--flash", default=os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "flash.img")) + ap.add_argument("--serial-port", type=int, default=4445, + help="TCP port for the firmware's serial, so the page can ask the " + "radio what it sees; needs to differ from --qmp") ap.add_argument("--gdb-port", type=int, default=1234) args = ap.parse_args() @@ -1494,7 +1556,8 @@ def main() -> int: supervisor = Supervisor( launch=default_launcher(args.qemu, flash, image, boot_key, - qmp_path=args.qmp, gdb_port=args.gdb_port), + qmp_path=args.qmp, gdb_port=args.gdb_port, + serial_port=args.serial_port), connect=connect, log=log) if args.attach: @@ -1506,7 +1569,7 @@ def main() -> int: app = create_app(supervisor.client(), args.frame_addr, args.status_addr, args.scale, supervisor=supervisor, log=log, image=image, - boot_key=boot_key, flash=flash) + boot_key=boot_key, flash=flash, serial_port=args.serial_port) print(f"serving on http://{args.host}:{args.port}/") print("attached to a running emulator" if args.attach else "emulator is OFF; press On in the browser to boot it")