mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-03 19:47:22 +00:00
Extract LCD unpacking and PNG encoding into a module
Lifted from tools/screenshot.py so the web UI cannot drift from the CLI screenshotter. Verified unpack() is identical to the original across 200 randomised trials, and the PNG IHDR matches byte for byte. encode_png() returns bytes instead of writing a file, and drops to zlib level 6: at streaming rates the CPU saving beats the last few bytes on loopback.
This commit is contained in:
1 parent
59ca82403f
commit
1906e110f1
2 files changed
+140
No files matched your search
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for LCD unpacking and PNG encoding. No emulator needed."""
|
||||
import struct
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
from uvk5_lcd import LCD_HEIGHT, LCD_WIDTH, 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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/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 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""))
|
||||
Reference in new issue
Block a user