diff --git a/README.md b/README.md index 535bd0a..60e4394 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Trackday or race session timer. # Change log +## Next +* Replaced abrupt track-session warning backgrounds with a smooth, duration-proportional green, yellow, amber, and red gradient. +* Added a distinct deep-purple overrun background and automatic black-or-white timer text chosen for maximum contrast. + ## Version 4.1 ### v4.1.0 [current] * Added persistent 0°, 90°, 180°, and 270° clockwise mounting orientations. @@ -50,7 +54,7 @@ See user guide. ## Background Managing time on the track can be challenging, whether it's during a track day or a race. Many competitors in the Seven category use kitchen timers mounted on their dashboards. However, these timers can be large, awkward, and difficult to read, requiring drivers to interpret small digits mid-race. -The code provided here offers a solution by creating a timer that is easy to read with clear digits. Additionally, it features background colors that change to indicate key moments during the session, such as when 85% and 95% of the session time has elapsed. +The code provided here offers a solution by creating a timer that is easy to read with clear digits. The track-session background continuously communicates progress: green at the start, yellow at one-third, amber at two-thirds, and red as scheduled time expires. Overrun is shown in deep purple with white text. Black or white timer text is selected automatically for the strongest contrast against every intermediate colour. The timer is designed to support common session lengths, making it quick and easy to set up without the need to scroll through unnecessary minute intervals. @@ -72,7 +76,7 @@ The generated font data is distributed under the SIL Open Font License 1.1 in `F ## Live display refresh -Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, font size, background, and text colour) with the previous frame. The 115,200-byte framebuffer is transferred only when a displayed second or warning state changes, normally reducing continuous redraws to one per second. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. +Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, font size, background, and text colour) with the previous frame. Track colour is interpolated from whole elapsed seconds, making the blend proportional to the selected duration while retaining a maximum of one normal full-screen transfer per displayed second. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. On the supported Waveshare board running MicroPython 1.21.0, five full live-screen redraws measured 56.2–65.4 ms. Input is therefore checked within 50 ms between redraws and within approximately 115 ms in the worst case when a gesture arrives immediately before a redraw. Five consecutive frames produced only the two register writes needed for the initial gesture-mode configuration and no rewrites on later frames. diff --git a/User Guide.md b/User Guide.md index 42833cf..7eaaa27 100644 --- a/User Guide.md +++ b/User Guide.md @@ -8,10 +8,9 @@ The following describes general operation of both the ``Track Session`` and ``Re * 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. -* At 85% completion of the ``Track Session`` the timer display colours will change to highlight progression. -* At 95% completion of the ``Track Session`` the timer display colours will again change, further highlighting progression and final expiry warning. -* At 100% completion the ``Track Session`` timer display colours will change again. -* Once the ``Track Session`` has completed, i.e. >100%, the timer will remain running. The session continues to run to provide visibility of any overrunning. ``Double Tap`` to complete/exit. A ``Double Tap`` can be used to terminate any running timer. +* During a ``Track Session``, the background blends continuously from green at the start, through yellow at one-third and amber at two-thirds, towards red at scheduled expiry. The blend is proportional to the selected session length rather than using fixed times. +* Timer text automatically uses whichever of black or white has the greater contrast against the current background colour. +* Once the ``Track Session`` has completed, i.e. >100%, the background becomes deep purple with white text and the timer remains running to provide visibility of any overrun. ``Double Tap`` to complete/exit. A ``Double Tap`` can be used to terminate any running timer. * Following termination, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer. * Once the ``Rest in Pits Session`` is complete, the timer will return to the ``Primary screen``. The ``Rest in Pits Session`` can be terminated with a ``Double Tap``. diff --git a/live_display.py b/live_display.py index a87aede..365b19c 100644 --- a/live_display.py +++ b/live_display.py @@ -10,6 +10,101 @@ # previous 64 px countdown (target 70.4 px). COUNTDOWN_TEXT_SIZE = 7 +# RGB888 is used for interpolation so named colours remain predictable. The +# final value is converted to the byte-swapped RGB565 integer required when +# MicroPython's little-endian framebuffer is sent directly to the GC9A01. +TRACK_GREEN_RGB = (0, 255, 0) +TRACK_YELLOW_RGB = (255, 255, 0) +TRACK_AMBER_RGB = (255, 191, 0) +TRACK_RED_RGB = (255, 0, 0) +TRACK_OVERRUN_PURPLE_RGB = (96, 32, 128) +TRACK_GRADIENT_RGB = ( + TRACK_GREEN_RGB, + TRACK_YELLOW_RGB, + TRACK_AMBER_RGB, + TRACK_RED_RGB, +) + + +def rgb_to_display565(rgb): + """Convert RGB888 to the byte-swapped RGB565 framebuffer integer.""" + red, green, blue = rgb + value = ((red & 0xF8) << 8) | ((green & 0xFC) << 3) | (blue >> 3) + return ((value & 0xFF) << 8) | (value >> 8) + + +def _interpolate_channel(start, end, numerator, denominator): + """Interpolate one byte channel using rounded integer arithmetic.""" + return ( + (start * (denominator - numerator)) + + (end * numerator) + + (denominator // 2) + ) // denominator + + +def interpolate_rgb(start, end, numerator, denominator): + """Return the clamped RGB colour between two endpoints.""" + if denominator <= 0: + raise ValueError("denominator must be positive") + if numerator <= 0: + return start + if numerator >= denominator: + return end + return ( + _interpolate_channel(start[0], end[0], numerator, denominator), + _interpolate_channel(start[1], end[1], numerator, denominator), + _interpolate_channel(start[2], end[2], numerator, denominator), + ) + + +def scheduled_track_rgb(elapsed_seconds, duration_seconds): + """Return the proportional green-yellow-amber-red session colour.""" + duration_seconds = int(duration_seconds) + if duration_seconds <= 0: + raise ValueError("duration_seconds must be positive") + + elapsed_seconds = int(elapsed_seconds) + if elapsed_seconds <= 0: + return TRACK_GREEN_RGB + if elapsed_seconds >= duration_seconds: + return TRACK_RED_RGB + + segment_count = len(TRACK_GRADIENT_RGB) - 1 + scaled_elapsed = elapsed_seconds * segment_count + segment = scaled_elapsed // duration_seconds + segment_progress = scaled_elapsed - (segment * duration_seconds) + return interpolate_rgb( + TRACK_GRADIENT_RGB[segment], + TRACK_GRADIENT_RGB[segment + 1], + segment_progress, + duration_seconds, + ) + + +def _linear_channel(channel): + """Convert an sRGB byte channel to its linear-light value.""" + value = channel / 255 + if value <= 0.04045: + return value / 12.92 + return ((value + 0.055) / 1.055) ** 2.4 + + +def relative_luminance(rgb): + """Return WCAG relative luminance for an RGB888 colour.""" + return ( + (0.2126 * _linear_channel(rgb[0])) + + (0.7152 * _linear_channel(rgb[1])) + + (0.0722 * _linear_channel(rgb[2])) + ) + + +def high_contrast_text_colour(rgb, black, white): + """Choose black or white, whichever has greater WCAG contrast.""" + luminance = relative_luminance(rgb) + black_contrast = (luminance + 0.05) / 0.05 + white_contrast = 1.05 / (luminance + 0.05) + return black if black_contrast >= white_contrast else white + def _visible_times(session, now): elapsed_seconds = max(0, int(now - session.start_time)) @@ -21,16 +116,31 @@ def _visible_times(session, now): def track_live_frame(session, now, lcd): - """Return all values visible on the track-session screen.""" + """Return the track timer with a smooth proportional colour gradient.""" remaining, elapsed = _visible_times(session, now) if now >= session.end_time: - return ("00:00", elapsed, COUNTDOWN_TEXT_SIZE, lcd.red, lcd.black) - - if now < session.last_15: - return (remaining, elapsed, COUNTDOWN_TEXT_SIZE, None, None) - if now < session.last_5: - return (remaining, elapsed, COUNTDOWN_TEXT_SIZE, lcd.salmon, lcd.black) - return (remaining, elapsed, COUNTDOWN_TEXT_SIZE, lcd.lilac, None) + background_rgb = TRACK_OVERRUN_PURPLE_RGB + remaining = "00:00" + else: + elapsed_seconds = max(0, int(now - session.start_time)) + background_rgb = scheduled_track_rgb( + elapsed_seconds, + session.duration_secs, + ) + + background = rgb_to_display565(background_rgb) + text_colour = high_contrast_text_colour( + background_rgb, + lcd.black, + lcd.white, + ) + return ( + remaining, + elapsed, + COUNTDOWN_TEXT_SIZE, + background, + text_colour, + ) def rest_live_frame(session, now, lcd): diff --git a/tests/test_live_display.py b/tests/test_live_display.py index 08aec36..57f4a08 100644 --- a/tests/test_live_display.py +++ b/tests/test_live_display.py @@ -4,8 +4,17 @@ from font_renderer import measure_text, pixel_height from live_display import ( COUNTDOWN_TEXT_SIZE, + TRACK_AMBER_RGB, + TRACK_GREEN_RGB, + TRACK_OVERRUN_PURPLE_RGB, + TRACK_RED_RGB, + TRACK_YELLOW_RGB, + high_contrast_text_colour, + interpolate_rgb, rest_live_frame, + rgb_to_display565, run_live_display, + scheduled_track_rgb, track_live_frame, ) from timing import SessionTracker @@ -30,6 +39,7 @@ class FakeLCD: salmon = 3 lilac = 4 blue = 5 + white = 6 class LiveDisplayTests(unittest.TestCase): @@ -137,27 +147,123 @@ def stop_check(): self.assertEqual("green", frames[0][3]) self.assertEqual("warning", frames[1][3]) - def test_track_frames_include_warning_and_overrun_state(self): + def test_rgb_conversion_matches_native_primary_colour_constants(self): + self.assertEqual(0x00F8, rgb_to_display565((255, 0, 0))) + self.assertEqual(0xE007, rgb_to_display565((0, 255, 0))) + self.assertEqual(0x1F00, rgb_to_display565((0, 0, 255))) + self.assertEqual(0xFFFF, rgb_to_display565((255, 255, 255))) + + def test_scheduled_gradient_has_exact_proportional_anchors(self): + duration = 600 + + self.assertEqual(TRACK_GREEN_RGB, scheduled_track_rgb(0, duration)) + self.assertEqual(TRACK_YELLOW_RGB, scheduled_track_rgb(200, duration)) + self.assertEqual(TRACK_AMBER_RGB, scheduled_track_rgb(400, duration)) + self.assertEqual(TRACK_RED_RGB, scheduled_track_rgb(600, duration)) + + def test_scheduled_gradient_interpolates_between_anchors(self): + duration = 600 + + green_yellow = scheduled_track_rgb(100, duration) + yellow_amber = scheduled_track_rgb(300, duration) + amber_red = scheduled_track_rgb(500, duration) + + self.assertEqual((128, 255, 0), green_yellow) + self.assertEqual((255, 223, 0), yellow_amber) + self.assertEqual((255, 96, 0), amber_red) + self.assertNotIn( + green_yellow, + (TRACK_GREEN_RGB, TRACK_YELLOW_RGB), + ) + + def test_scheduled_gradient_scales_with_total_duration(self): + self.assertEqual( + scheduled_track_rgb(30, 60), + scheduled_track_rgb(300, 600), + ) + + def test_scheduled_gradient_clamps_progress_and_rejects_zero_duration(self): + self.assertEqual(TRACK_GREEN_RGB, scheduled_track_rgb(-1, 60)) + self.assertEqual(TRACK_RED_RGB, scheduled_track_rgb(61, 60)) + with self.assertRaises(ValueError): + scheduled_track_rgb(0, 0) + + def test_rgb_interpolation_clamps_to_endpoints(self): + self.assertEqual((0, 0, 0), interpolate_rgb((0, 0, 0), (9, 9, 9), -1, 3)) + self.assertEqual((9, 9, 9), interpolate_rgb((0, 0, 0), (9, 9, 9), 4, 3)) + with self.assertRaises(ValueError): + interpolate_rgb((0, 0, 0), (9, 9, 9), 1, 0) + + def test_text_colour_always_selects_the_higher_contrast_option(self): + lcd = FakeLCD() + + self.assertEqual( + lcd.white, + high_contrast_text_colour((0, 0, 0), lcd.black, lcd.white), + ) + for background in ( + TRACK_GREEN_RGB, + TRACK_YELLOW_RGB, + TRACK_AMBER_RGB, + TRACK_RED_RGB, + scheduled_track_rgb(100, 600), + scheduled_track_rgb(300, 600), + scheduled_track_rgb(500, 600), + ): + self.assertEqual( + lcd.black, + high_contrast_text_colour(background, lcd.black, lcd.white), + ) + self.assertEqual( + lcd.white, + high_contrast_text_colour( + TRACK_OVERRUN_PURPLE_RGB, + lcd.black, + lcd.white, + ), + ) + + def test_track_frames_follow_gradient_and_use_deep_purple_for_overrun(self): lcd = FakeLCD() session = SessionTracker(duration_mins=10, clock=lambda: 100) session.start_session() - running = track_live_frame(session, 609, lcd) - last_15 = track_live_frame(session, 610, lcd) - last_5 = track_live_frame(session, 670, lcd) + start = track_live_frame(session, 100, lcd) + one_third = track_live_frame(session, 300, lcd) + two_thirds = track_live_frame(session, 500, lcd) + near_expiry = track_live_frame(session, 699, lcd) overrun = track_live_frame(session, 700, lcd) self.assertTrue( all( frame[2] == COUNTDOWN_TEXT_SIZE - for frame in (running, last_15, last_5, overrun) + for frame in ( + start, + one_third, + two_thirds, + near_expiry, + overrun, + ) ) ) - self.assertEqual((None, None), running[3:]) - self.assertEqual((lcd.salmon, lcd.black), last_15[3:]) - self.assertEqual((lcd.lilac, None), last_5[3:]) + self.assertEqual( + (rgb_to_display565(TRACK_GREEN_RGB), lcd.black), + start[3:], + ) + self.assertEqual( + (rgb_to_display565(TRACK_YELLOW_RGB), lcd.black), + one_third[3:], + ) + self.assertEqual( + (rgb_to_display565(TRACK_AMBER_RGB), lcd.black), + two_thirds[3:], + ) + self.assertNotEqual(two_thirds[3], near_expiry[3]) self.assertEqual("00:00", overrun[0]) - self.assertEqual((lcd.red, lcd.black), overrun[3:]) + self.assertEqual( + (rgb_to_display565(TRACK_OVERRUN_PURPLE_RGB), lcd.white), + overrun[3:], + ) def test_rest_frame_uses_larger_countdown_size(self): lcd = FakeLCD()