From 1906e110f1c8fe7408d01d1f7d31639dc08a4104 Mon Sep 17 00:00:00 2001 From: MCKero Date: Fri, 28 Aug 2026 04:30:45 +0100 Subject: [PATCH] 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. --- tools/test_uvk5_lcd.py | 82 ++++++++++++++++++++++++++++++++++++++++++ tools/uvk5_lcd.py | 58 ++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tools/test_uvk5_lcd.py create mode 100644 tools/uvk5_lcd.py diff --git a/tools/test_uvk5_lcd.py b/tools/test_uvk5_lcd.py new file mode 100644 index 0000000..931b246 --- /dev/null +++ b/tools/test_uvk5_lcd.py @@ -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() diff --git a/tools/uvk5_lcd.py b/tools/uvk5_lcd.py new file mode 100644 index 0000000..ca2a424 --- /dev/null +++ b/tools/uvk5_lcd.py @@ -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""))