Report what the device says it is running, not the file we handed it

The page only knew its own input. A build called f4hwn.fusion.bin reports EGZUMER+F4HWN v6.0.0.CN, and with the multi-system release a committed external slot makes the factory bootloader reflash the internal flash from that slot on every power-on -- so the uploaded image never runs and the page keeps naming it. The firmware prints its own banner on USART1; tools/uvk5_banner.py reads it back, /api/firmware returns running: {banner, matches_uploaded, note}, and the page shows what the device reports, flagging it only when the running version is not in the uploaded image at all.

That reader also exposed a regression of my own: _start_stderr_pump had been rewritten to read the pipe in 64 KB chunks, which kept QEMU from blocking but delivered nothing to the log until 64 KB had accumulated -- and the banner is forty bytes, so it never appeared. It reads lines again, still starting before anything waits on QEMU, and test_uvk5_supervisor passes either way.
This commit is contained in:
mckero committed 2026-10-01 16:22:44 +08:00
1 parent 11e3678140
commit fc432d2055
6 files changed
+187 -21

No files matched your search

+24
View File
@@ -526,6 +526,30 @@ The panel path needs none of this, and is what the page draws from: the controll
memory is the screen for every firmware. The addresses only serve the guest-RAM
fallback, which is why a failed search is reported and does not stop anything.
### The page must report what the device says it is running
The page knew only the file it had been handed, and those are different questions. A build
called `f4hwn.fusion.bin` can report `EGZUMER+F4HWN v6.0.0.CN` -- that one does -- so
"it still boots the CN version" was the page describing its input, not the radio. Worse,
with the multi-system release a committed external slot plus a valid state marker makes the
factory bootloader reflash the internal flash from that slot on every power-on: the uploaded
image is overwritten before it runs, and the page keeps naming a file that never executed.
The firmware answers the question itself. It prints `UV-K5 Firmware, ...` on USART1, the
machine tags it SERIAL, and `tools/uvk5_banner.py` reads it back. `/api/firmware` now
returns `running: {banner, matches_uploaded, note}` and the page shows `device reports: ...`,
flagging it only when the running version is not in the uploaded image at all -- because a
file name that differs from a banner usually just is a different name, and a hint that cries
wolf gets ignored.
Reading that banner back also exposed a bug of my own. It had stopped reaching the log
entirely: `_start_stderr_pump` was rewritten to read the pipe in 64 KB chunks so QEMU could
not block on it. That kept the deadlock fixed and silently lost the other half -- nothing
arrived until 64 KB had accumulated, and the banner is forty bytes. It reads lines again,
and still starts before anything waits on QEMU. **A rewrite that preserves the property you
were fixing while losing another is the expensive kind**, and this one hid because the log
still "worked" for the binary screen stream.
## The keypad: two real bugs, both fixed
The old note here said "keys reach the firmware but the UI does not react" and
+19
View File
@@ -425,6 +425,25 @@ PTT+SIDE1/SIDE2 与 MENU(那是应用的几个特殊模式)、开机窗口
面板路径完全不需要这些,而页面画的正是它:对任何固件,控制器的显存就是屏幕。这些地址只服务
guest RAM 回落路径 —— 所以搜索失败会被报出来,而不会挡住任何东西。
### 页面必须报告**设备自己说**它在跑什么
页面只知道它被交给的那个文件,而这是两个不同的问题。一个叫 `f4hwn.fusion.bin` 的构建,
报出的是 `EGZUMER+F4HWN v6.0.0.CN` —— 你那份就是 —— 所以「开机还是 CN 版」其实是页面在描述
**自己的输入**,不是电台。更麻烦的是:多系统版里,只要外部槽已提交且状态标记有效,出厂引导
**每次开机都会用那个槽重刷内部 flash**,于是你上传的镜像在被执行之前就被覆盖,而页面还在显示
一个从未运行过的文件名。
固件自己会回答这个问题:它在 USART1 上打印 `UV-K5 Firmware, ...`,模型给它打上 SERIAL 标记,
`tools/uvk5_banner.py` 把它读回来。`/api/firmware` 现在返回 `running: {banner,
matches_uploaded, note}`,页面显示 `device reports: ...`,**只在"设备报的版本根本不在你上传的
镜像里"时才提示** —— 因为文件名与横幅不同通常只是名字不同,而一个乱报警的提示最后会被无视。
把这条横幅读回来,也顺带暴露了我自己写的一个 bug:横幅**已经完全进不了日志**了 ——
`_start_stderr_pump` 被改成按 64 KB 读管道,以免 QEMU 阻塞。死锁确实因此没回来,但另一半被
悄悄丢掉:攒够 64 KB 之前什么都收不到,而横幅只有 40 字节。现在改回按行读,并且**仍然在等待
QEMU 之前就开始排空**。**一次既保住你要修的性质、又悄悄丢掉另一种性质的改写,是最贵的那种** ——
而这次它藏得住,是因为对那条二进制屏幕流来说,日志"看起来还是好的"。
## 键盘:两个真 bug,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""The page must report what the device says, not what it was asked to boot."""
import os
import tempfile
import unittest
import uvk5_banner
class TestBanner(unittest.TestCase):
def test_it_finds_the_last_banner(self):
lines = ["[qemu] SERIAL booting", "[qemu] SERIAL UV-K5 Firmware, EGZUMER+F4HWN v6.0.0.CN",
"[qemu] SERIAL noise", "[qemu] SERIAL UV-K5 Firmware, EGZUMER+F4HWN v5.9.0.CN"]
self.assertEqual(uvk5_banner.latest(lines), "EGZUMER+F4HWN v5.9.0.CN")
def test_no_banner_is_not_a_banner(self):
self.assertIsNone(uvk5_banner.latest(["[qemu] SERIAL nothing to see", ""]))
def test_a_name_that_differs_from_the_banner_is_not_a_mismatch(self):
"""f4hwn.fusion.bin legitimately reports v6.0.0.CN -- the string is in the file."""
path = os.path.join(tempfile.mkdtemp(), "f4hwn.fusion.bin")
with open(path, "wb") as fh:
fh.write(b"\x00" * 32 + b"EGZUMER+F4HWN v6.0.0.CN" + b"\x00" * 32)
self.assertTrue(uvk5_banner.image_mentions(path, "EGZUMER+F4HWN v6.0.0.CN"))
def test_a_banner_the_image_does_not_contain_is_a_mismatch(self):
"""This is the 'the bootloader restored a slot instead' case."""
path = os.path.join(tempfile.mkdtemp(), "wanted.bin")
with open(path, "wb") as fh:
fh.write(b"\x00" * 64 + b"EGZUMER+F4HWN v5.9.0.CN" + b"\x00" * 64)
self.assertFalse(uvk5_banner.image_mentions(path, "EGZUMER+F4HWN v6.0.0.CN"))
def test_a_missing_file_does_not_accuse_anything(self):
self.assertTrue(uvk5_banner.image_mentions("/nonexistent/fw.bin", "anything"))
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""What the device says it is running, and whether that is what was uploaded.
The page knew only what it had *asked* to boot: the image it was given, or the one
uploaded through it. That is not the same question. With the multi-system release, an
external slot plus a valid state marker makes the factory bootloader reflash the
internal flash from that slot on every power-on -- so the uploaded image is overwritten
before it ever runs, the page keeps saying "f4hwn.fusion.bin", and the radio is running
something else entirely.
The firmware answers the question itself: it prints a banner on USART1 in its first
seconds ("UV-K5 Firmware, EGZUMER+F4HWN v6.0.0.CN"), which the machine tags SERIAL and
the page already collects. Reading that back is the difference between what we asked for
and what is running.
The second half matters too: a banner *should* often differ from a file name -- a file
called f4hwn.fusion.bin can legitimately report v6.0.0.CN -- so a difference is only
suspicious when the running version is not in the uploaded image at all. That check is
`image_mentions`, and it is what makes the hint honest rather than a false alarm.
"""
import re
BANNER = re.compile(r"UV-K5 Firmware,\s*(.+?)\s*$")
def latest(lines):
"""The most recent firmware banner in an iterable of log lines, or None."""
found = None
for line in lines:
match = BANNER.search(str(line))
if match:
found = match.group(1).strip()
return found
def image_mentions(path, banner) -> bool:
"""Does this firmware file contain the string the device reported?
A version string lives in the image, so an image whose banner this is will contain
it. If it does not, the device is running something else -- which is the case that
needs explaining (a slot restored by the bootloader, most often).
"""
if not path or not banner:
return True # nothing to check against; do not accuse anything
try:
with open(path, "rb") as fh:
blob = fh.read()
except OSError:
return True # unreadable is not evidence of a mismatch
return banner.encode("ascii", "ignore") in blob
+21 -20
View File
@@ -218,32 +218,33 @@ class Supervisor:
reached in about a second.
Measured: with the pipe drained the launcher's QEMU accepts QMP in 0.5 s; with it
left unread, the same command line never answers at all. So: read in fixed-size
chunks (a readline() on a stream with no newlines hoards it), decode leniently, and
swallow anything the log throws -- a logging failure must not become a stopped
drain, which is a deadlock rather than a lost line.
left unread, the same command line never answers at all. So the drain starts here,
before anything waits on QEMU, and swallowing what the log throws -- a logging
failure must not become a stopped drain, which is a deadlock rather than a lost
line. It reads lines, for the reason the drain below records.
"""
stream = getattr(proc, "stderr", None)
if stream is None:
return False
def drain():
while True:
try:
chunk = stream.read(65536)
except Exception:
return
if not chunk:
return
if self._log is None:
continue
try:
text = chunk.decode("utf-8", "replace")
for line in text.splitlines():
if line:
self._log.add("qemu", line[:400])
except Exception:
pass
# Line by line, through the log's own reader: it is what tags the model's
# SERIAL output and summarises binary lines, and it is the path the tests
# cover. Reading happens in the background from the moment QEMU starts,
# which is what keeps the pipe from filling -- a readline() still pulls
# whole chunks out of the pipe, it just does not *return* until it has a
# line, so a stream of binary without newlines is buffered, not stalled.
#
# The first version of this used a fixed 64 KB read() to avoid that
# buffering: it drained the pipe, so the deadlock stayed fixed, but nothing
# reached the log until 64 KB had accumulated -- and the banner naming the
# running firmware is forty bytes, so it never appeared at all.
if self._log is None:
return
try:
self._log.pump_stream(stream, default_source="qemu")
except Exception:
pass
threading.Thread(target=drain, daemon=True).start()
return True
+35 -1
View File
@@ -342,12 +342,40 @@ def create_app(client, frame_addr: int = None, status_addr: int = None, scale: i
body["panel_error"] = str(exc)
return jsonify(body)
def running_firmware():
"""What the device says it is running, which is not always what we asked for.
The page knows the image it was given, and that is a different question. With the
multi-system release, a committed external slot plus a valid state marker makes the
factory bootloader reflash the internal flash from that slot on every power-on, so
the uploaded image is overwritten before it runs and the page keeps naming a file
the radio never executed. The firmware prints its own banner on USART1, which the
page already collects, so it is read back from there (tools/uvk5_banner.py) -- and
only called a mismatch when the running version is not in the uploaded image at
all, because a file called f4hwn.fusion.bin legitimately reports v6.0.0.CN.
"""
import uvk5_banner
try:
banner = uvk5_banner.latest(entry.get("text", "") for entry in log.entries(since=0))
except Exception:
return None
if not banner:
return None
path = image.current.path if image is not None and image.current else None
matches = uvk5_banner.image_mentions(path, banner)
note = None
if not matches:
note = ("the device reports %s, which is not in the image we asked it to boot: "
"the bootloader most likely restored the internal flash from an "
"external slot first" % banner)
return {"banner": banner, "matches_uploaded": matches, "note": note}
@app.get("/api/firmware")
def api_firmware():
info = firmware_info()
if info is not None and image is not None:
info = dict(info, multiboot=image_has_multiboot(image.path))
return jsonify(loaded=info is not None, firmware=info)
return jsonify(loaded=info is not None, firmware=info, running=running_firmware())
@app.post("/api/firmware")
def api_firmware_upload():
@@ -1215,6 +1243,12 @@ document.addEventListener('drop', (e) => {{
function fwLabel(fw) {{
let s = fw.name + ' (' + fw.kind + ')';
if (fw.multiboot === false) s += ' - no multi-system menu';
// What the device *says* it is running, which is not always the file we handed it:
// a committed external slot makes the bootloader reflash the radio first.
if (fw.running && fw.running.banner) {{
s += ' | device reports: ' + fw.running.banner;
if (fw.running.matches_uploaded === false) s += ' <-- NOT this image';
}}
return s;
}}
async function pollFirmware() {{