From ff092809bbf10356da334ecdee85eba996f3bf04 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Mon, 10 Aug 2026 12:30:35 +0100 Subject: [PATCH] Add configurable hardware startup splash --- README.md | 16 ++-- User Guide.md | 4 +- hardware_splash.py | 144 +++++++++++++++++++++++++++++++++ main.py | 17 +++- params.json | 3 +- settings.py | 36 ++++++--- tests/test_hardware_splash.py | 146 ++++++++++++++++++++++++++++++++++ tests/test_settings.py | 28 +++++++ 8 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 hardware_splash.py create mode 100644 tests/test_hardware_splash.py diff --git a/README.md b/README.md index 51a7fc6..f9650c9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Trackday or race session timer. * Added configuration prompts and a complete Ready-screen settings summary. * Increased the countdown font and added fixed-width timer digits to prevent movement. * Added a Ready-screen battery gauge with an external-power lightning indicator. -* Expanded hardware-independent regression coverage to 82 tests. +* Expanded hardware-independent regression coverage to 89 tests. ### v3.3 * Smooth proportional font rendering at native display resolution. * Centered typography and improved layout across timer, configuration, and diagnostic screens. @@ -66,7 +66,9 @@ On the supported Waveshare board running MicroPython 1.21.0, five full live-scre ## 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. +Startup now has two consecutive screens. First, 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 second screen uses high-contrast white text on black and identifies the hardware and runtime: board vendor and model, processor type, timer firmware, MicroPython version, and platform. Both screens default to two seconds. Their durations can be tuned independently in `params.json` with `STARTUP_SPLASH_DURATION_SEC` and `HARDWARE_SPLASH_DURATION_SEC`; zero skips the wait while still drawing that screen. Existing installations using `BOOT_DELAY_SEC` automatically apply that value to the first screen and use two seconds for the new hardware screen. 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: @@ -142,7 +144,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 battery.py configuration.py font_data.py font_renderer.py hardware.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py ready_screen.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : +mpremote connect auto fs cp battery.py configuration.py font_data.py font_renderer.py hardware.py hardware_splash.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py ready_screen.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 ``` @@ -157,7 +159,7 @@ When upgrading an existing device, omit that command so its saved track duration ### 4. Verify first boot -The display should show the Caterham v3.5 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.5 splash, the hardware-information 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: @@ -176,9 +178,9 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity is selecte ## Configuration files -Version 3.2 uses two separate configuration scopes: +Version 3.5 uses two separate configuration scopes: -* `params.json` contains system-owned choices and display behavior: `DURATION_VALUES`, `LAUNCH_SENSE_VALUES`, `VERSION`, `DISPLAY_DELAY_REST`, `DISPLAY_DELAY_REST_COLOUR`, and `BOOT_DELAY_SEC`. +* `params.json` contains system-owned choices and display behavior: `DURATION_VALUES`, `LAUNCH_SENSE_VALUES`, `VERSION`, `DISPLAY_DELAY_REST`, `DISPLAY_DELAY_REST_COLOUR`, `STARTUP_SPLASH_DURATION_SEC`, and `HARDWARE_SPLASH_DURATION_SEC`. * `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), and `SENSITIVITY` (launch threshold; `0` disables Launch Mode). Launch sensitivity is the filtered change in acceleration-vector magnitude from a 0.4-second stationary baseline, measured in g. This removes gravity and mounting orientation and handles acceleration on either side of every axis. Lower non-zero values are more sensitive. Detection requires three consecutive samples above the threshold; double-tap cancels the wait, and a 30-second timeout returns to the Ready screen. See `User Guide.md` for the practical meaning of every configured value. @@ -193,4 +195,4 @@ Run the hardware-independent regression suite with: python -m unittest discover -s tests -v ``` -The suite uses fakes for time, touch gestures, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 3.5 was additionally validated on the supported Waveshare board for boot, LCD/font rendering, CST816S touchscreen detection, QMI8658 initialization, saved settings, launch behavior, and the Ready-screen battery indicator. +The suite uses fakes for time, touch gestures, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 3.5 was additionally validated on the supported Waveshare board for both startup screens, LCD/font rendering, CST816S touchscreen detection, QMI8658 initialization, saved settings, launch behavior, and the Ready-screen battery indicator. diff --git a/User Guide.md b/User Guide.md index d2f4dd1..ea741b9 100644 --- a/User Guide.md +++ b/User Guide.md @@ -3,8 +3,8 @@ ## General / Sessions Use The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer. -* Upon start up a boot splash will be shown for 2 seconds. -* After which the ``Primary Screen`` will show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``. +* Upon startup, the Caterham boot artwork is followed by a hardware-information screen showing the board, processor, firmware, operating system, and platform. Each screen is shown for two seconds by default. Maintainers can tune the waits independently with `STARTUP_SPLASH_DURATION_SEC` and `HARDWARE_SPLASH_DURATION_SEC` in `params.json`. +* The ``Primary Screen`` will then show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``. * After swiping down, ``Go`` will display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer measures a stationary baseline and waits for sufficient acceleration. * While waiting in ``Launch Mode``, double-tap to cancel and return to the ``Primary Screen``. The wait also cancels automatically after 30 seconds. * Upon starting, the ``Track Session`` timer count down will be displayed, and immediately commence. diff --git a/hardware_splash.py b/hardware_splash.py new file mode 100644 index 0000000..910bb51 --- /dev/null +++ b/hardware_splash.py @@ -0,0 +1,144 @@ +"""Configurable startup sequence and compact hardware-information screen.""" + +import time + + +BOARD_VENDOR = "Waveshare" +BOARD_MODEL = "Touch LCD 1.28" +UNKNOWN_VALUE = "Unknown" + +HARDWARE_SPLASH_BACKGROUND = "black" +HARDWARE_SPLASH_TEXT_COLOUR = "white" + + +class DeviceDetails: + """Display-safe device metadata collected without requiring hardware.""" + + def __init__( + self, + firmware, + cpu, + os_name, + os_version, + platform, + vendor=BOARD_VENDOR, + model=BOARD_MODEL, + ): + self.vendor = vendor + self.model = model + self.firmware = firmware + self.cpu = cpu + self.os_name = os_name + self.os_version = os_version + self.platform = platform + + +def _runtime_value(source, attribute): + try: + value = getattr(source, attribute) + except Exception: + return UNKNOWN_VALUE + if value is None: + return UNKNOWN_VALUE + value = str(value).strip() + return value if value else UNKNOWN_VALUE + + +def _short_value(value, maximum=18): + value = str(value) + if len(value) <= maximum: + return value + return value[: maximum - 1] + "." + + +def _runtime_name(implementation): + name = _runtime_value(implementation, "name") + known_names = { + "micropython": "MicroPython", + "cpython": "CPython", + } + return known_names.get(name.lower(), _short_value(name)) + + +def _cpu_name(uname_info): + machine = _runtime_value(uname_info, "machine") + if "RP2040" in machine.upper(): + return "RP2040" + return _short_value(machine, maximum=12) + + +def collect_device_details(firmware_version, uname_info=None, implementation=None): + """Collect normalized details with safe fallbacks for missing fields.""" + if uname_info is None: + try: + try: + import uos as os + except ImportError: + import os + uname_info = os.uname() + except Exception: + uname_info = object() + + if implementation is None: + try: + import sys + implementation = sys.implementation + except Exception: + implementation = object() + + return DeviceDetails( + firmware=_short_value(firmware_version, maximum=10), + cpu=_cpu_name(uname_info), + os_name=_runtime_name(implementation), + os_version=_short_value( + _runtime_value(uname_info, "release"), + maximum=10, + ), + platform=_short_value( + _runtime_value(uname_info, "sysname"), + maximum=12, + ), + ) + + +def hardware_splash_lines(details): + """Build a circular-display-safe hardware summary.""" + return [ + ["Hardware", None, 24, 3, HARDWARE_SPLASH_TEXT_COLOUR], + ["Board " + details.vendor, None, 70, 1, HARDWARE_SPLASH_TEXT_COLOUR], + ["Model " + details.model, None, 94, 1, HARDWARE_SPLASH_TEXT_COLOUR], + ["Type " + details.cpu, None, 118, 1, HARDWARE_SPLASH_TEXT_COLOUR], + ["Firmware v" + details.firmware, None, 142, 1, HARDWARE_SPLASH_TEXT_COLOUR], + [ + "OS " + details.os_name + " " + details.os_version, + None, + 166, + 1, + HARDWARE_SPLASH_TEXT_COLOUR, + ], + ["Platform " + details.platform, None, 190, 1, HARDWARE_SPLASH_TEXT_COLOUR], + ] + + +def run_startup_screens( + touch, + lcd, + firmware_version, + startup_duration_sec, + hardware_duration_sec, + clock=time, + details=None, +): + """Show artwork, then hardware details, for their configured durations.""" + touch.BootScreen(lcd, version_number=firmware_version) + clock.sleep(startup_duration_sec) + + if details is None: + details = collect_device_details(firmware_version) + touch.ControlScreen( + lcd, + text_array=hardware_splash_lines(details), + back_colour=HARDWARE_SPLASH_BACKGROUND, + ) + clock.sleep(hardware_duration_sec) + return details diff --git a/main.py b/main.py index d1e7c76..9e030d7 100644 --- a/main.py +++ b/main.py @@ -63,14 +63,19 @@ def main(): duration_values = system_params["DURATION_VALUES"] launch_sense_values = system_params["LAUNCH_SENSE_VALUES"] version = system_params["VERSION"] - boot_delay_sec = system_params["BOOT_DELAY_SEC"] + startup_splash_duration_sec = system_params["STARTUP_SPLASH_DURATION_SEC"] + hardware_splash_duration_sec = system_params["HARDWARE_SPLASH_DURATION_SEC"] display_delay_rest = system_params["DISPLAY_DELAY_REST"] display_delay_rest_colour = system_params["DISPLAY_DELAY_REST_COLOUR"] print("User Parameters: " + str(user_params)) # Display and touchscreen + # Allocate the 115,200-byte framebuffer before importing optional startup + # presentation code. On the RP2040, doing this in the opposite order can + # fragment the heap enough to prevent the single large allocation. lcd = LCD_1inch28() + from hardware_splash import run_startup_screens lcd.set_bl_pwm(65535) battery_monitor = BatteryMonitor() try: @@ -78,13 +83,17 @@ def main(): lambda: Touch_CST816T(mode=1, LCD=lcd), "CST816T", ) - touch.BootScreen(lcd, version_number=version) + run_startup_screens( + touch, + lcd, + firmware_version=version, + startup_duration_sec=startup_splash_duration_sec, + hardware_duration_sec=hardware_splash_duration_sec, + ) except PeripheralError as error: _show_touch_failure(lcd, error) return False - time.sleep(boot_delay_sec) - qmi8658, imu_error = _initialize_imu(user_params["SENSITIVITY"]) if imu_error is not None: _show_imu_degraded(lcd, imu_error) diff --git a/params.json b/params.json index 991fb43..9a59d43 100644 --- a/params.json +++ b/params.json @@ -4,5 +4,6 @@ "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], "VERSION": "3.5", "DISPLAY_DELAY_REST_COLOUR": "blue", - "BOOT_DELAY_SEC": 2 + "STARTUP_SPLASH_DURATION_SEC": 2, + "HARDWARE_SPLASH_DURATION_SEC": 2 } diff --git a/settings.py b/settings.py index fa90a84..b756eaf 100644 --- a/settings.py +++ b/settings.py @@ -14,7 +14,8 @@ "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], "VERSION": "3.5", "DISPLAY_DELAY_REST_COLOUR": "blue", - "BOOT_DELAY_SEC": 2, + "STARTUP_SPLASH_DURATION_SEC": 2, + "HARDWARE_SPLASH_DURATION_SEC": 2, } DEFAULT_USER_PARAMS = { @@ -78,23 +79,36 @@ def validate_system_params(data): if not isinstance(data, dict): return _copy_params(DEFAULT_SYSTEM_PARAMS), False + source = dict(data) + if ( + "STARTUP_SPLASH_DURATION_SEC" not in source + and "BOOT_DELAY_SEC" in source + ): + source["STARTUP_SPLASH_DURATION_SEC"] = source["BOOT_DELAY_SEC"] + if "HARDWARE_SPLASH_DURATION_SEC" not in source: + source["HARDWARE_SPLASH_DURATION_SEC"] = DEFAULT_SYSTEM_PARAMS[ + "HARDWARE_SPLASH_DURATION_SEC" + ] + valid = ( - _positive_int_list(data.get("DURATION_VALUES")) - and _non_negative_number_list(data.get("LAUNCH_SENSE_VALUES")) - and _is_number(data.get("DISPLAY_DELAY_REST")) - and data.get("DISPLAY_DELAY_REST") >= 0 - and _is_number(data.get("BOOT_DELAY_SEC")) - and data.get("BOOT_DELAY_SEC") >= 0 - and isinstance(data.get("VERSION"), str) - and len(data.get("VERSION")) > 0 - and data.get("DISPLAY_DELAY_REST_COLOUR") in DISPLAY_COLOURS + _positive_int_list(source.get("DURATION_VALUES")) + and _non_negative_number_list(source.get("LAUNCH_SENSE_VALUES")) + and _is_number(source.get("DISPLAY_DELAY_REST")) + and source.get("DISPLAY_DELAY_REST") >= 0 + and _is_number(source.get("STARTUP_SPLASH_DURATION_SEC")) + and source.get("STARTUP_SPLASH_DURATION_SEC") >= 0 + and _is_number(source.get("HARDWARE_SPLASH_DURATION_SEC")) + and source.get("HARDWARE_SPLASH_DURATION_SEC") >= 0 + and isinstance(source.get("VERSION"), str) + and len(source.get("VERSION")) > 0 + and source.get("DISPLAY_DELAY_REST_COLOUR") in DISPLAY_COLOURS ) if not valid: return _copy_params(DEFAULT_SYSTEM_PARAMS), False params = {} for key in DEFAULT_SYSTEM_PARAMS: - value = data[key] + value = source[key] params[key] = list(value) if isinstance(value, list) else value return params, True diff --git a/tests/test_hardware_splash.py b/tests/test_hardware_splash.py new file mode 100644 index 0000000..d75428d --- /dev/null +++ b/tests/test_hardware_splash.py @@ -0,0 +1,146 @@ +import math +import unittest +from types import SimpleNamespace + +from font_renderer import measure_text, pixel_height +from hardware_splash import ( + DeviceDetails, + HARDWARE_SPLASH_BACKGROUND, + HARDWARE_SPLASH_TEXT_COLOUR, + UNKNOWN_VALUE, + collect_device_details, + hardware_splash_lines, + run_startup_screens, +) + + +class FakeClock: + def __init__(self, events): + self.events = events + + def sleep(self, seconds): + self.events.append(("sleep", seconds)) + + +class FakeTouch: + def __init__(self, events): + self.events = events + + def BootScreen(self, lcd, version_number): + self.events.append(("primary", lcd, version_number)) + + def ControlScreen(self, lcd, text_array, back_colour): + self.events.append(("hardware", lcd, text_array, back_colour)) + + +class HardwareSplashTests(unittest.TestCase): + def setUp(self): + self.details = DeviceDetails( + firmware="3.5", + cpu="RP2040", + os_name="MicroPython", + os_version="1.21.0", + platform="rp2", + ) + + def test_runtime_details_are_normalized_for_supported_hardware(self): + uname_info = SimpleNamespace( + machine="Raspberry Pi Pico with RP2040", + release="1.21.0", + sysname="rp2", + ) + implementation = SimpleNamespace(name="micropython") + + details = collect_device_details( + "3.5", + uname_info=uname_info, + implementation=implementation, + ) + + self.assertEqual("Waveshare", details.vendor) + self.assertEqual("Touch LCD 1.28", details.model) + self.assertEqual("RP2040", details.cpu) + self.assertEqual("3.5", details.firmware) + self.assertEqual("MicroPython", details.os_name) + self.assertEqual("1.21.0", details.os_version) + self.assertEqual("rp2", details.platform) + + def test_missing_runtime_details_use_safe_fallbacks(self): + details = collect_device_details( + "3.5", + uname_info=object(), + implementation=object(), + ) + + self.assertEqual(UNKNOWN_VALUE, details.cpu) + self.assertEqual(UNKNOWN_VALUE, details.os_name) + self.assertEqual(UNKNOWN_VALUE, details.os_version) + self.assertEqual(UNKNOWN_VALUE, details.platform) + + def test_hardware_screen_contains_expected_details(self): + lines = hardware_splash_lines(self.details) + text = [line[0] for line in lines] + + self.assertEqual( + [ + "Hardware", + "Board Waveshare", + "Model Touch LCD 1.28", + "Type RP2040", + "Firmware v3.5", + "OS MicroPython 1.21.0", + "Platform rp2", + ], + text, + ) + self.assertEqual("black", HARDWARE_SPLASH_BACKGROUND) + self.assertEqual("white", HARDWARE_SPLASH_TEXT_COLOUR) + self.assertTrue( + all(line[4] == HARDWARE_SPLASH_TEXT_COLOUR for line in lines) + ) + + def test_every_line_fits_inside_round_display(self): + display_size = 240 + radius = display_size / 2 + + for text, x, y, size, _colour in hardware_splash_lines(self.details): + with self.subTest(text=text): + height = pixel_height(size) + centre_y = y + (height / 2) + vertical_offset = abs(centre_y - radius) + available_width = 2 * math.sqrt( + (radius * radius) - (vertical_offset * vertical_offset) + ) + self.assertIsNone(x) + self.assertGreaterEqual(y, 0) + self.assertLessEqual(y + height, display_size) + self.assertLessEqual(measure_text(text, size), available_width) + + def test_startup_screens_run_in_order_for_configured_durations(self): + events = [] + touch = FakeTouch(events) + clock = FakeClock(events) + lcd = object() + + returned = run_startup_screens( + touch, + lcd, + firmware_version="3.5", + startup_duration_sec=2.25, + hardware_duration_sec=1.75, + clock=clock, + details=self.details, + ) + + self.assertIs(self.details, returned) + self.assertEqual(("primary", lcd, "3.5"), events[0]) + self.assertEqual(("sleep", 2.25), events[1]) + self.assertEqual("hardware", events[2][0]) + self.assertIs(lcd, events[2][1]) + self.assertEqual(hardware_splash_lines(self.details), events[2][2]) + self.assertEqual(HARDWARE_SPLASH_BACKGROUND, events[2][3]) + self.assertEqual(("sleep", 1.75), events[3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py index a7dd293..209d82a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -105,6 +105,34 @@ def test_invalid_system_params_use_known_defaults(self): self.assertFalse(valid) self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + for duration_key in ( + "STARTUP_SPLASH_DURATION_SEC", + "HARDWARE_SPLASH_DURATION_SEC", + ): + with self.subTest(duration_key=duration_key): + invalid_duration = dict(DEFAULT_SYSTEM_PARAMS) + invalid_duration[duration_key] = -1 + params, valid = validate_system_params(invalid_duration) + self.assertFalse(valid) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + + def test_splash_durations_default_to_two_seconds(self): + self.assertEqual(2, DEFAULT_SYSTEM_PARAMS["STARTUP_SPLASH_DURATION_SEC"]) + self.assertEqual(2, DEFAULT_SYSTEM_PARAMS["HARDWARE_SPLASH_DURATION_SEC"]) + + def test_legacy_boot_delay_is_migrated(self): + legacy = dict(DEFAULT_SYSTEM_PARAMS) + del legacy["STARTUP_SPLASH_DURATION_SEC"] + del legacy["HARDWARE_SPLASH_DURATION_SEC"] + legacy["BOOT_DELAY_SEC"] = 1.5 + + params, valid = validate_system_params(legacy) + + self.assertTrue(valid) + self.assertEqual(1.5, params["STARTUP_SPLASH_DURATION_SEC"]) + self.assertEqual(2, params["HARDWARE_SPLASH_DURATION_SEC"]) + self.assertNotIn("BOOT_DELAY_SEC", params) + def test_missing_and_out_of_range_user_values_use_defaults(self): normalized, changed = normalize_user_params(None, DEFAULT_SYSTEM_PARAMS) self.assertTrue(changed)