diff --git a/README.md b/README.md index e1f23a3..55a5b5d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Trackday or race session timer. * Added a hardware Bill of Materials identifying the cased Waveshare RP2040-Touch-LCD-1.28-B reference variant. * 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. * Added a persistent average lap-time setting with separate minute and second editing for future lap features. -* Expanded hardware-independent regression coverage to 195 tests. +* Added a conditional Lower Display setting that can replace the running track-session count-up line with an approximate laps-remaining value. +* Expanded hardware-independent regression coverage to 207 tests. ## Version 4.2 ### v4.2.0 @@ -112,6 +113,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. +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. ## Startup splash @@ -133,7 +136,7 @@ Press and continuously hold the touchscreen for five seconds from the Timer Read * **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. * **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. The value is reserved for later lap functionality and does not alter current sessions. 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, 20-minute track/rest sessions, and disabled Launch Mode. +* **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. If the IMU is unavailable in G Mode, the firmware shows an actionable message and safely returns to Timer Mode. The timer remains usable. @@ -247,13 +250,13 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity, G Mode, A Version 4.3.0 uses two separate configuration scopes: * `firmware/params.json` is the repository source for system-owned choices and display behavior: `DURATION_VALUES`, `LAUNCH_SENSE_VALUES`, `VERSION`, `DISPLAY_DELAY_REST`, `DISPLAY_DELAY_REST_COLOUR`, `STARTUP_SPLASH_DURATION_SEC`, `HARDWARE_SPLASH_DURATION_SEC`, `MODE_MENU_HOLD_SEC`, and `AUTO_DIM_PERCENT` (an integer from 1 to 100, default 25). -* `firmware/user.json` is the optional fresh-install example. The device-root `user.json` contains the current selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), `SENSITIVITY` (launch threshold; `0` disables Launch Mode), `OPERATING_MODE` (`timer` or `g`), `BRIGHTNESS_PERCENT`, `DISPLAY_ROTATION_DEG` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`), `AUTO_DIM_ENABLED` (`true` or `false`), and `AVG_LAP_TIME_SECONDS` (integer `0` to `3599`; `0` means not configured). +* `firmware/user.json` is the optional fresh-install example. The device-root `user.json` contains the current selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), `SENSITIVITY` (launch threshold; `0` disables Launch Mode), `OPERATING_MODE` (`timer` or `g`), `BRIGHTNESS_PERCENT`, `DISPLAY_ROTATION_DEG` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`), `AUTO_DIM_ENABLED` (`true` or `false`), `AVG_LAP_TIME_SECONDS` (integer `0` to `3599`; `0` means not configured), and `TRACK_LOWER_DISPLAY` (`elapsed` or `laps_remaining`; laps require a non-zero average). 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 the [User Guide](docs/USER_GUIDE.md) for the practical meaning of every configured value. Directional summary labels use a dashboard mounting convention: the screen faces the driver, the screen-normal axis represents acceleration/braking, and the viewer-horizontal axis represents left/right. Fixed and automatic quarter-turn display rotations are applied to the lateral mapping. Mounting the board with its screen facing away from the driver reverses the longitudinal labels. -The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically, while older files gain Timer Mode, 100% brightness, 0° rotation, disabled Auto-Dim, and an unset average lap-time default. +The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically, while older files gain Timer Mode, 100% brightness, 0° rotation, disabled Auto-Dim, an unset average lap time, and the elapsed Count Up lower-display default. ## Host-side tests @@ -263,4 +266,4 @@ Run the hardware-independent regression suite with: python -m unittest discover -s tests -t . -v ``` -The suite uses fakes for time, continuous holds, touch gestures, automatic and fixed display rotation, Ready-screen inactivity dimming and motion wake-up, average-lap-time editing and migration, gravity filtering/hysteresis, mode/settings navigation, graphical G vectors, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 4.0.0 was additionally validated on the supported Waveshare board for both startup screens, Timer and G Mode boots, native G-meter rendering, LCD/font rendering, CST816S touch-state detection, QMI8658 sampling, saved settings, launch behavior, and the Ready-screen battery indicator. +The suite uses fakes for time, continuous holds, touch gestures, automatic and fixed display rotation, Ready-screen inactivity dimming and motion wake-up, average-lap-time editing, laps-remaining estimates and migration, gravity filtering/hysteresis, mode/settings navigation, graphical G vectors, display calls, filesystem operations, accelerometer samples, battery readings, and USB power state. Version 4.0.0 was additionally validated on the supported Waveshare board for both startup screens, Timer and G Mode boots, native G-meter rendering, LCD/font rendering, CST816S touch-state detection, QMI8658 sampling, saved settings, launch behavior, and the Ready-screen battery indicator. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c6cdbe8..6fa5452 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -10,6 +10,7 @@ The following describes general operation of both the ``Track Session`` and ``Re * 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. +* 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. * Following termination, an eight-page post-session review begins. Actual run time, overrun, total maximum G, maximum acceleration, maximum braking, maximum left G, maximum right G, and the stop reason each have a separate high-visibility screen. Swipe ``Left`` to advance and ``Right`` to return to the previous result. The first page cannot be bypassed backwards, and cool-down starts only after swiping ``Left`` from the eighth and final page. ``--`` and ``IMU UNAVAILABLE`` mean the session completed without usable sensor data; ``IMU DATA PARTIAL`` means peaks captured before a sensor fault were retained. The review is for immediate inspection only and is not saved to flash. @@ -18,7 +19,7 @@ The following describes general operation of both the ``Track Session`` and ``Re * 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``. ## Configuration / Setup -Track duration, rest duration, launch sensitivity, operating mode, brightness, display rotation, Auto-Dim state, and average lap time are saved to `user.json` when changed. These settings persist across restarts and power loss. If the file is missing, damaged, or contains unsupported values, the timer restores safe defaults and rewrites the file using the canonical setting names. +Track duration, rest duration, launch sensitivity, operating mode, brightness, display rotation, Auto-Dim state, average lap time, and the running track lower-display choice are saved to `user.json` when changed. These settings persist across restarts and power loss. If the file is missing, damaged, or contains unsupported values, the timer restores safe defaults and rewrites the file using the canonical setting names. ### Operating Mode Menu @@ -48,8 +49,9 @@ Choose ``Settings`` from the operating-mode menu. * ``Brightness`` offers 25%, 50%, 75%, and 100%. Left/right previews each level immediately, ``Swipe UP`` saves, and ``Swipe DOWN`` cancels and restores the previous level. * ``Rotation`` offers ``Auto`` followed by fixed 0°, 90°, 180°, and 270° choices. Fixed values are the physical device rotation clockwise from its original 0° position. ``Auto`` uses the QMI8658 gravity reading to keep the screen upright and shows the currently detected angle during preview. Left/right previews immediately; text and directional gestures rotate together, so swipes stay relative to the displayed instructions. ``Swipe UP`` saves, while ``Swipe DOWN`` safely restores the previous orientation. * ``Auto-Dim`` offers ``Off`` and ``On``. When On, 10 continuous seconds without motion reduces only the Timer Ready screen to the level configured by `AUTO_DIM_PERCENT` in `params.json` (25% by default; valid values are integer percentages from 1 to 100). Moving the device immediately restores the saved brightness and restarts the inactivity interval. Leaving Ready for a menu, configuration, Launch Mode, an active session, review, rest, or G Mode always restores normal saved brightness. The setting defaults to Off and does not change the saved Brightness percentage. If the IMU is unavailable, the timer shows a warning and safely remains at normal brightness. -* ``Avg Lap Time`` stores the expected lap duration for the current circuit. The first editor stage sets minutes from `00` to `59`; swipe Up to continue, then set seconds from `00` to `59` and swipe Up again to save. Swipe Left or Right to change the active component. Swipe Down from either stage to cancel the complete edit without writing it. `00:00` means not configured. This value is saved for later lap features and does not currently change Timer Mode, the Ready screen, session review, or G Mode. -* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 0° rotation, disabled Auto-Dim, an unset `00:00` average lap time, 20-minute track and rest sessions, and disabled Launch Mode. Cancelling changes nothing. +* ``Avg Lap Time`` stores the expected lap duration for the current circuit. The first editor stage sets minutes from `00` to `59`; swipe Up to continue, then set seconds from `00` to `59` and swipe Up again to save. Swipe Left or Right to change the active component. Swipe Down from either stage to cancel the complete edit without writing it. `00:00` means not configured. +* ``Lower Display`` appears only after Avg Lap Time has a non-zero value. Choose ``COUNT UP`` to retain the elapsed timer beneath the running track countdown, or ``LAPS LEFT`` to show the approximate remaining-lap calculation. Swipe Left or Right to choose, Up to save, or Down to cancel. Clearing Avg Lap Time back to `00:00` hides this setting and atomically restores Count Up. This selection affects track sessions only; pit/rest timing, the Ready screen, review, and G Mode remain unchanged. +* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 0° rotation, disabled Auto-Dim, an unset `00:00` average lap time, Count Up, 20-minute track and rest sessions, and disabled Launch Mode. Cancelling changes nothing. * Choose ``Back`` or swipe down to return to the operating-mode menu, then select or cancel back to an operating mode. Auto rotation continues through Ready, configuration, active track/rest timing, Launch Mode, G Mode, menus, and timed information screens. A turn must remain clear and stable for about 0.3 seconds before the display changes, which prevents flicker from road vibration or positions near a diagonal. Changing orientation does not reset or pause a session, and detected angles are not written repeatedly to flash. diff --git a/firmware/live_display.py b/firmware/live_display.py index 477e149..aca86c2 100644 --- a/firmware/live_display.py +++ b/firmware/live_display.py @@ -107,17 +107,54 @@ def high_contrast_text_colour(rgb, black, white): def _visible_times(session, now): - elapsed_seconds = max(0, int(now - session.start_time)) - remaining_seconds = max(0, int(session.duration_secs) - elapsed_seconds) + elapsed_seconds, remaining_seconds = _visible_seconds(session, now) return ( secs_to_mins_secs(remaining_seconds), secs_to_mins_secs(elapsed_seconds), ) -def track_live_frame(session, now, lcd, maximum_g=None): +def _visible_seconds(session, now): + elapsed_seconds = max(0, int(now - session.start_time)) + remaining_seconds = max(0, int(session.duration_secs) - elapsed_seconds) + return elapsed_seconds, remaining_seconds + + +def estimated_laps_display(remaining_seconds, avg_lap_time_seconds): + """Return separate lap label/value content, or ``None`` if unavailable.""" + if ( + not isinstance(avg_lap_time_seconds, int) + or isinstance(avg_lap_time_seconds, bool) + or avg_lap_time_seconds <= 0 + ): + return None + remaining = max(0, int(remaining_seconds)) + estimate = remaining / avg_lap_time_seconds + if estimate < 100: + value = "{:.1f}".format(estimate) + else: + value = str(int(estimate + 0.5)) + return "LAP", value + + +def track_live_frame( + session, + now, + lcd, + maximum_g=None, + lower_display="elapsed", + avg_lap_time_seconds=0, +): """Return the track timer with a smooth proportional colour gradient.""" + _, remaining_seconds = _visible_seconds(session, now) remaining, elapsed = _visible_times(session, now) + if lower_display == "laps_remaining": + laps_display = estimated_laps_display( + remaining_seconds, + avg_lap_time_seconds, + ) + if laps_display is not None: + elapsed = laps_display if now >= session.end_time: background_rgb = TRACK_OVERRUN_PURPLE_RGB remaining = "00:00" diff --git a/firmware/operating_modes.py b/firmware/operating_modes.py index e0fcf4b..e6bd610 100644 --- a/firmware/operating_modes.py +++ b/firmware/operating_modes.py @@ -7,7 +7,9 @@ DISPLAY_ROTATION_VALUES, MAX_AVG_LAP_TIME_SECONDS, OPERATING_MODES, + TRACK_LOWER_DISPLAY_VALUES, persist_setting, + persist_settings, restore_user_defaults, ) @@ -27,6 +29,7 @@ ("Rotation", "rotation"), ("Auto-Dim", "auto_dim"), ("Avg Lap Time", "avg_lap_time"), + ("Lower Display", "lower_display"), ("Restore defaults", "restore"), ("Back", "back"), ) @@ -66,9 +69,22 @@ def mode_menu_lines(index): return _selection_lines("Operating Mode", label, position) -def settings_menu_lines(index): - label = SETTINGS_CHOICES[index][0] - position = "{} / {}".format(index + 1, len(SETTINGS_CHOICES)) +def available_settings_choices(avg_lap_time_seconds): + """Hide the lap-dependent display choice until lap time is configured.""" + if ( + isinstance(avg_lap_time_seconds, int) + and not isinstance(avg_lap_time_seconds, bool) + and avg_lap_time_seconds > 0 + ): + return SETTINGS_CHOICES + return tuple( + choice for choice in SETTINGS_CHOICES if choice[1] != "lower_display" + ) + + +def settings_menu_lines(index, choices=SETTINGS_CHOICES): + label = choices[index][0] + position = "{} / {}".format(index + 1, len(choices)) return _selection_lines("Settings", label, position, label_size=2) @@ -107,12 +123,13 @@ def select_operating_mode(touch, lcd, current_mode): ) -def select_settings_action(touch, lcd): +def select_settings_action(touch, lcd, avg_lap_time_seconds=0): + choices = available_settings_choices(avg_lap_time_seconds) return _select_choice( touch, lcd, - SETTINGS_CHOICES, - settings_menu_lines, + choices, + lambda index: settings_menu_lines(index, choices), ) @@ -264,6 +281,46 @@ def draw(): return original, False +def lower_display_lines(value): + label = "LAPS LEFT" if value == "laps_remaining" else "COUNT UP" + return [ + ["Lower Display", None, 35, 2, "white"], + [label, None, 88, 3, "white"], + ["Track sessions", None, 150, 1, "white"], + ["L/R: change", None, 180, 1, "white"], + ["UP: save", None, 202, 1, "white"], + ["DOWN: cancel", None, 220, 1, "white"], + ] + + +def select_lower_display(touch, lcd, current): + """Select the running track screen's lower-line content.""" + values = TRACK_LOWER_DISPLAY_VALUES + try: + index = values.index(current) + except ValueError: + index = values.index(DEFAULT_USER_PARAMS["TRACK_LOWER_DISPLAY"]) + original = values[index] + + def draw(): + touch.ControlScreen( + lcd, + text_array=lower_display_lines(values[index]), + back_colour="black", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture in ("left", "right"): + index = 1 - index + draw() + elif gesture == "up": + return values[index], True + elif gesture == "down": + return original, False + + def apply_rotation(lcd, touch, degrees, auto_rotation=None): """Apply one mount angle to rendering and directional gestures.""" if degrees == AUTO_ROTATION: @@ -397,7 +454,11 @@ def _run_settings( auto_rotation=None, ): while True: - action = select_settings_action(touch, lcd) + action = select_settings_action( + touch, + lcd, + user_params["AVG_LAP_TIME_SECONDS"], + ) if action is None or action == "back": return user_params, False @@ -464,12 +525,34 @@ def _run_settings( lcd, previous, ) + if not should_save: + continue + updates = {"AVG_LAP_TIME_SECONDS": selected} + if selected == 0: + updates["TRACK_LOWER_DISPLAY"] = DEFAULT_USER_PARAMS[ + "TRACK_LOWER_DISPLAY" + ] + updated, saved = persist_settings( + user_file, + user_params, + updates, + ) + if saved: + user_params = updated + + elif action == "lower_display": + previous = user_params["TRACK_LOWER_DISPLAY"] + selected, should_save = select_lower_display( + touch, + lcd, + previous, + ) if not should_save: continue updated, saved = persist_setting( user_file, user_params, - "AVG_LAP_TIME_SECONDS", + "TRACK_LOWER_DISPLAY", selected, ) if saved: diff --git a/firmware/settings.py b/firmware/settings.py index 934e6cc..d66bfb4 100644 --- a/firmware/settings.py +++ b/firmware/settings.py @@ -29,12 +29,14 @@ "DISPLAY_ROTATION_DEG": 0, "AUTO_DIM_ENABLED": False, "AVG_LAP_TIME_SECONDS": 0, + "TRACK_LOWER_DISPLAY": "elapsed", } OPERATING_MODES = ("timer", "g") BRIGHTNESS_VALUES = (25, 50, 75, 100) DISPLAY_ROTATION_VALUES = (0, 90, 180, 270, "auto") MAX_AVG_LAP_TIME_SECONDS = (60 * 60) - 1 +TRACK_LOWER_DISPLAY_VALUES = ("elapsed", "laps_remaining") LEGACY_USER_KEYS = { "TRACK_LENGTH": "RACE_LENGTH", @@ -192,6 +194,17 @@ def normalize_user_params(data, system_params=None): or avg_lap_time_seconds < 0 or avg_lap_time_seconds > MAX_AVG_LAP_TIME_SECONDS ) + track_lower_display = migrated.get( + "TRACK_LOWER_DISPLAY", + DEFAULT_USER_PARAMS["TRACK_LOWER_DISPLAY"], + ) + track_lower_display_invalid = ( + track_lower_display not in TRACK_LOWER_DISPLAY_VALUES + or ( + avg_lap_time_seconds == 0 + and track_lower_display != DEFAULT_USER_PARAMS["TRACK_LOWER_DISPLAY"] + ) + ) if ( not isinstance(race_length, int) @@ -224,6 +237,8 @@ def normalize_user_params(data, system_params=None): auto_dim_enabled = DEFAULT_USER_PARAMS["AUTO_DIM_ENABLED"] if avg_lap_time_invalid: avg_lap_time_seconds = DEFAULT_USER_PARAMS["AVG_LAP_TIME_SECONDS"] + if track_lower_display_invalid or avg_lap_time_seconds == 0: + track_lower_display = DEFAULT_USER_PARAMS["TRACK_LOWER_DISPLAY"] normalized = { "SENSITIVITY": sensitivity, @@ -234,10 +249,14 @@ def normalize_user_params(data, system_params=None): "DISPLAY_ROTATION_DEG": display_rotation, "AUTO_DIM_ENABLED": auto_dim_enabled, "AVG_LAP_TIME_SECONDS": avg_lap_time_seconds, + "TRACK_LOWER_DISPLAY": track_lower_display, } return ( normalized, - auto_dim_invalid or avg_lap_time_invalid or normalized != source, + auto_dim_invalid + or avg_lap_time_invalid + or track_lower_display_invalid + or normalized != source, ) @@ -336,14 +355,35 @@ def update_json(json_data=None, key=None, value=None): return updated -def persist_setting(file, json_data, key, value, debug=True): - """Persist one setting and retain the known-good dictionary on failure.""" - updated = update_json(json_data, key, value) - if updated is None or not file_out(file, updated, debug=debug): +def persist_settings(file, json_data, updates, debug=True): + """Atomically persist multiple settings as one configuration update.""" + if ( + not isinstance(json_data, dict) + or not isinstance(updates, dict) + or not updates + or any( + key is None or key == "" or value is None + for key, value in updates.items() + ) + ): + return json_data, False + updated = dict(json_data) + updated.update(updates) + if not file_out(file, updated, debug=debug): return json_data, False return updated, True +def persist_setting(file, json_data, key, value, debug=True): + """Persist one setting and retain the known-good dictionary on failure.""" + return persist_settings( + file, + json_data, + {key: value}, + debug=debug, + ) + + def restore_user_defaults(file, debug=True): """Atomically persist and return a fresh canonical default dictionary.""" defaults = _copy_params(DEFAULT_USER_PARAMS) diff --git a/firmware/timer_mode.py b/firmware/timer_mode.py index ac27e49..1a14af9 100644 --- a/firmware/timer_mode.py +++ b/firmware/timer_mode.py @@ -281,6 +281,8 @@ def sample_session_g(_now): maximum_g=session_g_peak.display_label( now - track_session.start_time ), + lower_display=user_params["TRACK_LOWER_DISPLAY"], + avg_lap_time_seconds=user_params["AVG_LAP_TIME_SECONDS"], ), draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), stop_check=lambda: touch.StopGesture(lcd), diff --git a/firmware/touch_drive.py b/firmware/touch_drive.py index f6e0f54..dbbfe9b 100644 --- a/firmware/touch_drive.py +++ b/firmware/touch_drive.py @@ -15,6 +15,10 @@ 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): @@ -359,7 +363,21 @@ def LiveScreen( textColour, ) LCD.write_time_centered(remaining, 82, textsize_rem, textColour) - LCD.write_time_centered(elapsed, 180, 3, textColour) + if isinstance(elapsed, tuple) and len(elapsed) == 2: + LCD.write_time_centered( + elapsed[0], + LAP_LABEL_Y, + LAP_LABEL_TEXT_SIZE, + textColour, + ) + LCD.write_time_centered( + elapsed[1], + LAP_VALUE_Y, + LAP_VALUE_TEXT_SIZE, + textColour, + ) + else: + LCD.write_time_centered(elapsed, 180, 3, textColour) LCD.show() diff --git a/firmware/user.json b/firmware/user.json index 021eb6a..205981c 100644 --- a/firmware/user.json +++ b/firmware/user.json @@ -6,5 +6,6 @@ "BRIGHTNESS_PERCENT": 100, "DISPLAY_ROTATION_DEG": 0, "AUTO_DIM_ENABLED": false, - "AVG_LAP_TIME_SECONDS": 0 + "AVG_LAP_TIME_SECONDS": 0, + "TRACK_LOWER_DISPLAY": "elapsed" } diff --git a/tests/test_live_display.py b/tests/test_live_display.py index fc3f006..03e623a 100644 --- a/tests/test_live_display.py +++ b/tests/test_live_display.py @@ -9,6 +9,7 @@ TRACK_OVERRUN_PURPLE_RGB, TRACK_RED_RGB, TRACK_YELLOW_RGB, + estimated_laps_display, high_contrast_text_colour, interpolate_rgb, rest_live_frame, @@ -87,6 +88,35 @@ def test_maximum_g_readout_fits_round_screen_safe_area(self): self.assertLess(y_position + text_height, 82) + def test_lap_label_and_enlarged_value_fit_without_touching_countdown(self): + display_radius = 120 + countdown_bottom = 82 + pixel_height(COUNTDOWN_TEXT_SIZE) + elements = ( + ("LAP", 160, 1), + ("3600", 176, 4), + ) + + self.assertGreaterEqual(elements[0][1], countdown_bottom) + for text, y_position, size in elements: + text_height = pixel_height(size) + text_width = measure_text(text, size, tabular_digits=True) + 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) + + def test_lap_estimate_divides_remaining_time_and_uses_bounded_precision(self): + self.assertEqual(("LAP", "1.7"), estimated_laps_display(100, 60)) + self.assertEqual(("LAP", "99.9"), estimated_laps_display(5994, 60)) + self.assertEqual(("LAP", "101"), estimated_laps_display(6030, 60)) + self.assertEqual(("LAP", "0.0"), estimated_laps_display(-1, 60)) + + for invalid in (0, -1, True, 60.0, None, "60"): + with self.subTest(invalid=invalid): + self.assertIsNone(estimated_laps_display(100, invalid)) + def test_simulated_session_redraws_at_most_once_per_visible_second(self): clock = FakeClock() lcd = FakeLCD() @@ -315,6 +345,44 @@ 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]) + def test_track_frame_can_replace_elapsed_time_with_laps_remaining(self): + lcd = FakeLCD() + session = SessionTracker(duration_mins=10, clock=lambda: 100) + session.start_session() + + active = track_live_frame( + session, + 100, + lcd, + lower_display="laps_remaining", + avg_lap_time_seconds=90, + ) + overrun = track_live_frame( + session, + 700, + lcd, + lower_display="laps_remaining", + avg_lap_time_seconds=90, + ) + + self.assertEqual(("LAP", "6.7"), active[1]) + self.assertEqual(("LAP", "0.0"), overrun[1]) + + def test_unavailable_lap_estimate_falls_back_to_elapsed_time(self): + lcd = FakeLCD() + session = SessionTracker(duration_mins=10, clock=lambda: 100) + session.start_session() + + frame = track_live_frame( + session, + 112, + lcd, + lower_display="laps_remaining", + avg_lap_time_seconds=0, + ) + + self.assertEqual("00:12", frame[1]) + def test_rest_frame_uses_larger_countdown_size(self): lcd = FakeLCD() session = SessionTracker(duration_mins=10, clock=lambda: 100) @@ -323,6 +391,7 @@ def test_rest_frame_uses_larger_countdown_size(self): frame = rest_live_frame(session, 100, lcd) self.assertEqual(COUNTDOWN_TEXT_SIZE, frame[2]) + self.assertEqual("00:00", frame[1]) self.assertIsNone(frame[5]) def test_loop_delay_must_be_bounded(self): diff --git a/tests/test_operating_modes.py b/tests/test_operating_modes.py index f92f951..d3fc4af 100644 --- a/tests/test_operating_modes.py +++ b/tests/test_operating_modes.py @@ -8,6 +8,7 @@ from operating_modes import ( SETTINGS_CHOICES, apply_brightness, + available_settings_choices, avg_lap_time_lines, auto_dim_lines, brightness_duty, @@ -15,12 +16,14 @@ confirm_restore_defaults, configure_operating_mode, format_avg_lap_time, + lower_display_lines, mode_menu_lines, restore_confirmation_lines, rotation_lines, select_brightness, select_avg_lap_time, select_auto_dim, + select_lower_display, select_operating_mode, select_rotation, settings_menu_lines, @@ -181,6 +184,8 @@ def test_all_menu_text_fits_round_display_at_every_rotation(self): self.assert_round_fit( avg_lap_time_lines(3599, component) ) + for value in ("elapsed", "laps_remaining"): + self.assert_round_fit(lower_display_lines(value)) self.assert_round_fit(rotation_lines(rotation)) self.assert_round_fit( rotation_lines("auto", FakeAutoRotation(rotation)) @@ -245,6 +250,31 @@ def test_average_lap_time_editor_can_cancel_from_either_component(self): self.assertEqual(90, selected) self.assertFalse(should_save) + def test_lower_display_is_available_only_with_average_lap_time(self): + without_average = available_settings_choices(0) + with_average = available_settings_choices(90) + + self.assertNotIn("lower_display", [choice[1] for choice in without_average]) + self.assertIn("lower_display", [choice[1] for choice in with_average]) + self.assertEqual(len(without_average) + 1, len(with_average)) + + def test_lower_display_selection_saves_and_cancels(self): + selected, should_save = select_lower_display( + FakeTouch(["right", "up"]), + object(), + "elapsed", + ) + cancelled, cancel_save = select_lower_display( + FakeTouch(["left", "down"]), + object(), + "elapsed", + ) + + self.assertEqual("laps_remaining", selected) + self.assertTrue(should_save) + self.assertEqual("elapsed", cancelled) + self.assertFalse(cancel_save) + def test_rotation_cancel_restores_display_and_touch_preview(self): lcd = FakeLCD() touch = FakeTouch(["right", "down"]) @@ -446,6 +476,68 @@ def test_cancelled_average_lap_time_is_not_persisted(self): self.assertEqual(0, updated["AVG_LAP_TIME_SECONDS"]) self.assertFalse(os.path.exists(path)) + def test_laps_remaining_lower_display_is_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + configured = dict(DEFAULT_USER_PARAMS) + configured["AVG_LAP_TIME_SECONDS"] = 90 + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "left", "up", # Settings + "right", "right", "right", "right", "up", + # Lower Display + "right", "up", # LAPS LEFT, save + "down", # leave Settings + "down", # cancel mode menu + ] + ), + FakeLCD(), + configured, + path, + ) + + self.assertEqual("timer", mode) + self.assertEqual( + "laps_remaining", + updated["TRACK_LOWER_DISPLAY"], + ) + self.assertEqual( + "laps_remaining", + file_in(path, debug=False)["TRACK_LOWER_DISPLAY"], + ) + + def test_clearing_average_atomically_restores_elapsed_display(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + configured = dict(DEFAULT_USER_PARAMS) + configured["AVG_LAP_TIME_SECONDS"] = 60 + configured["TRACK_LOWER_DISPLAY"] = "laps_remaining" + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "left", "up", # Settings + "right", "right", "right", "up", # Avg Lap Time + "left", "up", # 00 minutes, next + "up", # 00 seconds, save + "down", # leave Settings + "down", # cancel mode menu + ] + ), + FakeLCD(), + configured, + path, + ) + + saved = file_in(path, debug=False) + self.assertEqual("timer", mode) + self.assertEqual(0, updated["AVG_LAP_TIME_SECONDS"]) + self.assertEqual("elapsed", updated["TRACK_LOWER_DISPLAY"]) + self.assertEqual(0, saved["AVG_LAP_TIME_SECONDS"]) + self.assertEqual("elapsed", saved["TRACK_LOWER_DISPLAY"]) + def test_restore_defaults_requires_confirmation_and_returns_timer(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") @@ -459,7 +551,7 @@ def test_restore_defaults_requires_confirmation_and_returns_timer(self): touch = FakeTouch( [ "right", "up", # Settings from G Mode - "right", "right", "right", "right", "up", + "right", "right", "right", "right", "right", "up", # Restore defaults "right", "up", # Confirm RESTORE ] diff --git a/tests/test_settings.py b/tests/test_settings.py index 80cd7f2..d03c23e 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -13,6 +13,7 @@ load_configuration, normalize_user_params, persist_setting, + persist_settings, restore_user_defaults, update_json, validate_system_params, @@ -49,6 +50,28 @@ def test_failed_persist_retains_known_good_dictionary(self): self.assertFalse(saved) self.assertIs(settings, updated) + def test_multiple_settings_are_persisted_atomically(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + settings = dict(DEFAULT_USER_PARAMS) + + updated, saved = persist_settings( + path, + settings, + { + "AVG_LAP_TIME_SECONDS": 90, + "TRACK_LOWER_DISPLAY": "laps_remaining", + }, + debug=False, + ) + + self.assertTrue(saved) + self.assertEqual(90, updated["AVG_LAP_TIME_SECONDS"]) + self.assertEqual( + "laps_remaining", + file_in(path, debug=False)["TRACK_LOWER_DISPLAY"], + ) + def test_serialization_failure_preserves_existing_file(self): with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "user.json") @@ -210,6 +233,7 @@ def test_legacy_user_keys_are_migrated_and_removed(self): "DISPLAY_ROTATION_DEG": 0, "AUTO_DIM_ENABLED": False, "AVG_LAP_TIME_SECONDS": 0, + "TRACK_LOWER_DISPLAY": "elapsed", }, normalized, ) @@ -226,6 +250,7 @@ def test_existing_user_file_gains_all_new_setting_defaults(self): self.assertEqual(0, normalized["DISPLAY_ROTATION_DEG"]) self.assertFalse(normalized["AUTO_DIM_ENABLED"]) self.assertEqual(0, normalized["AVG_LAP_TIME_SECONDS"]) + self.assertEqual("elapsed", normalized["TRACK_LOWER_DISPLAY"]) def test_invalid_mode_and_brightness_use_defaults(self): invalid = dict(DEFAULT_USER_PARAMS) @@ -287,6 +312,45 @@ def test_average_lap_time_accepts_only_bounded_integer_seconds(self): self.assertTrue(changed) self.assertEqual(0, normalized["AVG_LAP_TIME_SECONDS"]) + def test_laps_remaining_display_requires_a_configured_average(self): + user = dict(DEFAULT_USER_PARAMS) + user["AVG_LAP_TIME_SECONDS"] = 90 + user["TRACK_LOWER_DISPLAY"] = "laps_remaining" + + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + + self.assertFalse(changed) + self.assertEqual( + "laps_remaining", + normalized["TRACK_LOWER_DISPLAY"], + ) + + for average in (0, -1): + with self.subTest(average=average): + user["AVG_LAP_TIME_SECONDS"] = average + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual("elapsed", normalized["TRACK_LOWER_DISPLAY"]) + + def test_invalid_lower_display_values_use_elapsed_default(self): + for invalid in (None, True, "laps", "count_up", 1): + with self.subTest(invalid=invalid): + user = dict(DEFAULT_USER_PARAMS) + user["AVG_LAP_TIME_SECONDS"] = 90 + user["TRACK_LOWER_DISPLAY"] = invalid + normalized, changed = normalize_user_params( + user, + DEFAULT_SYSTEM_PARAMS, + ) + self.assertTrue(changed) + self.assertEqual("elapsed", normalized["TRACK_LOWER_DISPLAY"]) + def test_rotation_accepts_auto_and_four_angles(self): for rotation in (0, 90, 180, 270, "auto"): with self.subTest(rotation=rotation): diff --git a/tests/test_touch_mode.py b/tests/test_touch_mode.py index 043bc90..2898693 100644 --- a/tests/test_touch_mode.py +++ b/tests/test_touch_mode.py @@ -137,6 +137,47 @@ def show(self): ) self.assertEqual(("show",), lcd.calls[-1]) + def test_live_screen_uses_small_lap_label_and_larger_value(self): + class FakeLCD: + green = 1 + white = 2 + + def __init__(self): + self.calls = [] + + def fill(self, colour): + self.calls.append(("fill", colour)) + + 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() + + touch.LiveScreen( + lcd, + textsize_rem=7, + backColour=lcd.green, + textColour=lcd.white, + elapsed=("LAP", "6.7"), + remaining="19:48", + ) + + text_calls = [ + call for call in lcd.calls if call[0] == "write_time_centered" + ] + self.assertEqual( + [ + ("write_time_centered", "19:48", 82, 7, lcd.white), + ("write_time_centered", "LAP", 160, 1, lcd.white), + ("write_time_centered", "6.7", 176, 4, lcd.white), + ], + text_calls, + ) + if __name__ == "__main__": unittest.main()