Fix the two pixel bugs behind "the other firmware looks shifted"

uvk5_stream.py used STATUS_BYTES without importing it, so the panel branch raised NameError on every frame and a bare except swallowed it: every screen the page drew came from guest RAM at one build's addresses. The pump now reports which source it used and why, webui exposes it (/api/panel and frame_source), and test_uvk5_stream asserts the panel wins when reachable and that a fallback is announced.

The ST7565 column counter wrapped at 128 instead of the controller's 132, so addresses 128..131 came back as 0..3, fell outside the col>=4 store, and were dropped: every row lost its last four pixels, which is where the battery icon lives. Pre-fix, filling a page with 0xFF left columns 124..127 blank; now they carry content and the page's frame matches the panel memory 8192/8192.
This commit is contained in:
mckero committed 2026-10-01 15:59:39 +08:00
1 parent f4c9d343fc
commit 57106c0a66
6 files changed
+202 -7

No files matched your search

+62
View File
@@ -5,6 +5,7 @@ import threading
import time
import unittest
from uvk5_lcd import LCD_HEIGHT, LCD_WIDTH, STATUS_BYTES, TOTAL_ROWS, unpack
from uvk5_stream import FramePump
@@ -130,5 +131,66 @@ class TestFramePump(unittest.TestCase):
self.assertTrue(wait_for_frame(pump), "did not resume after rebind")
class FakePanel:
"""A grabber whose panel works, and whose guest RAM must never be touched.
read() raises, so any test that silently takes the fallback fails loudly instead of
comparing two wrong pictures -- which is how the bug below stayed invisible: the
fake client used by the tests above answers memsave and nothing else, so the panel
branch failed there too, the bare except swallowed it, and every one of them was
really exercising the fallback.
"""
def __init__(self):
self.gram = bytes((i * 7) & 0xFF for i in range(TOTAL_ROWS * LCD_WIDTH))
def panel_gram(self):
return self.gram
def panel_pixels(self):
return unpack(self.gram[:STATUS_BYTES], self.gram[STATUS_BYTES:])
def raw(self):
raise AssertionError("guest RAM was read while the panel was available")
class TestFrameSource(unittest.TestCase):
"""Which memory the page draws is not a detail -- it decides whose screen is right.
The panel is the only firmware-independent source: every build pushes pixels through
the same controller, while guest RAM is correct only for the build whose buffer
addresses were passed in. So when the panel is reachable it must be used.
uvk5_stream.py used STATUS_BYTES without importing it. The panel branch therefore
raised NameError on every frame, a bare except swallowed it, and every screen the
page drew came from guest RAM at one firmware's addresses -- right-looking for that
firmware, plausible and offset for any other. These two tests fail on that code.
"""
def test_the_panel_is_used_when_it_is_there(self):
pump = FramePump(None, 0, 0)
status, frame, pixels = pump._grab(FakePanel())
self.assertEqual(pump.source()[0], "panel",
"the panel is reachable, so guest RAM must not be used")
self.assertIsNone(pump.source()[1])
self.assertEqual(len(status), STATUS_BYTES)
self.assertEqual(len(frame), TOTAL_ROWS * LCD_WIDTH - STATUS_BYTES)
# unpack() returns one entry per LCD line, not per page: eight pages of eight.
self.assertEqual(len(pixels), LCD_HEIGHT)
self.assertEqual(len(pixels[0]), LCD_WIDTH)
def test_a_broken_panel_falls_back_and_says_why(self):
notes = []
pump = FramePump(None, 0, 0, on_fallback=notes.append)
broken = FakePanel()
broken.panel_gram = lambda: (_ for _ in ()).throw(RuntimeError("no panel model"))
broken.raw = lambda: (b"\x00" * STATUS_BYTES,
b"\x00" * (TOTAL_ROWS * LCD_WIDTH - STATUS_BYTES))
pump._grab(broken)
self.assertEqual(pump.source()[0], "framebuffer")
self.assertIn("no panel model", pump.source()[1])
self.assertEqual(len(notes), 1, "the fallback is announced once, with its reason")
if __name__ == "__main__":
unittest.main()
+40 -3
View File
@@ -17,12 +17,24 @@ looks live.
import threading
import time
from uvk5_lcd import FrameGrabber, default_spool_dir, encode_png, unpack
# STATUS_BYTES is not decoration: _grab() splits the controller's memory into the
# status line and the frame with it. It was missing from this import for the whole
# life of the panel path, so the panel branch raised NameError on every frame, the
# bare except below swallowed it, and every picture the page ever drew came from the
# guest-RAM fallback instead -- which needs *that build's* buffer addresses. With the
# CN addresses and a CN firmware that looked right; with any other firmware the screen
# was plausible and offset, which is exactly how it was reported.
from uvk5_lcd import STATUS_BYTES, FrameGrabber, default_spool_dir, encode_png, unpack
class FramePump:
def __init__(self, client, frame_addr: int, status_addr: int,
fps: int = 15, scale: int = 4, spool_dir: str = None):
fps: int = 15, scale: int = 4, spool_dir: str = None,
on_fallback=None):
# Called once with the reason the first time a frame has to come from guest RAM.
# It used to be swallowed whole, which is how a NameError in the panel branch
# went unnoticed for as long as the fallback kept producing plausible pictures.
self._on_fallback = on_fallback
self._frame_addr = frame_addr
self._status_addr = status_addr
# Resolved by FrameGrabber: /dev/shm on Linux, the temp directory on
@@ -36,6 +48,13 @@ class FramePump:
self._png = None
self._raw = None
self._generation = 0
# Which memory the pixels came from. The panel is preferred and is the only
# source that is firmware-independent; the guest-RAM fallback needs that
# build's own buffer addresses, so getting it wrong shows up as a picture
# that is plausible but offset. Reporting it is what turns "the screen looks
# wrong" into "it came from the fallback, and here is why".
self._source = None
self._note = None
self._stop = threading.Event()
self._thread = None
@@ -91,6 +110,11 @@ class FramePump:
self._stop.wait(slack)
def source(self):
"""(which memory the last frame came from, why the fallback happened)."""
with self._lock:
return self._source, self._note
def _grab(self, grabber):
"""One frame: (status, frame, pixels), from the panel if it is there.
@@ -105,8 +129,21 @@ class FramePump:
try:
pixels = grabber.panel_pixels()
gram = grabber.panel_gram()
self._source = "panel"
self._note = None
return gram[:STATUS_BYTES], gram[STATUS_BYTES:], pixels
except Exception:
except Exception as exc:
# Remember the first reason rather than overwriting it every frame: the
# cause does not change while the emulator runs, and the first one is
# the informative one.
if self._source != "framebuffer":
self._note = "%s: %s" % (type(exc).__name__, exc)
if self._on_fallback is not None:
try:
self._on_fallback(self._note)
except Exception:
pass
self._source = "framebuffer"
status, frame = grabber.raw()
return status, frame, unpack(status, frame)
def latest(self):
+32 -2
View File
@@ -154,7 +154,10 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4,
# One background grabber for every client. client may be None: the emulator
# can be powered off, and the page still has to load.
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale)
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale,
on_fallback=lambda note: log.add(
"qemu", "panel unavailable, drawing from guest RAM at "
f"0x{frame_addr:08X}: {note}"))
pump.start()
app.config["PUMP"] = pump
app.config["SUPERVISOR"] = supervisor
@@ -270,7 +273,34 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4,
# The emulator can die under us; that is a state to report, not a 500.
return jsonify(powered=False, status="unreachable", error=str(exc))
return jsonify(powered=True, speaker=speaker_on(),
panel=panel_state(), firmware=firmware_info(), **info)
panel=panel_state(), firmware=firmware_info(),
frame_source=pump.source()[0], **info)
@app.get("/api/panel")
def api_panel():
"""The display controller's own memory, and where /frame.png came from.
/frame.png prefers the panel and falls back to guest RAM when the panel model
is not there. The fallback needs the addresses of *that* build's buffers, so
it is the path that can look right and be offset at the same time -- and a
caller cannot tell which it got from the picture. This says which, gives the
reason for a fallback, and hands back the controller's own bytes so the two
can be compared without guessing.
"""
source, note = pump.source()
body = {"source": source, "note": note,
"frame_addr": frame_addr, "status_addr": status_addr}
target = active_client()
if target is None:
return jsonify(body, powered=False)
try:
body["gram"] = target.command("qom-get", path=PANEL_PATH, property="gram")
body["invert"] = bool(target.command("qom-get", path=PANEL_PATH,
property="invert"))
body["bytes"] = len(body["gram"]) // 2
except Exception as exc:
body["panel_error"] = str(exc)
return jsonify(body)
@app.get("/api/firmware")
def api_firmware():