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:
mckero committed 2026-10-01 17:09:00 +08:00
1 parent c64b5e6ad8
commit d76d95851f
8 files changed
+299 -4

No files matched your search

+18
View File
@@ -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
+14
View File
@@ -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,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个 这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
+3
View File
@@ -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
+3
View File
@@ -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`。
+30
View File
@@ -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."""
+78
View File
@@ -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
View File
@@ -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)"
+61
View File
@@ -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."""