mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-05 20:47:28 +00:00
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:
1 parent
11e3678140
commit
fc432d2055
6 files changed
+187
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,都已修复
|
||||
|
||||
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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() {{
|
||||
|
||||
Reference in new issue
Block a user