mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
207 lines
9.2 KiB
Python
207 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for LCD unpacking and PNG encoding. No emulator needed."""
|
|
import os
|
|
import struct
|
|
import tempfile
|
|
import unittest
|
|
import zlib
|
|
|
|
import uvk5_lcd
|
|
from uvk5_lcd import (FRAME_BYTES, LCD_HEIGHT, LCD_WIDTH, STATUS_BYTES,
|
|
FrameGrabber, encode_png, unpack)
|
|
|
|
|
|
class TestUnpack(unittest.TestCase):
|
|
def test_dimensions(self):
|
|
pixels = unpack(bytes(LCD_WIDTH), bytes(7 * LCD_WIDTH))
|
|
self.assertEqual(len(pixels), LCD_HEIGHT)
|
|
self.assertEqual(len(pixels[0]), LCD_WIDTH)
|
|
|
|
def test_lsb_is_top_pixel_of_its_page(self):
|
|
# status row (page 0), column 0, bit 0 set -> pixel (0, 0)
|
|
status = bytearray(LCD_WIDTH)
|
|
status[0] = 0x01
|
|
pixels = unpack(bytes(status), bytes(7 * LCD_WIDTH))
|
|
self.assertEqual(pixels[0][0], 1)
|
|
self.assertEqual(pixels[1][0], 0)
|
|
|
|
def test_msb_is_bottom_pixel_of_its_page(self):
|
|
status = bytearray(LCD_WIDTH)
|
|
status[0] = 0x80
|
|
pixels = unpack(bytes(status), bytes(7 * LCD_WIDTH))
|
|
self.assertEqual(pixels[7][0], 1)
|
|
self.assertEqual(pixels[6][0], 0)
|
|
|
|
def test_frame_page_one_starts_at_row_eight(self):
|
|
frame = bytearray(7 * LCD_WIDTH)
|
|
frame[0] = 0x01 # page 1, column 0, top bit
|
|
pixels = unpack(bytes(LCD_WIDTH), bytes(frame))
|
|
self.assertEqual(pixels[8][0], 1)
|
|
|
|
def test_columns_are_independent(self):
|
|
frame = bytearray(7 * LCD_WIDTH)
|
|
frame[5] = 0xFF # page 1, column 5, all eight rows
|
|
pixels = unpack(bytes(LCD_WIDTH), bytes(frame))
|
|
for row in range(8, 16):
|
|
self.assertEqual(pixels[row][5], 1, f"row {row} col 5")
|
|
self.assertEqual(pixels[row][4], 0, f"row {row} col 4 should be clear")
|
|
|
|
|
|
class TestEncodePng(unittest.TestCase):
|
|
def test_emits_a_png_signature(self):
|
|
pixels = unpack(bytes(LCD_WIDTH), bytes(7 * LCD_WIDTH))
|
|
self.assertTrue(encode_png(pixels, scale=2).startswith(b"\x89PNG\r\n\x1a\n"))
|
|
|
|
def test_header_reports_scaled_dimensions(self):
|
|
pixels = unpack(bytes(LCD_WIDTH), bytes(7 * LCD_WIDTH))
|
|
blob = encode_png(pixels, scale=3)
|
|
# IHDR payload starts 8 bytes of signature + 4 length + 4 tag in.
|
|
width, height = struct.unpack(">II", blob[16:24])
|
|
self.assertEqual((width, height), (LCD_WIDTH * 3, LCD_HEIGHT * 3))
|
|
|
|
def test_pixels_are_dark_on_light(self):
|
|
"""A set bit must render black; the radio LCD is dark-on-light."""
|
|
status = bytearray(LCD_WIDTH)
|
|
status[0] = 0x01 # top-left pixel on
|
|
pixels = unpack(bytes(status), bytes(7 * LCD_WIDTH))
|
|
blob = encode_png(pixels, scale=1)
|
|
|
|
# Pull IDAT back out and inflate it to check actual sample values.
|
|
offset, idat = 8, b""
|
|
while offset < len(blob):
|
|
(length,) = struct.unpack(">I", blob[offset:offset + 4])
|
|
tag = blob[offset + 4:offset + 8]
|
|
if tag == b"IDAT":
|
|
idat += blob[offset + 8:offset + 8 + length]
|
|
offset += 12 + length
|
|
raw = zlib.decompress(idat)
|
|
|
|
# Each row is one filter byte followed by LCD_WIDTH samples at scale 1.
|
|
self.assertEqual(raw[0], 0) # filter type 0
|
|
self.assertEqual(raw[1], 0x00) # lit pixel -> black
|
|
self.assertEqual(raw[2], 0xFF) # neighbour -> white
|
|
|
|
|
|
class StubClient:
|
|
"""Stands in for QmpClient; writes the files memsave would write.
|
|
|
|
Rejects pmemsave on purpose. pmemsave takes a *physical* address and returns
|
|
zeros for the framebuffer's virtual address -- a blank screen with no error
|
|
reported anywhere, which is exactly the bug this stub is here to catch.
|
|
"""
|
|
|
|
def __init__(self, invert=False, contrast=31, display_on=True):
|
|
self.calls = []
|
|
self.panel = {"invert": invert, "contrast": contrast,
|
|
"display-on": display_on}
|
|
|
|
def command(self, name, **args):
|
|
self.calls.append((name, args))
|
|
if name == "pmemsave":
|
|
raise AssertionError(
|
|
"pmemsave reads physical addresses and silently returns zeros "
|
|
"for gFrameBuffer; use memsave")
|
|
if name == "qom-get":
|
|
# The panel's own settings, which are not in the framebuffer at all.
|
|
if args.get("path") != uvk5_lcd.PANEL_PATH:
|
|
raise AssertionError(f"unexpected qom-get path {args.get('path')}")
|
|
return self.panel[args["property"]]
|
|
if name != "memsave":
|
|
raise AssertionError(f"unexpected command {name}")
|
|
with open(args["filename"], "wb") as fh:
|
|
fh.write(bytes(args["size"]))
|
|
return {}
|
|
|
|
|
|
class TestFrameGrabber(unittest.TestCase):
|
|
def test_uses_memsave_and_returns_png(self):
|
|
client = StubClient()
|
|
tmp = tempfile.mkdtemp()
|
|
grabber = FrameGrabber(client, frame_addr=0x200013DC,
|
|
status_addr=0x2000175C, spool_dir=tmp)
|
|
png = grabber.png(scale=2)
|
|
|
|
self.assertTrue(png.startswith(b"\x89PNG\r\n\x1a\n"))
|
|
# Two reads, in frame-then-status order, followed by the panel's own
|
|
# settings -- inversion and contrast live in the controller, not in RAM.
|
|
self.assertEqual([c[0] for c in client.calls],
|
|
["memsave", "memsave"] + ["qom-get"] * 3)
|
|
|
|
def test_panel_inversion_flips_the_picture(self):
|
|
"""SetInv (0xA7) changes no byte of gFrameBuffer, so the render has to follow
|
|
the controller or the menu entry looks like it did nothing."""
|
|
tmp = tempfile.mkdtemp()
|
|
normal = FrameGrabber(StubClient(invert=False), 0x1000, 0x2000, spool_dir=tmp)
|
|
flipped = FrameGrabber(StubClient(invert=True), 0x1000, 0x2000,
|
|
spool_dir=tempfile.mkdtemp())
|
|
self.assertNotEqual(normal.png(scale=1), flipped.png(scale=1))
|
|
|
|
def test_display_off_is_reported_but_does_not_blank_the_frame(self):
|
|
"""Whether a software reset (0xE2) clears the display-on latch is not certain,
|
|
so the flag is reported rather than acted on: blanking the screen on a guess
|
|
would be worse than leaving the image alone."""
|
|
off = FrameGrabber(StubClient(display_on=False), 0x1000, 0x2000,
|
|
spool_dir=tempfile.mkdtemp())
|
|
on = FrameGrabber(StubClient(display_on=True), 0x1000, 0x2000,
|
|
spool_dir=tempfile.mkdtemp())
|
|
self.assertFalse(off.panel_state()[2])
|
|
self.assertEqual(off.png(scale=1), on.png(scale=1))
|
|
|
|
def test_reads_the_right_addresses_and_sizes(self):
|
|
client = StubClient()
|
|
grabber = FrameGrabber(client, frame_addr=0x200013DC,
|
|
status_addr=0x2000175C,
|
|
spool_dir=tempfile.mkdtemp())
|
|
grabber.raw()
|
|
|
|
by_addr = {args["val"]: args["size"] for _, args in client.calls}
|
|
self.assertEqual(by_addr[0x200013DC], FRAME_BYTES)
|
|
self.assertEqual(by_addr[0x2000175C], STATUS_BYTES)
|
|
|
|
def test_raw_returns_status_then_frame(self):
|
|
client = StubClient()
|
|
grabber = FrameGrabber(client, frame_addr=0x1000, status_addr=0x2000,
|
|
spool_dir=tempfile.mkdtemp())
|
|
status, frame = grabber.raw()
|
|
self.assertEqual(len(status), STATUS_BYTES)
|
|
self.assertEqual(len(frame), FRAME_BYTES)
|
|
|
|
def test_never_uses_pmemsave(self):
|
|
"""Regression guard: pmemsave silently reads the wrong memory.
|
|
|
|
The framebuffer symbols are CPU virtual addresses. pmemsave treats its
|
|
argument as physical and returns zeros, so the screen renders blank with
|
|
no error raised. Verified against a live emulator: pmemsave gave 0 lit
|
|
bits, memsave gave 1693, matching the gdb path exactly.
|
|
"""
|
|
source = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"uvk5_lcd.py")).read()
|
|
code = "\n".join(line for line in source.splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
and not line.lstrip().startswith("*"))
|
|
# Allow the word inside the explanatory docstring, forbid a real call.
|
|
self.assertNotIn('command("pmemsave"', code)
|
|
|
|
def test_does_not_invoke_gdb(self):
|
|
"""Guard the design decision, not just the current behaviour.
|
|
|
|
A gdb attach halts the guest, which would both stutter a live stream and
|
|
perturb key debounce timing (see AGENTS.md). pmemsave leaves the guest
|
|
running -- measured ~1.3 ms per frame with status still "running".
|
|
|
|
Checks for the machinery needed to shell out, not for the word "gdb":
|
|
the module mentions gdb in prose precisely to explain why it is avoided.
|
|
"""
|
|
source = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"uvk5_lcd.py")).read()
|
|
code = "\n".join(line for line in source.splitlines()
|
|
if not line.lstrip().startswith("#"))
|
|
for forbidden in ("subprocess", "os.system", "popen", "gdb-multiarch"):
|
|
self.assertNotIn(forbidden, code.lower(),
|
|
f"{forbidden} must not appear: reading frames has "
|
|
"to stay on the QMP path")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|