mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
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:
1 parent
0267371e28
commit
b855b0b047
9 files changed
+169
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,都已修复
|
||||
|
||||
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
|
||||
|
||||
@@ -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/<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` |
|
||||
| `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
|
||||
|
||||
@@ -302,6 +302,7 @@ uvk5_elf.sh 探针脚本从哪里找固件(环境变量,然后本
|
||||
| `GET /api/apps` | 同一 flash 镜像里 Labs 版的叠加应用槽 |
|
||||
| `POST /api/apps/<n>` | 请求体是一个 `.app`;装进应用槽 `n`(加 `?force=1` 可覆盖非应用数据) |
|
||||
| `POST /api/apps/<n>/erase` | 清空应用槽 `n` |
|
||||
| `GET /api/apps/radio` | 问正在运行的固件它在每个应用槽里看到了什么(`0x0730`) |
|
||||
| `POST /api/flash` | 请求体是一份 flash 镜像;之后就用它 |
|
||||
|
||||
画面现在取自显示控制器自己的内存:对面板的 `gram` 属性做一次 QMP `qom-get`。
|
||||
|
||||
@@ -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"])
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+67
-4
@@ -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:
|
||||
<div class="fwbar">
|
||||
<label>Overlay apps</label>
|
||||
<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
|
||||
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>
|
||||
@@ -1300,10 +1336,14 @@ async function loadApps() {{
|
||||
: s.state === 'empty' ? '<i>Empty</i>'
|
||||
: '<i>' + s.state + '</i> — 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 = '<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 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")
|
||||
|
||||
Reference in new issue
Block a user