mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
Apps on the page: list, install and erase overlay apps in the flash image
The Labs edition's apps live in the external flash, in the region its own App/apps/app_overlay.h defines (16 slots of 8 KiB from 0x102000, a 64-byte FAP1 header at the slot base, code one 4 KiB sector later), and upstream installs them from UVStudio over WebSerial. The page owns the image, so this adds GET /api/apps, POST /api/apps/<n> and POST /api/apps/<n>/erase, which put the same bytes at the same offsets with no serial protocol and no browser permission. Measured on a real image while wiring it up: every one of the 16 slots already held data that is neither empty nor an app -- the localised build's factory resource block overlaps 0x102000 -- so install now refuses to overwrite anything that is not an app unless asked (--force, ?force=1), naming what is there. And _edit_flash edits a copy, which is why the source image shows no changed bytes; the first test read that as 'the install did nothing' and now checks FlashSlot.path. Tests: test_uvk5_apps grew to 20, test_webui.TestAppEndpoints adds 7 over the endpoints.
This commit is contained in:
1 parent
c64b5e6ad8
commit
d76d95851f
8 files changed
+299
-4
No files matched your search
@@ -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
|
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.
|
(`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/<n>`,
|
||||||
|
`POST /api/apps/<n>/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 keypad: two real bugs, both fixed
|
||||||
|
|
||||||
The old note here said "keys reach the firmware but the UI does not react" and
|
The old note here said "keys reach the firmware but the UI does not react" and
|
||||||
|
|||||||
@@ -462,6 +462,20 @@ BroadcastFM)。上游是用 UVStudio 通过 WebSerial 装进去的;在我们
|
|||||||
另外,这块区域和屏幕缓冲一样,是**从固件自己的常量里读出来的**而非猜的:这里头结构的第一版是 60 字节,
|
另外,这块区域和屏幕缓冲一样,是**从固件自己的常量里读出来的**而非猜的:这里头结构的第一版是 60 字节,
|
||||||
因为漏了一个字段(`vma`),而真实的 `Beam.app` 字节当场就指出了这一点。
|
因为漏了一个字段(`vma`),而真实的 `Beam.app` 字节当场就指出了这一点。
|
||||||
|
|
||||||
|
页面通过三个接口写入(`GET /api/apps`、`POST /api/apps/<n>`、`POST /api/apps/<n>/erase`),
|
||||||
|
它们改的都是模拟器启动用的那个 flash 镜像 —— 所以这一切在网页上就能做,**不需要 WebSerial、
|
||||||
|
不需要浏览器授权、也不需要串口协议**。
|
||||||
|
|
||||||
|
接进去的过程中量到两件事,两件都改了代码:
|
||||||
|
|
||||||
|
* **那块区域可能本来就有东西。** 在真实镜像上,16 个槽读回来全是"既不是空、也不是应用"的数据 ——
|
||||||
|
汉化版构建的工厂资源块**压在 `0x102000` 上**。在那里安装会**无声地毁掉它**,所以 `install`
|
||||||
|
现在会拒绝装有非应用内容的槽,除非明确要求覆盖(工具用 `--force`,接口用 `?force=1`)。
|
||||||
|
拒绝时会说清那里是什么、槽在哪。
|
||||||
|
* **改动不会落在你传进去的那个文件上。** `_edit_flash` 会**复制**镜像并把槽指向副本 —— 这是故意的,
|
||||||
|
以免正在运行的模拟器脚下的文件被改写。第一版测试断言原文件变了,看到 0 字节差异,读起来像
|
||||||
|
"安装什么都没做" —— 其实字节在副本里。要检查 `FlashSlot.path`,不是你传进去的路径。
|
||||||
|
|
||||||
## 键盘:两个真 bug,都已修复
|
## 键盘:两个真 bug,都已修复
|
||||||
|
|
||||||
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
|
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ Endpoints, if you want to script it:
|
|||||||
| `GET /api/slots` | the firmware slots in the flash image the emulator uses |
|
| `GET /api/slots` | the firmware slots in the flash image the emulator uses |
|
||||||
| `POST /api/slots/<n>` | body is a `.bin`; writes it into slot `n` and restarts |
|
| `POST /api/slots/<n>` | body is a `.bin`; writes it into slot `n` and restarts |
|
||||||
| `POST /api/slots/<n>/erase` | erase slot `n` |
|
| `POST /api/slots/<n>/erase` | erase slot `n` |
|
||||||
|
| `GET /api/apps` | the Labs edition's overlay-app slots in the same flash image |
|
||||||
|
| `POST /api/apps/<n>` | body is a `.app`; installs it into app slot `n` (add `?force=1` to overwrite data that is not an app) |
|
||||||
|
| `POST /api/apps/<n>/erase` | clear app slot `n` |
|
||||||
| `POST /api/flash` | body is a flash image; use it from now on |
|
| `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
|
Frames now come from the display controller's own memory: a QMP `qom-get` on the
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本
|
|||||||
| `GET /api/slots` | 当前 flash 镜像里的固件槽 |
|
| `GET /api/slots` | 当前 flash 镜像里的固件槽 |
|
||||||
| `POST /api/slots/<n>` | 请求体是一个 `.bin`;写进槽 `n` 并重启 |
|
| `POST /api/slots/<n>` | 请求体是一个 `.bin`;写进槽 `n` 并重启 |
|
||||||
| `POST /api/slots/<n>/erase` | 擦除槽 `n` |
|
| `POST /api/slots/<n>/erase` | 擦除槽 `n` |
|
||||||
|
| `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 |
|
||||||
|
| `POST /api/apps/<n>` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) |
|
||||||
|
| `POST /api/apps/<n>/erase` | 清空应用槽 `n` |
|
||||||
| `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 |
|
| `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 |
|
||||||
|
|
||||||
画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。
|
画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。
|
||||||
|
|||||||
@@ -115,11 +115,41 @@ class TestInstall(unittest.TestCase):
|
|||||||
A.erase(img, 2)
|
A.erase(img, 2)
|
||||||
self.assertIsNone(A.read_slot(bytes(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):
|
def test_a_slot_outside_the_region_is_refused(self):
|
||||||
with self.assertRaises(A.AppError):
|
with self.assertRaises(A.AppError):
|
||||||
A.install(image(), 16, A.build(b"x", "A"))
|
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):
|
class TestRealFile(unittest.TestCase):
|
||||||
"""Runs against a downloaded Beam.app when one is present; skipped otherwise."""
|
"""Runs against a downloaded Beam.app when one is present; skipped otherwise."""
|
||||||
|
|
||||||
|
|||||||
@@ -1052,3 +1052,81 @@ class TestPageScriptParses(unittest.TestCase):
|
|||||||
"the page's script does not parse:\n" + done.stderr)
|
"the page's script does not parse:\n" + done.stderr)
|
||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
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"])
|
||||||
+92
-4
@@ -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
|
tools/uvk5_apps.py erase work/user-flash.img 1
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
@@ -45,6 +46,12 @@ REGION_BASE = 0x00102000
|
|||||||
SLOT_STRIDE = 0x0002000
|
SLOT_STRIDE = 0x0002000
|
||||||
CODE_OFFSET = 0x00001000
|
CODE_OFFSET = 0x00001000
|
||||||
SLOT_COUNT = 16
|
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_COMMITTED = 0x0001
|
||||||
FLAG_SCREEN_SAVER = 0x0002
|
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]
|
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.
|
"""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
|
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.
|
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)
|
info = parse(blob)
|
||||||
base = slot_base(slot)
|
base = slot_base(slot)
|
||||||
if base + SLOT_STRIDE > len(image):
|
if base + SLOT_STRIDE > len(image):
|
||||||
raise AppError("the image is too small for slot %d" % slot)
|
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):
|
for i in range(base, base + SLOT_STRIDE):
|
||||||
image[i] = 0xFF
|
image[i] = 0xFF
|
||||||
image[base:base + HDR_SIZE] = blob[:HDR_SIZE]
|
image[base:base + HDR_SIZE] = blob[:HDR_SIZE]
|
||||||
@@ -193,6 +212,73 @@ def erase(image: bytearray, slot: int) -> int:
|
|||||||
return base
|
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:
|
def _read(path: str) -> bytearray:
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
return bytearray(fh.read())
|
return bytearray(fh.read())
|
||||||
@@ -208,6 +294,8 @@ def main(argv=None) -> int:
|
|||||||
p.add_argument("image")
|
p.add_argument("image")
|
||||||
p.add_argument("slot", type=int)
|
p.add_argument("slot", type=int)
|
||||||
p.add_argument("app")
|
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 = sub.add_parser("erase", help="clear a slot")
|
||||||
p.add_argument("image")
|
p.add_argument("image")
|
||||||
p.add_argument("slot", type=int)
|
p.add_argument("slot", type=int)
|
||||||
@@ -217,7 +305,7 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if args.cmd == "info":
|
if args.cmd == "info":
|
||||||
with open(args.app, "rb") as fh:
|
with open(resolve(args.app), "rb") as fh:
|
||||||
info = parse(fh.read())
|
info = parse(fh.read())
|
||||||
print("%s %s %d bytes of code (blob %d) ABI %d api>=%d shortcut %s CRC 0x%08X"
|
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"],
|
% (info["name"], info["version"], info["code_size"], info["total"], info["abi"],
|
||||||
@@ -236,10 +324,10 @@ def main(argv=None) -> int:
|
|||||||
else:
|
else:
|
||||||
print("slot %2d @0x%06X %s" % (info["slot"], info["base"], info["kind"]))
|
print("slot %2d @0x%06X %s" % (info["slot"], info["base"], info["kind"]))
|
||||||
return 0
|
return 0
|
||||||
with open(args.app, "rb") as fh:
|
with open(resolve(args.app), "rb") as fh:
|
||||||
blob = fh.read()
|
blob = fh.read()
|
||||||
if args.cmd == "install":
|
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:
|
with open(args.image, "wb") as fh:
|
||||||
fh.write(image)
|
fh.write(image)
|
||||||
print("installed %s %s into slot %d at 0x%06X (%d bytes)"
|
print("installed %s %s into slot %d at 0x%06X (%d bytes)"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from uvk5_image import ImageError, detect as detect_image
|
|||||||
from uvk5_slots import (SLOT_COUNT, erase_slot_file, slots_json,
|
from uvk5_slots import (SLOT_COUNT, erase_slot_file, slots_json,
|
||||||
write_slot_file)
|
write_slot_file)
|
||||||
from uvk5_keys import KEYS, is_valid, normalise
|
from uvk5_keys import KEYS, is_valid, normalise
|
||||||
|
import uvk5_apps
|
||||||
from uvk5_lcd import PANEL_PATH
|
from uvk5_lcd import PANEL_PATH
|
||||||
from uvk5_logs import LogBuffer
|
from uvk5_logs import LogBuffer
|
||||||
from uvk5_stream import FramePump
|
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())
|
log.add("slots", "slot %d erased" % slot, ip=client_ip())
|
||||||
return jsonify(slot=row)
|
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/<int:slot>")
|
||||||
|
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/<int:slot>/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")
|
@app.post("/api/flash")
|
||||||
def api_flash_upload():
|
def api_flash_upload():
|
||||||
"""Use an uploaded image as the external flash, slots and all."""
|
"""Use an uploaded image as the external flash, slots and all."""
|
||||||
|
|||||||
Reference in new issue
Block a user