diff --git a/AGENTS.md b/AGENTS.md index 2b8798a..feefde9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md index b62c6c5..60540be 100644 --- a/AGENTS.zh-CN.md +++ b/AGENTS.zh-CN.md @@ -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,都已修复 这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个 diff --git a/tools/test_uvk5_banner.py b/tools/test_uvk5_banner.py new file mode 100644 index 0000000..8ea3c7c --- /dev/null +++ b/tools/test_uvk5_banner.py @@ -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() diff --git a/tools/uvk5_banner.py b/tools/uvk5_banner.py new file mode 100644 index 0000000..691f005 --- /dev/null +++ b/tools/uvk5_banner.py @@ -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 diff --git a/tools/uvk5_supervisor.py b/tools/uvk5_supervisor.py index 3967e2c..fcc5c65 100644 --- a/tools/uvk5_supervisor.py +++ b/tools/uvk5_supervisor.py @@ -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 diff --git a/tools/webui.py b/tools/webui.py index ef1f9f7..7215278 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -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() {{