diff --git a/AGENTS.md b/AGENTS.md index cdb237e..c3724ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -571,6 +571,24 @@ region is found the same way the screen buffers were: from the firmware's own co not from a guess -- the first version of the header here was 60 bytes because a field (`vma`) was missing, and the real `Beam.app` bytes said so immediately. +The page writes them through three endpoints (`GET /api/apps`, `POST /api/apps/`, +`POST /api/apps//erase`), all of which edit the flash image the emulator boots from -- +so this is page-operable with no WebSerial, no browser permission and no serial protocol. + +Two things measured while wiring it up, both of which changed the code: + +* **The region can already hold something.** On a real image every one of the 16 slots read + back as data that is neither empty nor an app -- the factory resource block of a localised + build overlaps `0x102000`. Installing there would have destroyed it in silence, so + `install` now refuses a slot that holds anything other than an app unless it is asked to + overwrite (`--force` on the tool, `?force=1` on the endpoint). The refusal names what is + there and where the slot is. +* **The edit does not land in the file you passed.** `_edit_flash` copies the image and + repoints the slot at the copy, deliberately, so a running emulator cannot have the file + under it rewritten. A first test asserted the original file had changed, saw zero + differing bytes and read as "the install did nothing" -- the bytes were in the copy. Check + `FlashSlot.path`, not the path you handed in. + ## 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 9c73ba8..172632e 100644 --- a/AGENTS.zh-CN.md +++ b/AGENTS.zh-CN.md @@ -462,6 +462,20 @@ BroadcastFM)。上游是用 UVStudio 通过 WebSerial 装进去的;在我们 另外,这块区域和屏幕缓冲一样,是**从固件自己的常量里读出来的**而非猜的:这里头结构的第一版是 60 字节, 因为漏了一个字段(`vma`),而真实的 `Beam.app` 字节当场就指出了这一点。 +页面通过三个接口写入(`GET /api/apps`、`POST /api/apps/`、`POST /api/apps//erase`), +它们改的都是模拟器启动用的那个 flash 镜像 —— 所以这一切在网页上就能做,**不需要 WebSerial、 +不需要浏览器授权、也不需要串口协议**。 + +接进去的过程中量到两件事,两件都改了代码: + +* **那块区域可能本来就有东西。** 在真实镜像上,16 个槽读回来全是"既不是空、也不是应用"的数据 —— + 汉化版构建的工厂资源块**压在 `0x102000` 上**。在那里安装会**无声地毁掉它**,所以 `install` + 现在会拒绝装有非应用内容的槽,除非明确要求覆盖(工具用 `--force`,接口用 `?force=1`)。 + 拒绝时会说清那里是什么、槽在哪。 +* **改动不会落在你传进去的那个文件上。** `_edit_flash` 会**复制**镜像并把槽指向副本 —— 这是故意的, + 以免正在运行的模拟器脚下的文件被改写。第一版测试断言原文件变了,看到 0 字节差异,读起来像 + "安装什么都没做" —— 其实字节在副本里。要检查 `FlashSlot.path`,不是你传进去的路径。 + ## 键盘:两个真 bug,都已修复 这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个 diff --git a/README.md b/README.md index 0eda6d4..64bf444 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,9 @@ Endpoints, if you want to script it: | `GET /api/slots` | the firmware slots in the flash image the emulator uses | | `POST /api/slots/` | body is a `.bin`; writes it into slot `n` and restarts | | `POST /api/slots//erase` | erase slot `n` | +| `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` | | `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 10d5741..29dcad8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -299,6 +299,9 @@ uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本 | `GET /api/slots` | 当前 flash 镜像里的固件槽 | | `POST /api/slots/` | 请求体是一个 `.bin`;写进槽 `n` 并重启 | | `POST /api/slots//erase` | 擦除槽 `n` | +| `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 | +| `POST /api/apps/` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) | +| `POST /api/apps//erase` | 清空应用槽 `n` | | `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 | 画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。 diff --git a/tools/test_uvk5_apps.py b/tools/test_uvk5_apps.py index 04898bd..0278ec0 100644 --- a/tools/test_uvk5_apps.py +++ b/tools/test_uvk5_apps.py @@ -115,11 +115,41 @@ class TestInstall(unittest.TestCase): A.erase(img, 2) self.assertIsNone(A.read_slot(bytes(img), 2)) + def test_it_refuses_to_overwrite_something_that_is_not_an_app(self): + """Measured on a real image: the factory resource block can overlap the region.""" + img = image() + base = A.REGION_BASE + img[base:base + 8] = b"RESDATA1" + with self.assertRaises(A.AppError) as caught: + A.install(img, 0, A.build(b"\\x01" * 64, "Beam")) + self.assertIn("already holds", str(caught.exception)) + A.install(img, 0, A.build(b"\\x01" * 64, "Beam"), force=True) + self.assertEqual(A.read_slot(bytes(img), 0)["name"], "Beam") + + def test_installing_over_an_app_needs_no_force(self): + img = image() + A.install(img, 0, A.build(b"\\x11" * 64, "Old")) + A.install(img, 0, A.build(b"\\x22" * 64, "New")) + self.assertEqual(A.read_slot(bytes(img), 0)["name"], "New") + def test_a_slot_outside_the_region_is_refused(self): with self.assertRaises(A.AppError): A.install(image(), 16, A.build(b"x", "A")) +class TestResolve(unittest.TestCase): + def test_a_bare_name_is_found_under_work_apps(self): + """The first attempt at this used 'Beam.app' from the repository root.""" + path = A.resolve("Beam.app") + self.assertTrue(os.path.exists(path), path) + + def test_a_missing_name_says_where_it_looked(self): + with self.assertRaises(A.AppError) as caught: + A.resolve("NoSuchGame.app") + self.assertIn("looked in", str(caught.exception)) + self.assertIn("work", str(caught.exception)) + + class TestRealFile(unittest.TestCase): """Runs against a downloaded Beam.app when one is present; skipped otherwise.""" diff --git a/tools/test_webui.py b/tools/test_webui.py index b6766b6..3dc5b29 100644 --- a/tools/test_webui.py +++ b/tools/test_webui.py @@ -1052,3 +1052,81 @@ class TestPageScriptParses(unittest.TestCase): "the page's script does not parse:\n" + done.stderr) finally: os.unlink(path) + + +class TestAppEndpoints(unittest.TestCase): + """The Labs edition's overlay apps, written into the external flash image. + + No emulator: create_app is handed the flash path directly and the endpoints edit + that file. The region and the header are the firmware's own, from its + App/apps/app_overlay.h: 16 slots of 8 KiB from 0x102000, a 64-byte FAP1 header at + the slot base and the code one 4 KiB sector later. + """ + + def setUp(self): + import uvk5_apps + self.dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dir, True) + self.flash = os.path.join(self.dir, "flash.img") + with open(self.flash, "wb") as fh: + fh.write(b"\xff" * 0x200000) + self.flashslot = FlashSlot(self.flash) + self.app = webui.create_app(None, frame_addr=0x1000, status_addr=0x2000, + flash=self.flashslot) + self.client = self.app.test_client() + self.blob = uvk5_apps.build(b"\x01" * 64, "Beam", "1.0", shortcut="beam") + + def test_the_listing_names_the_region_and_every_slot(self): + body = self.client.get("/api/apps").get_json() + self.assertEqual(body["region"], 0x102000) + self.assertEqual(body["slot_count"], 16) + self.assertEqual(len(body["slots"]), 16) + self.assertEqual(body["slots"][0]["state"], "empty") + + def test_installing_an_app_puts_it_where_the_firmware_looks(self): + r = self.client.post("/api/apps/3", data=self.blob) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.get_json()["app"]["name"], "Beam") + row = r.get_json()["apps"]["slots"][3] + self.assertEqual(row["state"], "app") + self.assertEqual(row["base"], 0x102000 + 3 * 0x2000) + self.assertEqual(row["code_size"], 64) + + def test_the_bytes_reach_the_image_the_emulator_will_boot(self): + # _edit_flash copies the image and repoints the slot at the copy, so the + # file it did not touch is expected to be untouched: check the one it uses. + self.client.post("/api/apps/0", data=self.blob) + with open(self.flashslot.path, "rb") as fh: + image = fh.read() + self.assertEqual(image[0x102000:0x102004], b"FAP1") + self.assertEqual(image[0x103000:0x103004], b"\x01" * 4) + + def test_a_blob_that_is_not_an_app_is_refused_with_the_reason(self): + r = self.client.post("/api/apps/0", data=b"this is not an app at all") + self.assertEqual(r.status_code, 400) + reason = r.get_json()["error"] + self.assertTrue("FAP1" in reason or "too short" in reason, reason) + + def test_installing_over_other_data_is_refused_unless_forced(self): + """Measured on a real image: the factory resource block can overlap the region.""" + image = bytearray(b"\xff" * 0x200000) + image[0x106000:0x106008] = b"RESDATA1" + with open(self.flash, "wb") as fh: + fh.write(image) + r = self.client.post("/api/apps/2", data=self.blob) + self.assertEqual(r.status_code, 400) + self.assertIn("already holds", r.get_json()["error"]) + r = self.client.post("/api/apps/2?force=1", data=self.blob) + self.assertEqual(r.status_code, 200) + + def test_erase_clears_the_slot(self): + self.assertEqual(self.client.post("/api/apps/2", data=self.blob).status_code, 200) + r = self.client.post("/api/apps/2/erase") + body = r.get_json() + self.assertEqual(r.status_code, 200, body) + self.assertEqual(body["apps"]["slots"][2]["state"], "empty") + + def test_a_slot_outside_the_region_is_refused(self): + r = self.client.post("/api/apps/16", data=self.blob) + self.assertEqual(r.status_code, 400) + self.assertIn("out of range", r.get_json()["error"]) diff --git a/tools/uvk5_apps.py b/tools/uvk5_apps.py index ba6cf2e..a0f7422 100644 --- a/tools/uvk5_apps.py +++ b/tools/uvk5_apps.py @@ -30,6 +30,7 @@ in slot N" in this page's slot table touch the same external flash. tools/uvk5_apps.py erase work/user-flash.img 1 """ import argparse +import os import struct import sys import zlib @@ -45,6 +46,12 @@ REGION_BASE = 0x00102000 SLOT_STRIDE = 0x0002000 CODE_OFFSET = 0x00001000 SLOT_COUNT = 16 +# The firmware's own names for these, so a reader can hold both side by side. +APP_REGION_BASE = REGION_BASE +APP_SLOT_STRIDE = SLOT_STRIDE +APP_CODE_OFFSET = CODE_OFFSET +APP_SLOT_COUNT = SLOT_COUNT +APP_OVERLAY_MAX_BYTES = APP_OVERLAY_MAX FLAG_COMMITTED = 0x0001 FLAG_SCREEN_SAVER = 0x0002 @@ -166,16 +173,28 @@ def list_apps(image: bytes): return [info for info in (read_slot(image, i) for i in range(SLOT_COUNT)) if info] -def install(image: bytearray, slot: int, blob: bytes) -> dict: +def install(image: bytearray, slot: int, blob: bytes, force: bool = False) -> dict: """Write @blob into @slot: header at the base, code at +0x1000, rest erased. The header sector is erased first, the way the flash would be: an install must not leave a byte of the previous app behind for the loader to trip over. + + Refuses to overwrite a slot that holds something which is neither empty nor an app. + Measured on a real image: 0x102000..0x122000 can already carry data (the factory + resource block of a localised build overlaps it), and an install there silently + destroys it. `force` is for when that is what you meant. """ info = parse(blob) base = slot_base(slot) if base + SLOT_STRIDE > len(image): raise AppError("the image is too small for slot %d" % slot) + if not force: + occupied = read_slot(bytes(image), slot) + if occupied is not None and occupied.get("kind") not in ("app",): + raise AppError( + "slot %d at 0x%06X already holds %s (%r), not an app; erase it first " + "or pass force to overwrite" % (slot, base, occupied.get("kind", "data"), + (occupied.get("name") or "")[:16])) for i in range(base, base + SLOT_STRIDE): image[i] = 0xFF image[base:base + HDR_SIZE] = blob[:HDR_SIZE] @@ -193,6 +212,73 @@ def erase(image: bytearray, slot: int) -> int: return base +def resolve(name: str) -> str: + """A path to the .app named @name, looking in the places it usually is. + + The first person to try this typed `tools/uvk5_apps.py install image 1 Beam.app` from the + repository root, where no such file exists: the tool said FileNotFoundError and nothing + else. The apps live in work/apps/ once downloaded, and the bare name is what everybody + types, so both are accepted and the refusal lists where it looked. + """ + if os.path.isabs(name) or os.path.exists(name): + return name + roots = [os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "work", "apps"), + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "apps"), + "."] + tried = [] + for root in roots: + candidate = os.path.join(root, name) + tried.append(candidate) + if os.path.exists(candidate): + return candidate + raise AppError("no %s; looked in %s" % (name, ", ".join(os.path.normpath(t) for t in tried))) + + +def apps_json(path: str) -> dict: + """Every app slot as a row, for the page: one entry per slot, occupied or not. + + The page shows all of them rather than only the occupied ones, because choosing the + slot is part of installing, and the radio itself prints Empty for a free one. + """ + with open(path, "rb") as fh: + image = fh.read() + occupied = {info["slot"]: info for info in list_apps(image)} + rows = [] + for slot in range(SLOT_COUNT): + row = dict(slot=slot, base=slot_base(slot)) + info = occupied.get(slot) + if info is None: + row["state"] = "empty" + elif info.get("kind") == "app": + row.update(state="app", name=info["name"], version=info["version"], + code_size=info["code_size"], shortcut=info["shortcut"], + committed=info["committed"], crc32=info["crc32"]) + else: + row.update(state=info.get("kind", "unknown"), name=info.get("magic", "?")) + rows.append(row) + return dict(region=REGION_BASE, slot_count=SLOT_COUNT, stride=SLOT_STRIDE, + code_offset=CODE_OFFSET, overlay_max=APP_OVERLAY_MAX, slots=rows) + + +def install_file(path: str, slot: int, blob: bytes, force: bool = False) -> dict: + """Install @blob into @slot of the flash image at @path, in place.""" + image = _read(path) + info = install(image, slot, blob, force=force) + with open(path, "wb") as fh: + fh.write(image) + return info + + +def erase_file(path: str, slot: int) -> int: + """Clear @slot of the flash image at @path.""" + image = _read(path) + base = erase(image, slot) + with open(path, "wb") as fh: + fh.write(image) + return base + + + def _read(path: str) -> bytearray: with open(path, "rb") as fh: return bytearray(fh.read()) @@ -208,6 +294,8 @@ def main(argv=None) -> int: p.add_argument("image") p.add_argument("slot", type=int) p.add_argument("app") + p.add_argument("--force", action="store_true", + help="overwrite a slot holding something other than an app") p = sub.add_parser("erase", help="clear a slot") p.add_argument("image") p.add_argument("slot", type=int) @@ -217,7 +305,7 @@ def main(argv=None) -> int: try: if args.cmd == "info": - with open(args.app, "rb") as fh: + with open(resolve(args.app), "rb") as fh: info = parse(fh.read()) print("%s %s %d bytes of code (blob %d) ABI %d api>=%d shortcut %s CRC 0x%08X" % (info["name"], info["version"], info["code_size"], info["total"], info["abi"], @@ -236,10 +324,10 @@ def main(argv=None) -> int: else: print("slot %2d @0x%06X %s" % (info["slot"], info["base"], info["kind"])) return 0 - with open(args.app, "rb") as fh: + with open(resolve(args.app), "rb") as fh: blob = fh.read() if args.cmd == "install": - info = install(image, args.slot, blob) + info = install(image, args.slot, blob, force=args.force) with open(args.image, "wb") as fh: fh.write(image) print("installed %s %s into slot %d at 0x%06X (%d bytes)" diff --git a/tools/webui.py b/tools/webui.py index 7215278..c6b18ed 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -29,6 +29,7 @@ from uvk5_image import ImageError, detect as detect_image from uvk5_slots import (SLOT_COUNT, erase_slot_file, slots_json, write_slot_file) from uvk5_keys import KEYS, is_valid, normalise +import uvk5_apps from uvk5_lcd import PANEL_PATH from uvk5_logs import LogBuffer from uvk5_stream import FramePump @@ -515,6 +516,66 @@ def create_app(client, frame_addr: int = None, status_addr: int = None, scale: i log.add("slots", "slot %d erased" % slot, ip=client_ip()) return jsonify(slot=row) + # ------------------------------------------------------------- overlay apps + # + # The Labs edition's small apps (Tetris, Breakout, Beam, Plasma, ...) live in the + # *external* flash, in the region its own App/apps/app_overlay.h describes: 16 slots + # of 8 KiB from 0x102000, a 64-byte FAP1 header at the slot base and the code one + # 4 KiB sector later. Upstream installs them from UVStudio over WebSerial; this page + # owns the image, so the same bytes go to the same offsets with no serial protocol + # and no browser permission. The 64-byte header is shared with the multiboot firmware + # slots -- FMB1 is a firmware, FAP1 is an app -- which is why both live in this one + # image and why the power-on menu can list them together. + @app.get("/api/apps") + def api_apps(): + """Every overlay-app slot in the flash image the emulator is using.""" + if flash is None: + return jsonify(error="this server was started without flash control"), 409 + try: + return jsonify(uvk5_apps.apps_json(flash.path)) + except Exception as exc: + return jsonify(error=str(exc)), 500 + + @app.post("/api/apps/") + def api_app_install(slot): + """Install an uploaded .app into a slot, then power the radio on again.""" + if not 0 <= slot < uvk5_apps.APP_SLOT_COUNT: + return jsonify(error="app slot %d is out of range (0..%d)" + % (slot, uvk5_apps.APP_SLOT_COUNT - 1)), 400 + data = request.get_data(cache=False, as_text=False) + if not data: + return jsonify(error="no .app in the request body"), 400 + if len(data) > MAX_UPLOAD_BYTES: + return jsonify(error="%d bytes is too large" % len(data)), 413 + try: + force = request.args.get("force", "").lower() in ("1", "true", "yes") + info = _edit_flash(lambda p: uvk5_apps.install_file(p, slot, data, force)) + except Exception as exc: + # The tool refuses what the firmware would show as APP ERROR, so the reason + # reaches the page instead of becoming a silent no-op on the radio. + log.add("apps", "slot %d refused: %s" % (slot, exc), ip=client_ip()) + return jsonify(error=str(exc)), 400 + log.add("apps", "slot %d <- %s %s (%d bytes of code)" + % (slot, info["name"], info["version"], info["code_size"]), ip=client_ip()) + return jsonify(app=dict(slot=slot, name=info["name"], version=info["version"], + code_size=info["code_size"], crc32=info["crc32"], + shortcut=info["shortcut"]), + apps=uvk5_apps.apps_json(flash.path)) + + @app.post("/api/apps//erase") + def api_app_erase(slot): + """Clear one app slot.""" + if not 0 <= slot < uvk5_apps.APP_SLOT_COUNT: + return jsonify(error="app slot %d is out of range (0..%d)" + % (slot, uvk5_apps.APP_SLOT_COUNT - 1)), 400 + try: + _edit_flash(lambda p: uvk5_apps.erase_file(p, slot)) + except Exception as exc: + log.add("apps", "slot %d erase failed: %s" % (slot, exc), ip=client_ip()) + return jsonify(error=str(exc)), 400 + log.add("apps", "slot %d erased" % slot, ip=client_ip()) + return jsonify(apps=uvk5_apps.apps_json(flash.path)) + @app.post("/api/flash") def api_flash_upload(): """Use an uploaded image as the external flash, slots and all."""