diff --git a/README.md b/README.md index de9988d..c9e8b9b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ Trackday or race session timer. # Change log +## Unreleased +* Added an experimental thin circular progress ring that empties in 36 proportional steps around active track and rest sessions without moving the primary countdown. +* Added explicit `TRACK`, `WARNING`, `FINAL LAP`, `OVERRUN`, and `REST` phase labels so session state does not depend on background colour alone. `FINAL LAP` is shown only with the user-selected approximate `LAPS LEFT` display. +* Refined the running maximum-G hierarchy with a smaller `MAX` label and a larger tabular numeric value and unit, while retaining clear separation from the primary countdown. +* Kept the progress indicator within the round panel and within the existing once-per-visible-frame refresh policy. A worst-case full ring added approximately 5.5 ms of framebuffer work on the supported device. +* Expanded hardware-independent regression coverage to 211 tests. + ## Version 4.4 ### v4.4.0 [current] * Moved the device payload into `firmware/` and added a layout-aware deployment tool so the repository stays organized while MicroPython still receives a flat filesystem. @@ -117,6 +124,8 @@ The generated font data is distributed under the SIL Open Font License 1.1 in `F Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, maximum G, 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. Maximum G is sampled at that same bounded polling rate but its visible value is latched to the displayed second, so sensing does not add framebuffer transfers. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. +Active track and rest screens also draw a two-pixel perimeter ring in 36 fixed segments. The arc starts full and empties in proportion to scheduled time, using the same automatically selected high-contrast colour as the text. A small label above the MAX-G line identifies `TRACK`, the final-third `WARNING`, `OVERRUN`, or `REST`. When `LAPS LEFT` is selected and its estimate reaches one lap or less, `FINAL LAP` replaces `WARNING`; it remains an average-based estimate rather than a detected timing-line crossing. The ring and label are part of the existing visible-frame tuple, so they do not introduce a faster refresh schedule. + When `LAPS LEFT` is selected, the track-session lower line divides the countdown's scheduled remaining seconds by `AVG_LAP_TIME_SECONDS`. A small `LAP` caption sits above a larger native-size numeric estimate, improving number visibility without moving or resizing the main countdown. Values below 100 laps use one decimal place (`LAP 6.7`); larger estimates use a rounded whole number so the value remains inside the circular safe area. Overrun displays `LAP 0.0`. The estimate is recalculated from the countdown rather than maintained as separate state, and invalid or unavailable average-lap data falls back to the normal elapsed count-up line. Pit/rest sessions always retain their count-up timer. 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. @@ -138,7 +147,7 @@ python tools/convert_splash.py assets/startup_splash.gif firmware/startup_splash Press and continuously hold the touchscreen for five seconds from the Timer Ready screen or G Mode to open the operating-mode menu. Releasing early cancels the hold. For safety, the menu cannot interrupt a running track/rest session or the Launch Mode wait. In menus, swipe left/right to choose, swipe up to select, and swipe down to cancel. The selected operating mode persists across restarts. -* **Timer Mode** retains the existing track, rest, and Launch Mode workflow. During a track session, a baseline-corrected value such as `MAX 1.23 g` appears in a compact, clearly spaced line above the countdown. The peak resets for each track session and remains visible through overrun. `MAX --` indicates that acceleration data is unavailable; timing and the stop gesture continue normally. After a double-tap stop, an interactive review gives actual duration, overrun, total maximum G, acceleration, braking, left G, right G, and the completion reason their own high-visibility screens. Swipe left to advance and right to go back; cool-down begins only after swiping left from the eighth and final page. The review is held in RAM only and is not retained after leaving it. Rest sessions do not show maximum G. +* **Timer Mode** retains the existing track, rest, and Launch Mode workflow. During a track session, a baseline-corrected value such as `MAX 1.23 g` appears above the countdown, with the `MAX` label deliberately smaller than the enlarged tabular value and unit. A thin perimeter ring empties with scheduled time, while an explicit phase label identifies Track, Warning, approximate Final Lap, Overrun, or Rest without relying only on colour. The peak resets for each track session and remains visible through overrun. `MAX --` indicates that acceleration data is unavailable; timing and the stop gesture continue normally. After a double-tap stop, an interactive review gives actual duration, overrun, total maximum G, acceleration, braking, left G, right G, and the completion reason their own high-visibility screens. Swipe left to advance and right to go back; cool-down begins only after swiping left from the eighth and final page. The review is held in RAM only and is not retained after leaving it. Rest sessions do not show maximum G. * **G Mode** calibrates the stationary QMI8658 baseline, then presents a responsive graphical round G meter rather than numeric telemetry. The green filled marker and short trail show the current filtered acceleration vector at the LCD's display-limited refresh rate. The red hollow marker records the maximum vector, while the red perimeter arc shows peak magnitude relative to the 4 g visual scale. Double-tap resets the trail and peak. Hold for five seconds to return to the mode menu. * **Settings** provides 25%, 50%, 75%, and 100% brightness choices with immediate preview. Rotation offers **Auto** plus fixed 0°, 90°, 180°, and 270° clockwise mounting angles. Auto uses the onboard IMU to keep the display upright as the device turns; fixed choices continue to work without the IMU. **Auto-Dim** is an independent On/Off choice: when enabled, the Ready screen reduces to 25% brightness after 10 seconds without motion and returns immediately to the saved brightness when motion is detected. **Avg Lap Time** stores the current circuit's expected lap duration from `00:00` to `59:59`; minutes and seconds are edited separately, and `00:00` means not configured. Once a non-zero average exists, **Lower Display** becomes available with `COUNT UP` and `LAPS LEFT` choices for the running track-session line below the countdown. Clearing Avg Lap Time hides that choice and restores Count Up. Auto-Dim never dims menus, configuration, Launch Mode, active timing, review, rest, or G Mode. In every case, touch gestures remain relative to the text on screen. Swipe up saves a preview; swipe down cancels and restores the previous value. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, fixed 0° rotation, disabled Auto-Dim, an unset `00:00` average lap time, Count Up, 20-minute track/rest sessions, and disabled Launch Mode. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e03b3cd..db51932 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -9,7 +9,8 @@ The following describes general operation of both the ``Track Session`` and ``Re * 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. * 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. -* The clearly spaced line such as ``MAX 1.23 g`` above the countdown shows the largest filtered planar acceleration recorded during the current track session. It is positioned with a clear gap from the countdown, resets at each new track session, and remains visible during overrun. ``MAX --`` means the IMU is unavailable; normal timing and the double-tap stop gesture continue. Rest sessions do not show this value. +* A thin high-contrast ring around the perimeter starts full and empties in 36 proportional steps as scheduled track or rest time is used. The small label above maximum G reads `TRACK`, changes to `WARNING` for the final third, reads `OVERRUN` after scheduled expiry, and reads `REST` during pit timing. With `LAPS LEFT` selected, an estimate of one lap or less changes the warning label to `FINAL LAP`; this is based on Avg Lap Time and is not an automatically detected lap crossing. +* The clearly spaced line such as ``MAX 1.23 g`` above the countdown shows the largest filtered planar acceleration recorded during the current track session. `MAX` uses a smaller label while the tabular numeric value and `g` unit are slightly larger for quicker recognition. It retains a clear gap from the countdown, resets at each new track session, and remains visible during overrun. ``MAX --`` means the IMU is unavailable; normal timing and the double-tap stop gesture continue. Rest sessions do not show this value. * The line below the track countdown normally counts up in elapsed `MM:SS`. If `LAPS LEFT` is selected, it instead shows a small `LAP` caption above a larger approximate value such as `6.7`, calculated by dividing scheduled time remaining by the configured Avg Lap Time. This dedicated lower layout does not move or resize the main countdown. Estimates below 100 use one decimal place and larger estimates use a rounded whole number. Overrun shows `0.0`. If the saved average is unavailable, the normal count-up line is used automatically. Pit/rest sessions always count up. * 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. diff --git a/firmware/application.py b/firmware/application.py index 4f78669..9c7b1c7 100644 --- a/firmware/application.py +++ b/firmware/application.py @@ -16,6 +16,7 @@ "hold_detector", "launch", "live_display", + "live_screen_graphics", "g_force", "ready_screen", "timing", diff --git a/firmware/lcd_1inch28.py b/firmware/lcd_1inch28.py index 670e3f4..3d187f4 100644 --- a/firmware/lcd_1inch28.py +++ b/firmware/lcd_1inch28.py @@ -402,13 +402,25 @@ def Windows_show(self,Xstart,Ystart,Xend,Yend): self.spi.write(self.buffer[Addr : Addr+((Xend-Xstart)*2)]) self.cs(1) - def text_width(self, text, size): + def text_width(self, text, size, tabular_digits=False): """Return the proportional width of text at a UI font size.""" - return self._measure_text(text, size) + return self._measure_text( + text, + size, + tabular_digits=tabular_digits, + ) - def write_text(self, text, x, y, size, color): + def write_text(self, text, x, y, size, color, tabular_digits=False): """Draw smooth proportional text and return its rendered width.""" - return self._draw_text(self, text, x, y, size, color) + return self._draw_text( + self, + text, + x, + y, + size, + color, + tabular_digits=tabular_digits, + ) def write_centered(self, text, y, size, color): """Draw smooth proportional text centered on the display.""" diff --git a/firmware/live_display.py b/firmware/live_display.py index aca86c2..2268c00 100644 --- a/firmware/live_display.py +++ b/firmware/live_display.py @@ -9,6 +9,9 @@ # Native font size 7 is 74 px: the nearest available size to 10% above the # previous 64 px countdown (target 70.4 px). COUNTDOWN_TEXT_SIZE = 7 +PROGRESS_RING_SEGMENTS = 36 +TRACK_WARNING_START_NUMERATOR = 2 +TRACK_WARNING_START_DENOMINATOR = 3 # RGB888 is used for interpolation so named colours remain predictable. The # final value is converted to the byte-swapped RGB565 integer required when @@ -137,6 +140,48 @@ def estimated_laps_display(remaining_seconds, avg_lap_time_seconds): return "LAP", value +def progress_ring_segments(elapsed_seconds, duration_seconds): + """Return the bounded number of scheduled-time ring segments remaining.""" + duration_seconds = int(duration_seconds) + if duration_seconds <= 0: + raise ValueError("duration_seconds must be positive") + elapsed_seconds = max(0, min(int(elapsed_seconds), duration_seconds)) + remaining_seconds = duration_seconds - elapsed_seconds + return int( + ((remaining_seconds * PROGRESS_RING_SEGMENTS) + (duration_seconds // 2)) + // duration_seconds + ) + + +def track_phase_label( + elapsed_seconds, + duration_seconds, + avg_lap_time_seconds=0, +): + """Return an explicit track phase that does not rely on colour alone.""" + duration_seconds = int(duration_seconds) + if duration_seconds <= 0: + raise ValueError("duration_seconds must be positive") + elapsed_seconds = max(0, int(elapsed_seconds)) + if elapsed_seconds >= duration_seconds: + return "OVERRUN" + + remaining_seconds = duration_seconds - elapsed_seconds + if ( + isinstance(avg_lap_time_seconds, int) + and not isinstance(avg_lap_time_seconds, bool) + and avg_lap_time_seconds > 0 + and remaining_seconds <= avg_lap_time_seconds + ): + return "FINAL LAP" + if ( + elapsed_seconds * TRACK_WARNING_START_DENOMINATOR + >= duration_seconds * TRACK_WARNING_START_NUMERATOR + ): + return "WARNING" + return "TRACK" + + def track_live_frame( session, now, @@ -146,7 +191,7 @@ def track_live_frame( avg_lap_time_seconds=0, ): """Return the track timer with a smooth proportional colour gradient.""" - _, remaining_seconds = _visible_seconds(session, now) + elapsed_seconds, remaining_seconds = _visible_seconds(session, now) remaining, elapsed = _visible_times(session, now) if lower_display == "laps_remaining": laps_display = estimated_laps_display( @@ -159,7 +204,6 @@ def track_live_frame( 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, @@ -178,6 +222,16 @@ def track_live_frame( background, text_colour, maximum_g, + progress_ring_segments(elapsed_seconds, session.duration_secs), + track_phase_label( + elapsed_seconds, + session.duration_secs, + avg_lap_time_seconds=( + avg_lap_time_seconds + if lower_display == "laps_remaining" + else 0 + ), + ), ) @@ -185,6 +239,7 @@ def rest_live_frame(session, now, lcd): """Return visible rest-session values, or ``None`` when it is complete.""" if now >= session.end_time: return None + elapsed_seconds, _ = _visible_seconds(session, now) remaining, elapsed = _visible_times(session, now) return ( remaining, @@ -193,12 +248,23 @@ def rest_live_frame(session, now, lcd): lcd.blue, None, None, + progress_ring_segments(elapsed_seconds, session.duration_secs), + "REST", ) def draw_live_frame(touch, lcd, frame): """Render one frame tuple produced by a live-frame builder.""" - remaining, elapsed, text_size, background, text_colour, maximum_g = frame + ( + remaining, + elapsed, + text_size, + background, + text_colour, + maximum_g, + progress_segments, + phase_label, + ) = frame touch.LiveScreen( lcd, textsize_rem=text_size, @@ -207,6 +273,8 @@ def draw_live_frame(touch, lcd, frame): elapsed=elapsed, remaining=remaining, maximum_g=maximum_g, + progress_segments=progress_segments, + phase_label=phase_label, ) diff --git a/firmware/live_screen_graphics.py b/firmware/live_screen_graphics.py new file mode 100644 index 0000000..f65a7e9 --- /dev/null +++ b/firmware/live_screen_graphics.py @@ -0,0 +1,112 @@ +"""Timer-only graphics kept out of the memory-sensitive startup path.""" + + +MAXIMUM_G_LABEL = "MAX" +MAXIMUM_G_LABEL_Y = 45 +MAXIMUM_G_LABEL_TEXT_SIZE = 1 +MAXIMUM_G_VALUE_Y = 36 +MAXIMUM_G_VALUE_TEXT_SIZE = 3 +MAXIMUM_G_GAP = 8 +PROGRESS_PHASE_Y = 18 +PROGRESS_PHASE_TEXT_SIZE = 1 +PROGRESS_RING_CENTRE = 120 + +# Packed x/y bytes keep the 37 perimeter points to 74 bytes on MicroPython. +PROGRESS_RING_POINTS = ( + b"\x78\x08\x8b\x0a\x9e\x0f\xb0\x17\xc0\x22\xce\x30" + b"\xd9\x40\xe1\x52\xe6\x65\xe8\x78\xe6\x8b\xe1\x9e" + b"\xd9\xb0\xce\xc0\xc0\xce\xb0\xd9\x9e\xe1\x8b\xe6" + b"\x78\xe8\x65\xe6\x52\xe1\x40\xd9\x30\xce\x22\xc0" + b"\x17\xb0\x0f\x9e\x0a\x8b\x08\x78\x0a\x65\x0f\x52" + b"\x17\x40\x22\x30\x30\x22\x40\x17\x52\x0f\x65\x0a" + b"\x78\x08" +) + + +def _toward_ring_centre(value): + if value < PROGRESS_RING_CENTRE: + return value + 1 + if value > PROGRESS_RING_CENTRE: + return value - 1 + return value + + +def draw_progress_ring(lcd, visible_segments, colour): + """Draw a thin clockwise perimeter arc without touching the text layout.""" + segment_count = (len(PROGRESS_RING_POINTS) // 2) - 1 + visible_segments = max(0, min(int(visible_segments), segment_count)) + for index in range(visible_segments): + point_offset = index * 2 + start_x = PROGRESS_RING_POINTS[point_offset] + start_y = PROGRESS_RING_POINTS[point_offset + 1] + end_x = PROGRESS_RING_POINTS[point_offset + 2] + end_y = PROGRESS_RING_POINTS[point_offset + 3] + lcd.line(start_x, start_y, end_x, end_y, colour) + lcd.line( + _toward_ring_centre(start_x), + _toward_ring_centre(start_y), + _toward_ring_centre(end_x), + _toward_ring_centre(end_y), + colour, + ) + + +def maximum_g_parts(maximum_g): + """Return a compact label and normalized value/unit for mixed-size text.""" + value = str(maximum_g) + if value.startswith(MAXIMUM_G_LABEL): + value = value[len(MAXIMUM_G_LABEL):] + value = value.strip() + while " " in value: + value = value.replace(" ", " ") + return MAXIMUM_G_LABEL, value + + +def draw_maximum_g(lcd, maximum_g, colour): + """Draw a small MAX label beside a larger, fixed-digit peak value.""" + label, value = maximum_g_parts(maximum_g) + label_width = lcd.text_width(label, MAXIMUM_G_LABEL_TEXT_SIZE) + value_width = lcd.text_width( + value, + MAXIMUM_G_VALUE_TEXT_SIZE, + tabular_digits=True, + ) + total_width = label_width + MAXIMUM_G_GAP + value_width + label_x = (lcd.width - total_width) // 2 + value_x = label_x + label_width + MAXIMUM_G_GAP + lcd.write_text( + label, + label_x, + MAXIMUM_G_LABEL_Y, + MAXIMUM_G_LABEL_TEXT_SIZE, + colour, + ) + lcd.write_text( + value, + value_x, + MAXIMUM_G_VALUE_Y, + MAXIMUM_G_VALUE_TEXT_SIZE, + colour, + tabular_digits=True, + ) + + +def draw_live_overlays( + lcd, + colour, + progress_segments=None, + phase_label=None, + maximum_g=None, +): + """Draw optional session overlays after Timer Mode has been loaded.""" + if progress_segments is not None: + draw_progress_ring(lcd, progress_segments, colour) + if phase_label is not None: + lcd.write_time_centered( + phase_label, + PROGRESS_PHASE_Y, + PROGRESS_PHASE_TEXT_SIZE, + colour, + ) + if maximum_g is not None: + draw_maximum_g(lcd, maximum_g, colour) diff --git a/firmware/touch_drive.py b/firmware/touch_drive.py index dbbfe9b..05b1067 100644 --- a/firmware/touch_drive.py +++ b/firmware/touch_drive.py @@ -13,14 +13,10 @@ G_RIGHT = 0x04 G_LONG_PRESS = 0x0C G_DOUBLE_CLIC = 0x0B -MAXIMUM_G_Y = 40 -MAXIMUM_G_TEXT_SIZE = 2 LAP_LABEL_Y = 160 LAP_LABEL_TEXT_SIZE = 1 LAP_VALUE_Y = 176 LAP_VALUE_TEXT_SIZE = 4 - - def _sleep_ms(clock, milliseconds): sleep_ms = getattr(clock, "sleep_ms", None) if sleep_ms is not None: @@ -341,6 +337,8 @@ def LiveScreen( elapsed=None, remaining=None, maximum_g=None, + progress_segments=None, + phase_label=None, ): self._update_auto_rotation(LCD, redraw=False) if remaining is None: @@ -355,13 +353,21 @@ def LiveScreen( textsize_rem = 5 self.Set_Mode(0) LCD.fill(backColour) - if maximum_g is not None: - LCD.write_time_centered( - maximum_g, - MAXIMUM_G_Y, - MAXIMUM_G_TEXT_SIZE, + if ( + progress_segments is not None + or phase_label is not None + or maximum_g is not None + ): + from live_screen_graphics import draw_live_overlays + + draw_live_overlays( + LCD, textColour, + progress_segments=progress_segments, + phase_label=phase_label, + maximum_g=maximum_g, ) + del draw_live_overlays LCD.write_time_centered(remaining, 82, textsize_rem, textColour) if isinstance(elapsed, tuple) and len(elapsed) == 2: LCD.write_time_centered( diff --git a/tests/test_live_display.py b/tests/test_live_display.py index 03e623a..d9fabfd 100644 --- a/tests/test_live_display.py +++ b/tests/test_live_display.py @@ -4,6 +4,7 @@ from font_renderer import measure_text, pixel_height from live_display import ( COUNTDOWN_TEXT_SIZE, + PROGRESS_RING_SEGMENTS, TRACK_AMBER_RGB, TRACK_GREEN_RGB, TRACK_OVERRUN_PURPLE_RGB, @@ -12,11 +13,13 @@ estimated_laps_display, high_contrast_text_colour, interpolate_rgb, + progress_ring_segments, rest_live_frame, rgb_to_display565, run_live_display, scheduled_track_rgb, track_live_frame, + track_phase_label, ) from timing import SessionTracker @@ -75,18 +78,40 @@ def test_maximum_countdown_width_fits_the_live_screen_position(self): def test_maximum_g_readout_fits_round_screen_safe_area(self): display_radius = 120 - y_position = 40 - text_height = pixel_height(2) - text_width = measure_text("MAX 99.99 g", 2, tabular_digits=True) + label_y = 45 + value_y = 36 + label_height = pixel_height(1) + value_height = pixel_height(3) + text_width = ( + measure_text("MAX", 1) + + 8 + + measure_text("99.99 g", 3, tabular_digits=True) + ) - for edge_y in (y_position, y_position + text_height - 1): + for edge_y in (value_y, value_y + value_height - 1): distance_from_center = edge_y - display_radius visible_width = 2 * math.sqrt( (display_radius ** 2) - (distance_from_center ** 2) ) self.assertLessEqual(text_width, visible_width) - self.assertLess(y_position + text_height, 82) + self.assertEqual(12, label_height) + self.assertLess(label_y + label_height, value_y + value_height) + self.assertLess(value_y + value_height, 82) + + def test_longest_phase_label_fits_above_maximum_g(self): + display_radius = 120 + y_position = 18 + text_height = pixel_height(1) + text_width = measure_text("FINAL LAP", 1) + + for edge_y in (y_position, y_position + text_height - 1): + distance_from_center = edge_y - display_radius + visible_width = 2 * math.sqrt( + (display_radius ** 2) - (distance_from_center ** 2) + ) + self.assertLessEqual(text_width, visible_width) + self.assertLess(y_position + text_height, 36) def test_lap_label_and_enlarged_value_fit_without_touching_countdown(self): display_radius = 120 @@ -117,6 +142,31 @@ def test_lap_estimate_divides_remaining_time_and_uses_bounded_precision(self): with self.subTest(invalid=invalid): self.assertIsNone(estimated_laps_display(100, invalid)) + def test_progress_ring_tracks_scheduled_time_and_clamps_at_both_ends(self): + self.assertEqual(PROGRESS_RING_SEGMENTS, progress_ring_segments(0, 60)) + self.assertEqual(PROGRESS_RING_SEGMENTS, progress_ring_segments(-1, 60)) + self.assertEqual(PROGRESS_RING_SEGMENTS // 2, progress_ring_segments(30, 60)) + self.assertEqual(0, progress_ring_segments(60, 60)) + self.assertEqual(0, progress_ring_segments(90, 60)) + with self.assertRaises(ValueError): + progress_ring_segments(0, 0) + + def test_phase_labels_identify_track_warning_final_lap_and_overrun(self): + duration = 600 + + self.assertEqual("TRACK", track_phase_label(0, duration)) + self.assertEqual("TRACK", track_phase_label(399, duration)) + self.assertEqual("WARNING", track_phase_label(400, duration)) + self.assertEqual( + "FINAL LAP", + track_phase_label(510, duration, avg_lap_time_seconds=90), + ) + self.assertEqual("OVERRUN", track_phase_label(600, duration, 90)) + self.assertEqual("OVERRUN", track_phase_label(700, duration, 90)) + self.assertEqual("WARNING", track_phase_label(510, duration, True)) + with self.assertRaises(ValueError): + track_phase_label(0, 0) + def test_simulated_session_redraws_at_most_once_per_visible_second(self): clock = FakeClock() lcd = FakeLCD() @@ -344,6 +394,12 @@ def test_track_frames_follow_gradient_and_use_deep_purple_for_overrun(self): ) self.assertEqual("MAX 0.00 g", start[5]) self.assertEqual("MAX 2.34 g", overrun[5]) + self.assertEqual(PROGRESS_RING_SEGMENTS, start[6]) + self.assertEqual(progress_ring_segments(200, 600), one_third[6]) + self.assertEqual(0, overrun[6]) + self.assertEqual("TRACK", start[7]) + self.assertEqual("WARNING", two_thirds[7]) + self.assertEqual("OVERRUN", overrun[7]) def test_track_frame_can_replace_elapsed_time_with_laps_remaining(self): lcd = FakeLCD() @@ -368,6 +424,24 @@ def test_track_frame_can_replace_elapsed_time_with_laps_remaining(self): self.assertEqual(("LAP", "6.7"), active[1]) self.assertEqual(("LAP", "0.0"), overrun[1]) + final_lap = track_live_frame( + session, + 611, + lcd, + lower_display="laps_remaining", + avg_lap_time_seconds=90, + ) + self.assertEqual("FINAL LAP", final_lap[7]) + + count_up = track_live_frame( + session, + 611, + lcd, + lower_display="elapsed", + avg_lap_time_seconds=90, + ) + self.assertEqual("WARNING", count_up[7]) + def test_unavailable_lap_estimate_falls_back_to_elapsed_time(self): lcd = FakeLCD() session = SessionTracker(duration_mins=10, clock=lambda: 100) @@ -393,6 +467,8 @@ def test_rest_frame_uses_larger_countdown_size(self): self.assertEqual(COUNTDOWN_TEXT_SIZE, frame[2]) self.assertEqual("00:00", frame[1]) self.assertIsNone(frame[5]) + self.assertEqual(PROGRESS_RING_SEGMENTS, frame[6]) + self.assertEqual("REST", frame[7]) def test_loop_delay_must_be_bounded(self): session = SessionTracker(duration_mins=1, live=True) diff --git a/tests/test_touch_mode.py b/tests/test_touch_mode.py index 2898693..3e5ea30 100644 --- a/tests/test_touch_mode.py +++ b/tests/test_touch_mode.py @@ -3,6 +3,9 @@ import types import unittest +from font_renderer import measure_text +import live_screen_graphics + def import_touch_driver(): machine = types.ModuleType("machine") @@ -95,9 +98,19 @@ def test_clear_gesture_uses_viewer_relative_up(self): self.assertFalse(touch.ClearGesture(None)) def test_live_screen_places_smaller_maximum_g_clear_of_countdown(self): + self.assertEqual( + ("MAX", "1.23 g"), + live_screen_graphics.maximum_g_parts("MAX 1.23 g"), + ) + self.assertEqual( + ("MAX", "--"), + live_screen_graphics.maximum_g_parts("MAX --"), + ) + class FakeLCD: green = 1 white = 2 + width = 240 def __init__(self): self.calls = [] @@ -108,6 +121,34 @@ def fill(self, colour): def write_time_centered(self, *args): self.calls.append(("write_time_centered",) + args) + def text_width(self, text, size, tabular_digits=False): + return measure_text( + text, + size, + tabular_digits=tabular_digits, + ) + + def write_text( + self, + text, + x_position, + y_position, + size, + colour, + tabular_digits=False, + ): + self.calls.append( + ( + "write_text", + text, + x_position, + y_position, + size, + colour, + tabular_digits, + ) + ) + def show(self): self.calls.append(("show",)) @@ -124,16 +165,35 @@ def show(self): maximum_g="MAX 1.23 g", ) - text_calls = [ + max_calls = [call for call in lcd.calls if call[0] == "write_text"] + value = "1.23 g" + label_width = measure_text("MAX", 1) + value_width = measure_text(value, 3, tabular_digits=True) + label_x = (lcd.width - label_width - 8 - value_width) // 2 + self.assertEqual( + [ + ("write_text", "MAX", label_x, 45, 1, lcd.white, False), + ( + "write_text", + value, + label_x + label_width + 8, + 36, + 3, + lcd.white, + True, + ), + ], + max_calls, + ) + time_calls = [ call for call in lcd.calls if call[0] == "write_time_centered" ] self.assertEqual( [ - ("write_time_centered", "MAX 1.23 g", 40, 2, lcd.white), ("write_time_centered", "19:48", 82, 7, lcd.white), ("write_time_centered", "00:12", 180, 3, lcd.white), ], - text_calls, + time_calls, ) self.assertEqual(("show",), lcd.calls[-1]) @@ -178,6 +238,72 @@ def show(self): text_calls, ) + def test_live_screen_draws_bounded_progress_ring_and_phase_label(self): + class FakeLCD: + green = 1 + white = 2 + + def __init__(self): + self.calls = [] + + def fill(self, colour): + self.calls.append(("fill", colour)) + + def line(self, *args): + self.calls.append(("line",) + args) + + def write_time_centered(self, *args): + self.calls.append(("write_time_centered",) + args) + + def show(self): + self.calls.append(("show",)) + + touch = self.make_touch() + lcd = FakeLCD() + + packed_points = live_screen_graphics.PROGRESS_RING_POINTS + self.assertEqual(74, len(packed_points)) + for offset in range(0, len(packed_points), 2): + x_position = packed_points[offset] + y_position = packed_points[offset + 1] + self.assertTrue(0 <= x_position < 240) + self.assertTrue(0 <= y_position < 240) + radius = ( + ((x_position - 120) ** 2) + ((y_position - 120) ** 2) + ) ** 0.5 + self.assertGreaterEqual(radius, 111) + self.assertLessEqual(radius, 113) + + touch.LiveScreen( + lcd, + textsize_rem=7, + backColour=lcd.green, + textColour=lcd.white, + elapsed="00:12", + remaining="19:48", + progress_segments=18, + phase_label="WARNING", + ) + + ring_lines = [call for call in lcd.calls if call[0] == "line"] + self.assertEqual(36, len(ring_lines)) + for _name, x1, y1, x2, y2, colour in ring_lines: + self.assertTrue( + all(0 <= value < 240 for value in (x1, y1, x2, y2)) + ) + self.assertEqual(lcd.white, colour) + self.assertIn( + ( + "write_time_centered", + "WARNING", + live_screen_graphics.PROGRESS_PHASE_Y, + live_screen_graphics.PROGRESS_PHASE_TEXT_SIZE, + lcd.white, + ), + lcd.calls, + ) + self.assertEqual(("show",), lcd.calls[-1]) + if __name__ == "__main__": unittest.main()