Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```
Expand All @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions assets/README.md
Original file line number Diff line number Diff line change
@@ -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
```
Binary file added assets/startup_splash.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/startup_splash_preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions splash.py
Original file line number Diff line number Diff line change
@@ -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
Binary file added startup_splash.rgb565
Binary file not shown.
66 changes: 66 additions & 0 deletions tests/test_splash.py
Original file line number Diff line number Diff line change
@@ -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()
77 changes: 77 additions & 0 deletions tools/convert_splash.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 19 additions & 5 deletions touch_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Loading