diff --git a/README.md b/README.md index e414b10..de04d40 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,17 @@ python tools/generate_font.py /path/to/Montserrat-SemiBold.otf font_data.py The generated font data is distributed under the SIL Open Font License 1.1 in `FONT_LICENSE.txt`. +## Startup splash + +At startup, the timer displays the supplied Caterham artwork on a black background sized for the 240x240 round display. The image is stored as a native `startup_splash.rgb565` framebuffer and loaded directly into the LCD's existing buffer, avoiding a second full-screen allocation on the RP2040. If the asset is absent or has the wrong size, the original text splash is shown instead. + +The original artwork and a device-layout preview are kept under `assets/`. To regenerate the runtime asset after changing the source image, install Pillow and run: + +```sh +python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \ + --preview assets/startup_splash_preview.png +``` + ## Supported hardware Version 3.3 supports the integrated [Waveshare RP2040-Touch-LCD-1.28](https://www.waveshare.com/product/rp2040-touch-lcd-1.28.htm). This board combines the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported. @@ -96,7 +107,7 @@ The second command should identify an RP2040 MicroPython board. Run these commands from the repository root. Supporting files and font assets are copied first; `main.py` is installed last as the automatic entry point. ```sh -mpremote connect auto fs cp configuration.py font_data.py font_renderer.py launch.py lcd_1inch28.py params.json qmi8658.py settings.py timing.py touch_drive.py font_data*.bin : +mpremote connect auto fs cp configuration.py font_data.py font_renderer.py launch.py lcd_1inch28.py params.json qmi8658.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : mpremote connect auto fs cp main.py : mpremote connect auto reset ``` @@ -111,11 +122,12 @@ When upgrading an existing device, omit that command so its saved track duration ### 4. Verify first boot -The display should show the v3.3 splash and then the green **Ready** screen. The serial console should report the loaded user parameters, `Success:Detected CST816T.`, and the touchscreen revision without a traceback. +The display should show the Caterham v3.3 splash and then the green **Ready** screen. The serial console should report the loaded user parameters, `Success:Detected CST816T.`, and the touchscreen revision without a traceback. If first boot fails: * `OSError: Font bitmap is missing or truncated` means one or more `font_data*.bin` files were not copied. +* The original text-only splash means `startup_splash.rgb565` is missing or has the wrong size; repeat the application upload command. * An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last. * No serial device after flashing usually indicates a charge-only USB cable, an incorrect UF2, or a board still in BOOT mode. * A missing touchscreen or IMU error indicates unsupported hardware or a board-level connection problem. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..4bd0b5f --- /dev/null +++ b/assets/README.md @@ -0,0 +1,12 @@ +# Startup splash source + +`startup_splash.gif` is the user-supplied source artwork from: + +https://static.wixstatic.com/media/467f72_bdf20d6c823c42a18bc41c04e17e7345~mv2.gif + +The runtime asset and preview are regenerated with: + +```sh +python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \ + --preview assets/startup_splash_preview.png +``` diff --git a/assets/startup_splash.gif b/assets/startup_splash.gif new file mode 100644 index 0000000..7292688 Binary files /dev/null and b/assets/startup_splash.gif differ diff --git a/assets/startup_splash_preview.png b/assets/startup_splash_preview.png new file mode 100644 index 0000000..90a2af2 Binary files /dev/null and b/assets/startup_splash_preview.png differ diff --git a/splash.py b/splash.py new file mode 100644 index 0000000..f0421e3 --- /dev/null +++ b/splash.py @@ -0,0 +1,31 @@ +"""Memory-efficient loading of the startup splash framebuffer.""" + + +SPLASH_FILE = "startup_splash.rgb565" +READ_CHUNK_BYTES = 2048 + + +def load_splash(surface, path=SPLASH_FILE): + """Load an RGB565 image into an existing framebuffer without duplicating it.""" + buffer = surface.buffer + expected_size = len(buffer) + + try: + with open(path, "rb") as splash_file: + splash_file.seek(0, 2) + if splash_file.tell() != expected_size: + return False + splash_file.seek(0) + + target = memoryview(buffer) + offset = 0 + while offset < expected_size: + end = min(offset + READ_CHUNK_BYTES, expected_size) + bytes_read = splash_file.readinto(target[offset:end]) + if not bytes_read: + return False + offset += bytes_read + except OSError: + return False + + return True diff --git a/startup_splash.rgb565 b/startup_splash.rgb565 new file mode 100644 index 0000000..d1b7bc6 Binary files /dev/null and b/startup_splash.rgb565 differ diff --git a/tests/test_splash.py b/tests/test_splash.py new file mode 100644 index 0000000..6c2bb82 --- /dev/null +++ b/tests/test_splash.py @@ -0,0 +1,66 @@ +import tempfile +import unittest +from pathlib import Path + +from splash import load_splash +from tools.convert_splash import to_framebuffer_bytes + + +class FakeSurface: + def __init__(self, size=32, value=0): + self.buffer = bytearray([value] * size) + + +class FakeImage: + width = 3 + height = 1 + + def getdata(self): + return [(255, 0, 0), (0, 255, 0), (0, 0, 255)] + + +class SplashTests(unittest.TestCase): + def test_converter_uses_gc9a01_rgb565_byte_order(self): + self.assertEqual( + bytes((0xF8, 0x00, 0x07, 0xE0, 0x00, 0x1F)), + to_framebuffer_bytes(FakeImage()), + ) + + def test_loads_exact_asset_into_existing_buffer(self): + expected = bytes(range(32)) + surface = FakeSurface() + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "splash.rgb565" + path.write_bytes(expected) + + self.assertTrue(load_splash(surface, str(path))) + + self.assertEqual(expected, surface.buffer) + + def test_missing_asset_returns_false_without_changing_buffer(self): + surface = FakeSurface(value=7) + + self.assertFalse(load_splash(surface, "/missing/splash.rgb565")) + + self.assertEqual(bytes([7] * 32), surface.buffer) + + def test_wrong_sized_asset_returns_false_without_changing_buffer(self): + surface = FakeSurface(value=9) + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "splash.rgb565" + path.write_bytes(bytes(range(31))) + + self.assertFalse(load_splash(surface, str(path))) + + self.assertEqual(bytes([9] * 32), surface.buffer) + + def test_generated_device_asset_matches_framebuffer_size(self): + asset = Path(__file__).parents[1] / "startup_splash.rgb565" + + self.assertEqual(240 * 240 * 2, asset.stat().st_size) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/convert_splash.py b/tools/convert_splash.py new file mode 100644 index 0000000..57ee59d --- /dev/null +++ b/tools/convert_splash.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Convert the supplied transparent artwork into the device splash format.""" + +import argparse +from pathlib import Path + +DISPLAY_SIZE = 240 +ARTWORK_MAX_WIDTH = 224 +ARTWORK_MAX_HEIGHT = 150 +ARTWORK_CENTER_Y = 115 +BACKGROUND = (0, 0, 0, 255) + + +def compose_splash(source): + """Center the source artwork inside the round display's safe area.""" + from PIL import Image + + artwork = source.convert("RGBA") + scale = min( + ARTWORK_MAX_WIDTH / artwork.width, + ARTWORK_MAX_HEIGHT / artwork.height, + ) + dimensions = ( + max(1, round(artwork.width * scale)), + max(1, round(artwork.height * scale)), + ) + artwork = artwork.resize(dimensions, Image.Resampling.LANCZOS) + + canvas = Image.new("RGBA", (DISPLAY_SIZE, DISPLAY_SIZE), BACKGROUND) + position = ( + (DISPLAY_SIZE - artwork.width) // 2, + ARTWORK_CENTER_Y - (artwork.height // 2), + ) + canvas.alpha_composite(artwork, position) + return canvas.convert("RGB") + + +def to_framebuffer_bytes(image): + """Encode the big-endian RGB565 byte stream expected by the GC9A01.""" + output = bytearray(image.width * image.height * 2) + offset = 0 + for red, green, blue in image.getdata(): + value = ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3) + # FrameBuffer stores 16-bit values little-endian, while show() sends + # the bytes directly over SPI. Writing the high byte first therefore + # matches the byte-swapped values used by MicroPython's RGB565 API. + output[offset] = value >> 8 + output[offset + 1] = value & 0xFF + offset += 2 + return output + + +def main(): + from PIL import Image + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path, help="source GIF, PNG, or JPEG") + parser.add_argument("output", type=Path, help="output RGB565 framebuffer file") + parser.add_argument( + "--preview", + type=Path, + help="optional PNG preview of the composed 240x240 splash", + ) + args = parser.parse_args() + + with Image.open(args.source) as source: + splash = compose_splash(source) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_bytes(to_framebuffer_bytes(splash)) + if args.preview: + args.preview.parent.mkdir(parents=True, exist_ok=True) + splash.save(args.preview) + + +if __name__ == "__main__": + main() diff --git a/touch_drive.py b/touch_drive.py index 6d0a4a7..fdd83bd 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -109,12 +109,26 @@ def Timer_callback(self,t): def BootScreen(self, LCD, sleep=4, version_number="0.0"): self.mode = 0 self.Set_Mode(self.Mode) - LCD.fill(LCD.red) - LCD.write_centered('Track',55,3,LCD.green) - LCD.write_centered('Session',90,3,LCD.green) - LCD.write_centered('Timer',125,3,LCD.green) - LCD.write_centered(('Version ' + version_number),195,1,LCD.green) + + splash_loaded = False + try: + # Import after LCD construction so the 115,200-byte framebuffer is + # allocated before this optional startup feature uses any heap. + from splash import load_splash + splash_loaded = load_splash(LCD) + except (ImportError, OSError): + pass + + if splash_loaded: + LCD.write_centered(('Version ' + version_number),205,1,LCD.white) + else: + LCD.fill(LCD.red) + LCD.write_centered('Track',55,3,LCD.green) + LCD.write_centered('Session',90,3,LCD.green) + LCD.write_centered('Timer',125,3,LCD.green) + LCD.write_centered(('Version ' + version_number),195,1,LCD.green) LCD.show() + return splash_loaded def SetBackColour(self, LCD, backColour): if backColour == 'green':