Files
uv-k5-v3-emulator/tools/uvk5_lcd.py
T
mckero 4c820bdf79 Add a frame grabber that reads the LCD over QMP memsave
memsave, not pmemsave. The plan specified pmemsave on the strength of a timing
measurement that never checked the contents; it turns out pmemsave takes a
*physical* address and silently returns zeros for gFrameBuffer's virtual
address. No error, no warning -- just a permanently blank screen.

Caught by rendering a real frame and finding 0 lit pixels where the gdb path
reported 1693. With memsave the count matches exactly, and the image reads
correctly: both VFOs at 18.00000 MHz, PS/DWR/CL status bar.

Two tests guard the decisions rather than the current text: the stub client
raises if pmemsave is ever used, and a source check rejects subprocess/popen so
frame reads cannot regress onto gdb, which would halt the guest.

Measured 1.35 ms per frame with the guest still reporting status running.
2026-08-28 04:36:30 +01:00

101 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""LCD framebuffer decoding for the UV-K5 emulator.
The firmware keeps the display in gStatusLine (page 0) and gFrameBuffer
(pages 1-7), one byte per column, 8 vertical pixels per byte, LSB at the top --
the layout the ST7565 expects. Extracted from tools/screenshot.py so the web UI
and the CLI screenshotter cannot drift apart.
"""
import os
import struct
import zlib
LCD_WIDTH = 128
STATUS_ROWS = 1
FRAME_ROWS = 7
TOTAL_ROWS = STATUS_ROWS + FRAME_ROWS # 8 pages of 8 pixels = 64 lines
LCD_HEIGHT = TOTAL_ROWS * 8
FRAME_BYTES = FRAME_ROWS * LCD_WIDTH
STATUS_BYTES = LCD_WIDTH
def unpack(status: bytes, frame: bytes) -> list[list[int]]:
"""Column-major, LSB-at-top bytes -> a row-major pixel grid."""
pixels = [[0] * LCD_WIDTH for _ in range(LCD_HEIGHT)]
for page in range(TOTAL_ROWS):
src = status if page == 0 else frame[(page - 1) * LCD_WIDTH:page * LCD_WIDTH]
for col in range(LCD_WIDTH):
byte = src[col]
for bit in range(8):
if byte & (1 << bit):
pixels[page * 8 + bit][col] = 1
return pixels
def encode_png(pixels, scale: int = 4) -> bytes:
"""1-bit greyscale PNG, no third-party dependency.
Compression level 6 rather than 9: at streaming rates the CPU saving matters
more than the last few bytes on loopback.
"""
width, height = LCD_WIDTH * scale, LCD_HEIGHT * scale
raw = bytearray()
for row in pixels:
line = bytearray()
for value in row:
# Radio LCD is dark-on-light: 0 -> white, 1 -> black.
line.extend([0x00 if value else 0xFF] * scale)
for _ in range(scale):
raw.append(0) # filter type 0
raw.extend(line)
def chunk(tag: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + tag + payload
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
+ chunk(b"IEND", b""))
class FrameGrabber:
"""Reads the LCD out of guest memory over QMP.
memsave, not pmemsave. The framebuffer symbols are CPU virtual addresses;
pmemsave interprets its argument as a *physical* address and silently returns
a block of zeros for these, which renders as a blank screen with no error
anywhere. memsave takes the virtual address and returns the real contents --
verified against the gdb path, both reporting 1693 lit bits on the same frame.
QMP, not gdb: measured ~1.35 ms per frame with the guest still reporting
status "running". The gdb path used by screenshot.py halts the guest on every
attach, which is unusable for a live stream and also perturbs key debounce
timing (see AGENTS.md). Do not reintroduce gdb here.
"""
def __init__(self, client, frame_addr: int, status_addr: int,
spool_dir: str = "/dev/shm"):
self._client = client
self._frame_addr = frame_addr
self._status_addr = status_addr
# pmemsave writes to a path, so a tmpfs avoids disk I/O every frame.
self._frame_path = os.path.join(spool_dir, "uvk5-frame.bin")
self._status_path = os.path.join(spool_dir, "uvk5-status.bin")
def raw(self) -> tuple[bytes, bytes]:
"""Return (status, frame) exactly as the firmware holds them."""
self._client.command("memsave", val=self._frame_addr,
size=FRAME_BYTES, filename=self._frame_path)
self._client.command("memsave", val=self._status_addr,
size=STATUS_BYTES, filename=self._status_path)
with open(self._frame_path, "rb") as fh:
frame = fh.read(FRAME_BYTES)
with open(self._status_path, "rb") as fh:
status = fh.read(STATUS_BYTES)
return status, frame
def png(self, scale: int = 4) -> bytes:
status, frame = self.raw()
return encode_png(unpack(status, frame), scale)