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'.
This commit is contained in:
mckero committed 2026-10-01 17:25:32 +08:00
1 parent 0267371e28
commit b855b0b047
9 files changed
+169 -6

No files matched your search

+16
View File
@@ -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 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. 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 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
@@ -493,6 +493,20 @@ UVStudio **只用槽 0..7 并显示为 1..8**;而且它**最后才写头** —
这就是本页写进去的那个头,被运行中的固件原样念了回来 —— 所以区域、偏移、布局、字节都是对的。 这就是本页写进去的那个头,被运行中的固件原样念了回来 —— 所以区域、偏移、布局、字节都是对的。
槽 1 和槽 2 回答 `status 2` 加无关数据,那正是"安装守护"要防的那块重叠区。 槽 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,都已修复 ## 键盘:两个真 bug,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个 这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
+1
View File
@@ -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 | | `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>` | 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/apps/<n>/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 | | `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
+1
View File
@@ -302,6 +302,7 @@ uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本
| `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 | | `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 |
| `POST /api/apps/<n>` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) | | `POST /api/apps/<n>` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) |
| `POST /api/apps/<n>/erase` | 清空应用槽 `n` | | `POST /api/apps/<n>/erase` | 清空应用槽 `n` |
| `GET /api/apps/radio` | 问正在运行的固件它在每个应用槽里看到了什么(`0x0730`) |
| `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 | | `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 |
画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。 画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。
+28
View File
@@ -166,3 +166,31 @@ class TestRealFile(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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"])
+27
View File
@@ -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: 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())
+11
View File
@@ -38,6 +38,8 @@ import uvk5_slots as slots # the header layout, in one place
MSG_SLOT_INFO = 0x0720 MSG_SLOT_INFO = 0x0720
MSG_SLOT_INFO_ACK = 0x0721 MSG_SLOT_INFO_ACK = 0x0721
MSG_APP_INFO = 0x0730
MSG_APP_INFO_ACK = 0x0731
MSG_SLOT_ERASE = 0x0722 MSG_SLOT_ERASE = 0x0722
MSG_SLOT_ERASE_ACK = 0x0723 MSG_SLOT_ERASE_ACK = 0x0723
MSG_SLOT_WRITE = 0x0724 MSG_SLOT_WRITE = 0x0724
@@ -191,6 +193,15 @@ class Radio:
pass pass
return None 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): def info(self, slot: int):
"""(status, header bytes) for @slot, without a CRC pass.""" """(status, header bytes) for @slot, without a CRC pass."""
ack = self._command(MSG_SLOT_INFO, bytes([slot]), MSG_SLOT_INFO_ACK) ack = self._command(MSG_SLOT_INFO, bytes([slot]), MSG_SLOT_INFO_ACK)
+4 -2
View File
@@ -85,7 +85,7 @@ def resolve_image(image):
def default_launcher(qemu: str, flash: str, elf, boot_key=None, def default_launcher(qemu: str, flash: str, elf, boot_key=None,
qmp_path: str = DEFAULT_QMP, gdb_port: int = 1234, 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. """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 `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, [qemu, "-M", machine,
"-nographic", "-monitor", "none", "-nographic", "-monitor", "none",
"-qmp", qmp_argument(qmp_path), "-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, env=env,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL) stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL)
+67 -4
View File
@@ -146,7 +146,8 @@ def image_has_multiboot(path):
def create_app(client, frame_addr: int = None, status_addr: int = None, scale: int = 4, 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__) app = Flask(__name__)
if log is None: 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)) info = dict(info, multiboot=image_has_multiboot(image.path))
return jsonify(loaded=info is not None, firmware=info, running=running_firmware()) 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") @app.post("/api/firmware")
def api_firmware_upload(): def api_firmware_upload():
"""Boot an uploaded firmware image. """Boot an uploaded firmware image.
@@ -941,6 +976,7 @@ def render_index(scale: int) -> str:
<div class="fwbar"> <div class="fwbar">
<label>Overlay apps</label> <label>Overlay apps</label>
<span id="appstate">-</span> <span id="appstate">-</span>
<button id="appask" title="ask the running firmware what it sees in each app slot">Ask the radio</button>
<span class="hint">the Labs edition's apps live in the same external flash (16 slots <span class="hint">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 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</span> install it — no serial port and no browser permission are involved</span>
@@ -1300,10 +1336,14 @@ async function loadApps() {{
: s.state === 'empty' ? '<i>Empty</i>' : s.state === 'empty' ? '<i>Empty</i>'
: '<i>' + s.state + '</i> — not an app'; : '<i>' + s.state + '</i> — not an app';
const size = s.state === 'app' ? s.code_size + ' B' : ''; 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 // 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. // region has sixteen: slot 0 is "1" there, so show them the same way.
tr.innerHTML = '<td>app ' + (s.slot + 1) + (s.slot < 8 ? '' : ' (after the menu)') + tr.innerHTML = '<td>app ' + (s.slot + 1) + (s.slot < 8 ? '' : ' (after the menu)') +
'</td><td>' + what + '</td><td>' + size + '</td><td></td>'; '</td><td>' + what + '</td><td>' + size + '</td><td>' + said + '</td><td></td>';
const td = tr.lastElementChild; const td = tr.lastElementChild;
const inp = document.createElement('input'); const inp = document.createElement('input');
inp.type = 'file'; inp.type = 'file';
@@ -1348,6 +1388,25 @@ async function loadApps() {{
}} }}
loadApps(); loadApps();
setInterval(loadApps, 15000); 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 // 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 // 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. // 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( ap.add_argument("--flash", default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"assets", "flash.img")) "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) ap.add_argument("--gdb-port", type=int, default=1234)
args = ap.parse_args() args = ap.parse_args()
@@ -1494,7 +1556,8 @@ def main() -> int:
supervisor = Supervisor( supervisor = Supervisor(
launch=default_launcher(args.qemu, flash, image, boot_key, 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) connect=connect, log=log)
if args.attach: if args.attach:
@@ -1506,7 +1569,7 @@ def main() -> int:
app = create_app(supervisor.client(), args.frame_addr, args.status_addr, app = create_app(supervisor.client(), args.frame_addr, args.status_addr,
args.scale, supervisor=supervisor, log=log, image=image, 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(f"serving on http://{args.host}:{args.port}/")
print("attached to a running emulator" if args.attach print("attached to a running emulator" if args.attach
else "emulator is OFF; press On in the browser to boot it") else "emulator is OFF; press On in the browser to boot it")