diff --git a/README.md b/README.md index f9650c9..986464a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ -# Track Session Timer - v3.5 +# Track Session Timer - v4.0.0 Trackday or race session timer. # Change log +## Version 4.0 +### v4.0.0 [current] +* Added a hold-to-open operating-mode menu with persistent Timer and G modes. +* Added a responsive graphical live/peak G meter with a high-visibility peak arc. +* Added saved display-brightness control and confirmed restoration of defaults. +* Expanded hardware-independent regression coverage to 116 tests. ## Version 3.0 -### v3.5 [current] +### v3.5 * Added the Caterham startup splash with a safe text fallback. * Made Launch Mode orientation-independent, filtered, cancellable, and timeout-safe. * Added controlled touch/IMU failure handling and normal-timer degraded operation. @@ -77,9 +83,19 @@ python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \ --preview assets/startup_splash_preview.png ``` +## Operating modes and settings + +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. +* **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. Swipe up saves; swipe down cancels and restores the previous brightness. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, 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. + ## Supported hardware -Version 3.5 supports the integrated [Waveshare RP2040-Touch-LCD-1.28](https://www.waveshare.com/product/rp2040-touch-lcd-1.28.htm). This board combines the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported. +Version 4.0.0 supports the integrated [Waveshare RP2040-Touch-LCD-1.28](https://www.waveshare.com/product/rp2040-touch-lcd-1.28.htm). This board combines the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported. ### Onboard pin map @@ -144,7 +160,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 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 application.py battery.py configuration.py font_data.py font_renderer.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py params.json qmi8658.py ready_screen.py settings.py splash.py timer_mode.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : mpremote connect auto fs cp main.py : mpremote connect auto reset ``` @@ -159,7 +175,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, 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. +The display should show the Caterham v4.0.0 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: @@ -178,14 +194,14 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity is selecte ## Configuration files -Version 3.5 uses two separate configuration scopes: +Version 4.0.0 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`, `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). +* `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`, `HARDWARE_SPLASH_DURATION_SEC`, and `MODE_MENU_HOLD_SEC`. +* `user.json` contains the current user selections: `RACE_LENGTH` (track-session minutes), `REST_LENGTH` (pit-rest minutes), `SENSITIVITY` (launch threshold; `0` disables Launch Mode), `OPERATING_MODE` (`timer` or `g`), and `BRIGHTNESS_PERCENT`. 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. -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. +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 and 100% brightness defaults. ## Host-side tests @@ -195,4 +211,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 both startup screens, LCD/font rendering, CST816S touchscreen detection, QMI8658 initialization, saved settings, launch behavior, and the Ready-screen battery indicator. +The suite uses fakes for time, continuous holds, touch gestures, 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/User Guide.md b/User Guide.md index ea741b9..ea259f6 100644 --- a/User Guide.md +++ b/User Guide.md @@ -1,10 +1,10 @@ -# User Guide - v3.5 +# User Guide - v4.0.0 ## General / Sessions Use The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer. * 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``. +* 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``. Hold the screen continuously for five seconds to open the operating-mode menu. * 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. @@ -16,7 +16,36 @@ 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, and launch sensitivity 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 v3.2 setting names. +Track duration, rest duration, launch sensitivity, operating mode, and brightness 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 + +From the Timer ``Ready`` screen or G Mode, press and continuously hold the touchscreen for five seconds. Releasing before five seconds cancels entry. The menu is intentionally unavailable during track/rest timing or the Launch Mode wait. + +* Swipe ``Left`` or ``Right`` to choose ``Timer Mode``, ``G Mode``, or ``Settings``. +* Swipe ``UP`` to select the displayed choice. +* Swipe ``DOWN`` to cancel and return to the previously active mode. +* Timer Mode and G Mode selections are saved and used on the next restart. Settings returns to the menu rather than becoming an operating mode. + +#### G Mode + +Keep the device still while ``Calibrating`` is displayed. The screen then becomes a graphical G meter: + +* The green filled marker is the current filtered acceleration vector; its short trail shows recent direction of travel. +* The red hollow marker is the maximum vector since G Mode began or was reset. +* The red outer arc represents maximum magnitude against the meter's 4 g visual scale. +* ``Double Tap`` clears the peak and trail. +* Hold the screen for five seconds to reopen the operating-mode menu. + +The graph removes the stationary gravity/mounting baseline and does not rely on small numeric telemetry. If the IMU is unavailable, the timer shows an error and returns safely to Timer Mode. + +#### Device Settings + +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. +* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 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. ### Session Duration It is possible to change the duration of both the ``Track Session`` and the ``Rest in Pits``. diff --git a/application.py b/application.py new file mode 100644 index 0000000..6ddf330 --- /dev/null +++ b/application.py @@ -0,0 +1,209 @@ +"""Memory-aware operating-mode dispatcher loaded after LCD allocation.""" + +import gc +import sys +import time + + +PARAMS_FILE = "params.json" +USER_FILE = "user.json" +MODE_TIMER = "timer" +MODE_G = "g" + +TIMER_MODULES = ( + "timer_mode", + "battery", + "configuration", + "hold_detector", + "launch", + "live_display", + "ready_screen", + "timing", +) + + +def _unload_modules(names): + for name in names: + sys.modules.pop(name, None) + gc.collect() + + +def _apply_brightness(lcd, percent): + lcd.set_bl_pwm(int(round(65535 * int(percent) / 100))) + + +def _show_touch_failure(lcd, error): + from hardware import show_hardware_message + + print("Timer stopped: {}".format(error)) + show_hardware_message( + lcd, + "Touch error", + ["Touch not detected", "Check board / I2C", "Restart timer"], + ) + + +def _show_imu_degraded(lcd, error): + from hardware import show_hardware_message + + print("Normal timing remains available: {}".format(error)) + show_hardware_message( + lcd, + "Launch disabled", + ["IMU not available", "Normal timer works", "Check board / I2C"], + background=lcd.brown, + ) + + +def _show_g_mode_unavailable(lcd, error): + from hardware import show_hardware_message + + print("G Mode unavailable: {}".format(error)) + show_hardware_message( + lcd, + "G Mode unavailable", + ["IMU not available", "Returning to Timer", "Check board / I2C"], + background=lcd.brown, + ) + + +def _initialize_imu(sensitivity): + from hardware import initialize_optional_imu + from qmi8658 import QMI8658 + + return initialize_optional_imu(sensitivity, QMI8658) + + +def _open_mode_menu(touch, lcd, user_params): + from operating_modes import configure_operating_mode + + result = configure_operating_mode( + touch, + lcd, + user_params, + USER_FILE, + ) + del configure_operating_mode + _unload_modules(("operating_modes",)) + return result + + +def _persist_timer_fallback(user_params): + from settings import persist_setting + + return persist_setting( + USER_FILE, + user_params, + "OPERATING_MODE", + MODE_TIMER, + )[0] + + +def run_application(lcd): + """Initialize shared hardware and dispatch one active feature at a time.""" + from hardware import PeripheralError, initialize_with_retry + from hardware_splash import run_startup_screens + from settings import load_configuration + from touch_drive import Touch_CST816T + + system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) + print("User Parameters: " + str(user_params)) + _apply_brightness(lcd, user_params["BRIGHTNESS_PERCENT"]) + + try: + touch = initialize_with_retry( + lambda: Touch_CST816T(mode=1, LCD=lcd), + "CST816T", + ) + run_startup_screens( + touch, + lcd, + firmware_version=system_params["VERSION"], + startup_duration_sec=system_params["STARTUP_SPLASH_DURATION_SEC"], + hardware_duration_sec=system_params["HARDWARE_SPLASH_DURATION_SEC"], + ) + except PeripheralError as error: + _show_touch_failure(lcd, error) + return False + + del run_startup_screens + _unload_modules(("hardware_splash", "splash")) + + active_mode = user_params["OPERATING_MODE"] + imu_requirement = user_params["SENSITIVITY"] + if active_mode == MODE_G and imu_requirement <= 0: + imu_requirement = 1 + qmi8658, imu_error = _initialize_imu(imu_requirement) + if imu_error is not None: + if active_mode == MODE_G: + _show_g_mode_unavailable(lcd, imu_error) + user_params = _persist_timer_fallback(user_params) + active_mode = MODE_TIMER + else: + _show_imu_degraded(lcd, imu_error) + time.sleep(2) + + while True: + if active_mode == MODE_TIMER: + from timer_mode import run_timer_mode + + try: + user_params, qmi8658 = run_timer_mode( + lcd, + touch, + user_params, + system_params, + qmi8658, + _initialize_imu, + _show_imu_degraded, + ) + except PeripheralError as error: + _show_touch_failure(lcd, error) + return False + del run_timer_mode + _unload_modules(TIMER_MODULES) + user_params, active_mode = _open_mode_menu( + touch, + lcd, + user_params, + ) + continue + + if qmi8658 is None: + qmi8658, imu_error = _initialize_imu(1) + if imu_error is not None: + _show_g_mode_unavailable(lcd, imu_error) + time.sleep(2) + user_params = _persist_timer_fallback(user_params) + active_mode = MODE_TIMER + continue + + from g_meter import run_g_mode + + try: + run_g_mode( + qmi8658, + touch, + lcd, + hold_seconds=system_params["MODE_MENU_HOLD_SEC"], + ) + except PeripheralError as error: + del run_g_mode + _unload_modules(("g_meter", "hold_detector")) + if error.peripheral == "CST816T": + _show_touch_failure(lcd, error) + return False + qmi8658 = None + _show_g_mode_unavailable(lcd, error) + time.sleep(2) + user_params = _persist_timer_fallback(user_params) + active_mode = MODE_TIMER + continue + + del run_g_mode + _unload_modules(("g_meter", "hold_detector")) + user_params, active_mode = _open_mode_menu( + touch, + lcd, + user_params, + ) diff --git a/g_meter.py b/g_meter.py new file mode 100644 index 0000000..6d5e196 --- /dev/null +++ b/g_meter.py @@ -0,0 +1,284 @@ +"""Graphical, baseline-corrected live G meter for the round display.""" + +import math +import time + +from hold_detector import HoldDetector + + +DISPLAY_CENTER_X = 120 +DISPLAY_CENTER_Y = 110 +PLOT_RADIUS = 72 +PEAK_ARC_RADIUS = 82 +PEAK_ARC_DOT_RADIUS = 2 +VISUAL_SCALE_G = 4.0 +FILTER_ALPHA = 0.60 +TRAIL_LENGTH = 6 +CALIBRATION_SAMPLES = 20 +CALIBRATION_INTERVAL_MS = 20 +FRAME_PERIOD_MS = 60 + + +def _sleep_ms(clock, milliseconds): + sleep_ms = getattr(clock, "sleep_ms", None) + if sleep_ms is not None: + sleep_ms(milliseconds) + else: + clock.sleep(milliseconds / 1000) + + +def _ticks_ms(clock): + ticks_ms = getattr(clock, "ticks_ms", None) + if ticks_ms is not None: + return ticks_ms() + monotonic = getattr(clock, "monotonic", None) + if monotonic is not None: + return int(monotonic() * 1000) + return int(clock.time() * 1000) + + +def _ticks_diff(clock, current, previous): + ticks_diff = getattr(clock, "ticks_diff", None) + if ticks_diff is not None: + return ticks_diff(current, previous) + return current - previous + + +def remaining_frame_delay_ms(clock, frame_started, period_ms=FRAME_PERIOD_MS): + """Return only the delay needed to complete the target frame period.""" + elapsed = _ticks_diff(clock, _ticks_ms(clock), frame_started) + return max(0, int(period_ms) - elapsed) + + +def _axes(sample): + if len(sample) < 3: + raise ValueError("Accelerometer sample must contain x, y, and z axes") + return float(sample[0]), float(sample[1]), float(sample[2]) + + +def calibrate_baseline( + sensor, + samples=CALIBRATION_SAMPLES, + interval_ms=CALIBRATION_INTERVAL_MS, + clock=time, +): + """Average stationary samples so gravity/mounting bias can be removed.""" + if samples < 1: + raise ValueError("Calibration samples must be positive") + baseline = [0.0, 0.0, 0.0] + for _ in range(samples): + axes = _axes(sensor.Read_XYZ()) + baseline[0] += axes[0] + baseline[1] += axes[1] + baseline[2] += axes[2] + _sleep_ms(clock, interval_ms) + return tuple(value / samples for value in baseline) + + +def vector_point( + vector, + scale_g=VISUAL_SCALE_G, + center_x=DISPLAY_CENTER_X, + center_y=DISPLAY_CENTER_Y, + radius=PLOT_RADIUS, +): + """Map a G vector to a clamped display point inside the meter.""" + scale = float(scale_g) + if scale <= 0: + raise ValueError("Visual scale must be greater than zero") + x_value = float(vector[0]) + y_value = float(vector[1]) + magnitude = math.sqrt((x_value * x_value) + (y_value * y_value)) + if magnitude > scale: + factor = scale / magnitude + x_value *= factor + y_value *= factor + return ( + int(round(center_x + ((x_value / scale) * radius))), + int(round(center_y - ((y_value / scale) * radius))), + ) + + +class GMeterState: + """Bounded filtered live/peak state independent of display hardware.""" + + def __init__( + self, + baseline=(0.0, 0.0, 0.0), + filter_alpha=FILTER_ALPHA, + visual_scale_g=VISUAL_SCALE_G, + trail_length=TRAIL_LENGTH, + ): + alpha = float(filter_alpha) + if alpha <= 0 or alpha > 1: + raise ValueError("Filter alpha must be greater than 0 and at most 1") + if float(visual_scale_g) <= 0: + raise ValueError("Visual scale must be greater than zero") + if trail_length < 1: + raise ValueError("Trail length must be positive") + self.baseline = tuple(float(value) for value in baseline[:3]) + if len(self.baseline) != 3: + raise ValueError("Baseline must contain x, y, and z axes") + self.filter_alpha = alpha + self.visual_scale_g = float(visual_scale_g) + self.trail_length = int(trail_length) + self.current = (0.0, 0.0) + self.peak = (0.0, 0.0) + self.peak_magnitude = 0.0 + self.trail = [] + + def update(self, sample): + x_axis, y_axis, _z_axis = _axes(sample) + target_x = x_axis - self.baseline[0] + target_y = y_axis - self.baseline[1] + filtered_x = self.current[0] + self.filter_alpha * ( + target_x - self.current[0] + ) + filtered_y = self.current[1] + self.filter_alpha * ( + target_y - self.current[1] + ) + self.current = (filtered_x, filtered_y) + magnitude = math.sqrt( + (filtered_x * filtered_x) + (filtered_y * filtered_y) + ) + if magnitude > self.peak_magnitude: + self.peak_magnitude = magnitude + self.peak = self.current + self.trail.append(self.current) + if len(self.trail) > self.trail_length: + del self.trail[0] + return self.current + + def reset_peak(self): + self.peak = (0.0, 0.0) + self.peak_magnitude = 0.0 + self.trail = [] + + +def _draw_peak_arc(lcd, peak_magnitude, scale_g): + segment_count = 60 + proportion = min(1.0, max(0.0, peak_magnitude / scale_g)) + active_segments = int(round(segment_count * proportion)) + for index in range(active_segments): + angle = (-math.pi / 2) + (2 * math.pi * index / segment_count) + x_position = DISPLAY_CENTER_X + int( + round(math.cos(angle) * PEAK_ARC_RADIUS) + ) + y_position = DISPLAY_CENTER_Y + int( + round(math.sin(angle) * PEAK_ARC_RADIUS) + ) + lcd.ellipse( + x_position, + y_position, + PEAK_ARC_DOT_RADIUS, + PEAK_ARC_DOT_RADIUS, + lcd.red, + True, + ) + + +def _hold_label(seconds): + value = float(seconds) + if value == int(value): + return str(int(value)) + return str(value) + + +def draw_g_meter_frame(lcd, state, mode_hold_seconds=5): + """Draw live vector, trail, peak marker, and peak-magnitude arc.""" + lcd.fill(lcd.black) + lcd.write_centered("G MODE", 5, 2, lcd.white) + + for radius in (24, 48, PLOT_RADIUS): + lcd.ellipse( + DISPLAY_CENTER_X, + DISPLAY_CENTER_Y, + radius, + radius, + lcd.blue, + ) + lcd.hline( + DISPLAY_CENTER_X - PLOT_RADIUS, + DISPLAY_CENTER_Y, + (PLOT_RADIUS * 2) + 1, + lcd.blue, + ) + lcd.vline( + DISPLAY_CENTER_X, + DISPLAY_CENTER_Y - PLOT_RADIUS, + (PLOT_RADIUS * 2) + 1, + lcd.blue, + ) + + for index, vector in enumerate(state.trail): + x_position, y_position = vector_point( + vector, + scale_g=state.visual_scale_g, + ) + marker_radius = 1 if index < len(state.trail) - 2 else 2 + lcd.ellipse( + x_position, + y_position, + marker_radius, + marker_radius, + lcd.palegreen, + True, + ) + + if state.peak_magnitude > 0: + peak_x, peak_y = vector_point( + state.peak, + scale_g=state.visual_scale_g, + ) + lcd.ellipse(peak_x, peak_y, 7, 7, lcd.red) + lcd.line(peak_x - 4, peak_y, peak_x + 4, peak_y, lcd.red) + lcd.line(peak_x, peak_y - 4, peak_x, peak_y + 4, lcd.red) + + current_x, current_y = vector_point( + state.current, + scale_g=state.visual_scale_g, + ) + lcd.ellipse(current_x, current_y, 5, 5, lcd.palegreen, True) + _draw_peak_arc(lcd, state.peak_magnitude, state.visual_scale_g) + + lcd.write_text("LIVE", 43, 178, 1, lcd.palegreen) + lcd.write_text("MAX", 169, 178, 1, lcd.red) + lcd.write_centered("Tap x2: reset", 201, 1, lcd.white) + lcd.write_centered( + "Hold {}s: menu".format(_hold_label(mode_hold_seconds)), + 220, + 1, + lcd.white, + ) + lcd.show() + + +def run_g_mode(sensor, touch, lcd, hold_seconds, clock=time): + """Run the live graphical meter until the configured hold opens the menu.""" + touch.Set_Mode(0) + touch.ControlScreen( + lcd, + text_array=[ + ["G MODE", None, 45, 3, "white"], + ["Calibrating", None, 110, 2, "white"], + ["Keep device still", None, 155, 1, "white"], + ], + back_colour="black", + ) + baseline = calibrate_baseline(sensor, clock=clock) + state = GMeterState(baseline=baseline) + hold_detector = HoldDetector(hold_seconds, clock=clock) + + while True: + frame_started = _ticks_ms(clock) + state.update(sensor.Read_XYZ()) + if touch.StopGesture(lcd): + state.reset_peak() + draw_g_meter_frame(lcd, state, mode_hold_seconds=hold_seconds) + + if hold_detector.update(touch.IsPressed()): + touch.ClearPendingInput() + return state + delay_ms = remaining_frame_delay_ms(clock, frame_started) + if delay_ms > 0: + _sleep_ms(clock, delay_ms) diff --git a/hold_detector.py b/hold_detector.py new file mode 100644 index 0000000..5db4fb5 --- /dev/null +++ b/hold_detector.py @@ -0,0 +1,53 @@ +"""Small non-blocking continuous-touch hold detector.""" + +import time + + +def _ticks_ms(clock): + ticks_ms = getattr(clock, "ticks_ms", None) + if ticks_ms is not None: + return ticks_ms() + monotonic = getattr(clock, "monotonic", None) + if monotonic is not None: + return int(monotonic() * 1000) + return int(clock.time() * 1000) + + +def _ticks_diff(clock, current, previous): + ticks_diff = getattr(clock, "ticks_diff", None) + if ticks_diff is not None: + return ticks_diff(current, previous) + return current - previous + + +class HoldDetector: + """Return True once when a continuous physical touch reaches a threshold.""" + + def __init__(self, duration_sec=5, clock=time): + duration = float(duration_sec) + if duration <= 0: + raise ValueError("Hold duration must be greater than zero") + self.duration_ms = int(duration * 1000) + self.clock = clock + self.started_at = None + self.triggered = False + + def reset(self): + self.started_at = None + self.triggered = False + + def update(self, pressed): + if not pressed: + self.reset() + return False + + now = _ticks_ms(self.clock) + if self.started_at is None: + self.started_at = now + return False + if self.triggered: + return False + if _ticks_diff(self.clock, now, self.started_at) >= self.duration_ms: + self.triggered = True + return True + return False diff --git a/lcd_1inch28.py b/lcd_1inch28.py index b8501d6..8c7c762 100644 --- a/lcd_1inch28.py +++ b/lcd_1inch28.py @@ -1,5 +1,5 @@ #LCD_1inch28 -#Version 3.5 +#Version 4.0.0 from machine import Pin,I2C,SPI,PWM,Timer,ADC import framebuf import time diff --git a/main.py b/main.py index 9e030d7..38aa258 100644 --- a/main.py +++ b/main.py @@ -1,222 +1,16 @@ -# Mark Rodman -# Session Timer for track days and racing. -# V3.5 +"""Memory-safe MicroPython entry point for Track Session Timer.""" -import time - -from battery import BatteryMonitor -from configuration import set_sensitivity, set_session -from hardware import ( - PeripheralError, - initialize_optional_imu, - initialize_with_retry, - show_hardware_message, -) -from launch import accel_launch from lcd_1inch28 import LCD_1inch28 -from live_display import ( - draw_live_frame, - rest_live_frame, - run_live_display, - track_live_frame, -) -from qmi8658 import QMI8658 -from ready_screen import draw_ready_screen -from settings import load_configuration, persist_setting -from timing import SessionTracker -from touch_drive import Touch_CST816T - - -PARAMS_FILE = "params.json" -USER_FILE = "user.json" - -PIT_SESSION_MSG = ["Cool down!", "Rest in pits"] -PLINE1 = [PIT_SESSION_MSG[0], None, 88, 3, "white"] -PLINE2 = [PIT_SESSION_MSG[1], None, 145, 2, "red"] - - -def _show_touch_failure(lcd, error): - print("Timer stopped: {}".format(error)) - show_hardware_message( - lcd, - "Touch error", - ["Touch not detected", "Check board / I2C", "Restart timer"], - ) - - -def _show_imu_degraded(lcd, error): - print("Normal timing remains available: {}".format(error)) - show_hardware_message( - lcd, - "Launch disabled", - ["IMU not available", "Normal timer works", "Check board / I2C"], - background=lcd.brown, - ) - - -def _initialize_imu(sensitivity): - return initialize_optional_imu(sensitivity, QMI8658) def main(): - system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE) - duration_values = system_params["DURATION_VALUES"] - launch_sense_values = system_params["LAUNCH_SENSE_VALUES"] - version = system_params["VERSION"] - 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. + # The 115,200-byte framebuffer must be the first large allocation. Import + # the application runtime only after this succeeds to avoid RP2040 heap + # fragmentation as features are added. lcd = LCD_1inch28() - from hardware_splash import run_startup_screens - lcd.set_bl_pwm(65535) - battery_monitor = BatteryMonitor() - try: - touch = initialize_with_retry( - lambda: Touch_CST816T(mode=1, LCD=lcd), - "CST816T", - ) - 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 - - qmi8658, imu_error = _initialize_imu(user_params["SENSITIVITY"]) - if imu_error is not None: - _show_imu_degraded(lcd, imu_error) - time.sleep(2) - - while True: - configured_sensitivity = user_params["SENSITIVITY"] - sensitivity = configured_sensitivity if qmi8658 is not None else 0 - race_length = user_params["RACE_LENGTH"] - rest_length = user_params["REST_LENGTH"] - - launch = False - track_session = SessionTracker(duration_mins=race_length, stype="track") - rest_session = SessionTracker(duration_mins=rest_length, stype="rest", debug=True) - - while not launch: - draw_ready_screen( - touch=touch, - lcd=lcd, - track_minutes=track_session.duration_mins, - rest_minutes=rest_session.duration_mins, - sensitivity=configured_sensitivity, - imu_available=qmi8658 is not None, - battery_status=battery_monitor.read_status(), - ) - gesture = touch.GetGesture(lcd) - - if gesture == "left": - race_length = set_session( - LCD=lcd, - Touch=touch, - session=track_session, - session_values=duration_values, - session_name="Track", - back_colour="palegreen", - ) - user_params, _ = persist_setting(USER_FILE, user_params, "RACE_LENGTH", race_length) - elif gesture == "right": - rest_length = set_session( - LCD=lcd, - Touch=touch, - session=rest_session, - session_values=duration_values, - session_name="Rest", - back_colour="paleblue", - ) - user_params, _ = persist_setting(USER_FILE, user_params, "REST_LENGTH", rest_length) - elif gesture == "up": - configured_sensitivity = set_sensitivity( - LCD=lcd, - Touch=touch, - sensitivity_values=launch_sense_values, - sensitivity=configured_sensitivity, - operation="Config", - back_colour="palegreen", - ) - user_params, _ = persist_setting( - USER_FILE, - user_params, - "SENSITIVITY", - configured_sensitivity, - ) - if configured_sensitivity > 0 and qmi8658 is None: - qmi8658, imu_error = _initialize_imu(configured_sensitivity) - if imu_error is not None: - _show_imu_degraded(lcd, imu_error) - time.sleep(2) - sensitivity = ( - configured_sensitivity if qmi8658 is not None else 0 - ) - elif gesture == "down": - print("Timer go!") - launch = True - - time.sleep(0.5) - - if sensitivity > 0: - touch.GoScreen( - lcd, - text="lights!", - subtitle="Double tap to cancel", - ) - else: - touch.GoScreen(lcd) - - try: - launch_detected = accel_launch( - qmi8658, - sensitivity=sensitivity, - cancel_check=lambda: touch.StopGesture(lcd), - ) - except PeripheralError as error: - qmi8658 = None - _show_imu_degraded(lcd, error) - time.sleep(2) - continue - if not launch_detected: - print("Launch mode cancelled or timed out.") - continue - - track_session.start_session() - - run_live_display( - track_session, - frame_builder=lambda now: track_live_frame(track_session, now, lcd), - draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), - stop_check=lambda: touch.StopGesture(lcd), - ) - - touch.ControlScreen( - lcd, - text_array=[PLINE1, PLINE2], - back_colour=display_delay_rest_colour, - ) - time.sleep(display_delay_rest) + from application import run_application - rest_session.start_session(debug=True) - run_live_display( - rest_session, - frame_builder=lambda now: rest_live_frame(rest_session, now, lcd), - draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), - stop_check=lambda: touch.ClearGesture(lcd), - ) + return run_application(lcd) if __name__ == "__main__": diff --git a/operating_modes.py b/operating_modes.py new file mode 100644 index 0000000..a368d23 --- /dev/null +++ b/operating_modes.py @@ -0,0 +1,249 @@ +"""On-device operating-mode and settings menus.""" + +from settings import ( + BRIGHTNESS_VALUES, + DEFAULT_USER_PARAMS, + OPERATING_MODES, + persist_setting, + restore_user_defaults, +) + + +MODE_TIMER = "timer" +MODE_G = "g" +MENU_SETTINGS = "settings" + +MODE_CHOICES = ( + ("Timer Mode", MODE_TIMER), + ("G Mode", MODE_G), + ("Settings", MENU_SETTINGS), +) + +SETTINGS_CHOICES = ( + ("Brightness", "brightness"), + ("Restore defaults", "restore"), + ("Back", "back"), +) + + +def brightness_duty(percent): + """Convert a validated percentage to the display PWM duty range.""" + value = max(0, min(100, int(percent))) + return int(round(65535 * value / 100)) + + +def apply_brightness(lcd, percent): + lcd.set_bl_pwm(brightness_duty(percent)) + + +def _choice_index(choices, value): + for index, choice in enumerate(choices): + if choice[1] == value: + return index + return 0 + + +def _selection_lines(title, label, position, label_size=3): + return [ + [title, None, 30, 2, "white"], + [label, None, 92, label_size, "white"], + [position, None, 145, 1, "white"], + ["L/R: choose", None, 180, 1, "white"], + ["UP: select", None, 202, 1, "white"], + ["DOWN: cancel", None, 220, 1, "white"], + ] + + +def mode_menu_lines(index): + label = MODE_CHOICES[index][0] + position = "{} / {}".format(index + 1, len(MODE_CHOICES)) + return _selection_lines("Operating Mode", label, position) + + +def settings_menu_lines(index): + label = SETTINGS_CHOICES[index][0] + position = "{} / {}".format(index + 1, len(SETTINGS_CHOICES)) + return _selection_lines("Settings", label, position, label_size=2) + + +def _select_choice(touch, lcd, choices, lines_builder, current=None): + index = _choice_index(choices, current) + + def draw(): + touch.ControlScreen( + lcd, + text_array=lines_builder(index), + back_colour="black", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture == "left": + index = (index - 1) % len(choices) + draw() + elif gesture == "right": + index = (index + 1) % len(choices) + draw() + elif gesture == "up": + return choices[index][1] + elif gesture == "down": + return None + + +def select_operating_mode(touch, lcd, current_mode): + return _select_choice( + touch, + lcd, + MODE_CHOICES, + mode_menu_lines, + current=current_mode, + ) + + +def select_settings_action(touch, lcd): + return _select_choice( + touch, + lcd, + SETTINGS_CHOICES, + settings_menu_lines, + ) + + +def brightness_lines(percent): + return [ + ["Brightness", None, 35, 2, "white"], + ["{}%".format(percent), None, 88, 5, "white"], + ["L/R: change", None, 180, 1, "white"], + ["UP: save", None, 202, 1, "white"], + ["DOWN: cancel", None, 220, 1, "white"], + ] + + +def select_brightness(touch, lcd, current): + """Preview brightness choices and return ``(value, should_save)``.""" + values = list(BRIGHTNESS_VALUES) + try: + index = values.index(current) + except ValueError: + index = values.index(DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"]) + original = values[index] + + def draw(): + apply_brightness(lcd, values[index]) + touch.ControlScreen( + lcd, + text_array=brightness_lines(values[index]), + back_colour="black", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture == "left": + index = (index - 1) % len(values) + draw() + elif gesture == "right": + index = (index + 1) % len(values) + draw() + elif gesture == "up": + return values[index], True + elif gesture == "down": + apply_brightness(lcd, original) + return original, False + + +def restore_confirmation_lines(selected): + return [ + ["Restore defaults?", None, 35, 2, "white"], + ["Erases saved choices", None, 82, 1, "white"], + [selected, None, 125, 3, "white"], + ["L/R: choose", None, 180, 1, "white"], + ["UP: confirm", None, 202, 1, "white"], + ["DOWN: cancel", None, 220, 1, "white"], + ] + + +def confirm_restore_defaults(touch, lcd): + choices = ("Cancel", "RESTORE") + index = 0 + + def draw(): + touch.ControlScreen( + lcd, + text_array=restore_confirmation_lines(choices[index]), + back_colour="red", + ) + + draw() + while True: + gesture = touch.GetGesture(lcd) + if gesture in ("left", "right"): + index = 1 - index + draw() + elif gesture == "up": + return index == 1 + elif gesture == "down": + return False + + +def _run_settings(touch, lcd, user_params, user_file): + while True: + action = select_settings_action(touch, lcd) + if action is None or action == "back": + return user_params, False + + if action == "brightness": + previous = user_params["BRIGHTNESS_PERCENT"] + selected, should_save = select_brightness(touch, lcd, previous) + if not should_save: + continue + updated, saved = persist_setting( + user_file, + user_params, + "BRIGHTNESS_PERCENT", + selected, + ) + if saved: + user_params = updated + else: + apply_brightness(lcd, previous) + + elif action == "restore" and confirm_restore_defaults(touch, lcd): + defaults, saved = restore_user_defaults(user_file) + if saved: + apply_brightness(lcd, defaults["BRIGHTNESS_PERCENT"]) + return defaults, True + + +def configure_operating_mode(touch, lcd, user_params, user_file): + """Run mode/settings UI and return ``(params, selected_mode)``.""" + previous_mode = user_params["OPERATING_MODE"] + while True: + choice = select_operating_mode(touch, lcd, previous_mode) + if choice is None: + return user_params, previous_mode + + if choice == MENU_SETTINGS: + user_params, restored = _run_settings( + touch, + lcd, + user_params, + user_file, + ) + if restored: + return user_params, MODE_TIMER + previous_mode = user_params["OPERATING_MODE"] + continue + + if choice not in OPERATING_MODES: + continue + updated, saved = persist_setting( + user_file, + user_params, + "OPERATING_MODE", + choice, + ) + if not saved: + return user_params, previous_mode + return updated, choice diff --git a/params.json b/params.json index 9a59d43..59a4693 100644 --- a/params.json +++ b/params.json @@ -2,8 +2,9 @@ "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], "DISPLAY_DELAY_REST": 5, "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], - "VERSION": "3.5", + "VERSION": "4.0.0", "DISPLAY_DELAY_REST_COLOUR": "blue", "STARTUP_SPLASH_DURATION_SEC": 2, - "HARDWARE_SPLASH_DURATION_SEC": 2 + "HARDWARE_SPLASH_DURATION_SEC": 2, + "MODE_MENU_HOLD_SEC": 5 } diff --git a/ready_screen.py b/ready_screen.py index dee3550..5bbc8a5 100644 --- a/ready_screen.py +++ b/ready_screen.py @@ -34,14 +34,21 @@ def ready_screen_lines( rest_minutes, sensitivity, imu_available, + mode_hold_seconds=5, ): """Build the complete Ready screen text layout.""" + hold_value = float(mode_hold_seconds) + if hold_value == int(hold_value): + hold_label = str(int(hold_value)) + else: + hold_label = str(hold_value) return [ ["Ready", None, READY_TITLE_Y, 4, "white"], ["Track {}m".format(track_minutes), None, 100, 2, "black"], ["Rest {}m".format(rest_minutes), None, 130, 2, "black"], [launch_status(sensitivity, imu_available), None, 160, 2, "black"], - ["Swipe DOWN to start", None, 205, 1, "black"], + ["Swipe DOWN to start", None, 198, 1, "black"], + ["Hold {}s: modes".format(hold_label), None, 218, 1, "black"], ] @@ -113,6 +120,7 @@ def draw_ready_screen( sensitivity, imu_available, battery_status, + mode_hold_seconds=5, ): """Render the Ready text and battery graphic in one framebuffer update.""" touch.ControlScreen( @@ -122,6 +130,7 @@ def draw_ready_screen( rest_minutes, sensitivity, imu_available, + mode_hold_seconds, ), back_colour="green", refresh=False, diff --git a/settings.py b/settings.py index b756eaf..a2bd098 100644 --- a/settings.py +++ b/settings.py @@ -12,18 +12,24 @@ "DURATION_VALUES": [1, 5, 10, 15, 20, 25, 30, 40, 50, 60], "DISPLAY_DELAY_REST": 5, "LAUNCH_SENSE_VALUES": [0, 0.5, 1, 1.25, 1.5, 1.75, 2, 2.5, 3.5, 4], - "VERSION": "3.5", + "VERSION": "4.0.0", "DISPLAY_DELAY_REST_COLOUR": "blue", "STARTUP_SPLASH_DURATION_SEC": 2, "HARDWARE_SPLASH_DURATION_SEC": 2, + "MODE_MENU_HOLD_SEC": 5, } DEFAULT_USER_PARAMS = { "SENSITIVITY": 0, "RACE_LENGTH": 20, "REST_LENGTH": 20, + "OPERATING_MODE": "timer", + "BRIGHTNESS_PERCENT": 100, } +OPERATING_MODES = ("timer", "g") +BRIGHTNESS_VALUES = (25, 50, 75, 100) + LEGACY_USER_KEYS = { "TRACK_LENGTH": "RACE_LENGTH", "TRACK_SESSION_LENGTH": "RACE_LENGTH", @@ -89,6 +95,10 @@ def validate_system_params(data): source["HARDWARE_SPLASH_DURATION_SEC"] = DEFAULT_SYSTEM_PARAMS[ "HARDWARE_SPLASH_DURATION_SEC" ] + if "MODE_MENU_HOLD_SEC" not in source: + source["MODE_MENU_HOLD_SEC"] = DEFAULT_SYSTEM_PARAMS[ + "MODE_MENU_HOLD_SEC" + ] valid = ( _positive_int_list(source.get("DURATION_VALUES")) @@ -99,6 +109,8 @@ def validate_system_params(data): 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 _is_number(source.get("MODE_MENU_HOLD_SEC")) + and source.get("MODE_MENU_HOLD_SEC") > 0 and isinstance(source.get("VERSION"), str) and len(source.get("VERSION")) > 0 and source.get("DISPLAY_DELAY_REST_COLOUR") in DISPLAY_COLOURS @@ -140,6 +152,14 @@ def normalize_user_params(data, system_params=None): race_length = migrated.get("RACE_LENGTH", default_race) rest_length = migrated.get("REST_LENGTH", default_rest) sensitivity = migrated.get("SENSITIVITY", default_sensitivity) + operating_mode = migrated.get( + "OPERATING_MODE", + DEFAULT_USER_PARAMS["OPERATING_MODE"], + ) + brightness_percent = migrated.get( + "BRIGHTNESS_PERCENT", + DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"], + ) if ( not isinstance(race_length, int) @@ -155,11 +175,21 @@ def normalize_user_params(data, system_params=None): rest_length = default_rest if not _is_number(sensitivity) or sensitivity not in sensitivity_values: sensitivity = default_sensitivity + if operating_mode not in OPERATING_MODES: + operating_mode = DEFAULT_USER_PARAMS["OPERATING_MODE"] + if ( + not isinstance(brightness_percent, int) + or isinstance(brightness_percent, bool) + or brightness_percent not in BRIGHTNESS_VALUES + ): + brightness_percent = DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"] normalized = { "SENSITIVITY": sensitivity, "RACE_LENGTH": race_length, "REST_LENGTH": rest_length, + "OPERATING_MODE": operating_mode, + "BRIGHTNESS_PERCENT": brightness_percent, } return normalized, normalized != source @@ -267,6 +297,14 @@ def persist_setting(file, json_data, key, value, debug=True): return updated, True +def restore_user_defaults(file, debug=True): + """Atomically persist and return a fresh canonical default dictionary.""" + defaults = _copy_params(DEFAULT_USER_PARAMS) + if not file_out(file, defaults, debug=debug): + return None, False + return defaults, True + + def load_configuration(params_file, user_file, debug=True): """Load validated system and canonical user settings with safe defaults.""" raw_system = file_in(params_file, debug=False) diff --git a/tests/test_g_meter.py b/tests/test_g_meter.py new file mode 100644 index 0000000..9fe05cc --- /dev/null +++ b/tests/test_g_meter.py @@ -0,0 +1,226 @@ +import math +import unittest + +from g_meter import ( + DISPLAY_CENTER_X, + DISPLAY_CENTER_Y, + GMeterState, + PEAK_ARC_DOT_RADIUS, + PLOT_RADIUS, + calibrate_baseline, + draw_g_meter_frame, + remaining_frame_delay_ms, + vector_point, +) +from hardware import PeripheralIOError + + +class FakeClock: + def __init__(self): + self.sleeps = [] + self.now = 0 + + def sleep_ms(self, milliseconds): + self.sleeps.append(milliseconds) + + def ticks_ms(self): + return self.now + + def ticks_diff(self, current, previous): + return current - previous + + +class FakeSensor: + def __init__(self, samples=None, error=None): + self.samples = list(samples or []) + self.error = error + self.index = 0 + + def Read_XYZ(self): + if self.error is not None: + raise self.error + sample = self.samples[min(self.index, len(self.samples) - 1)] + self.index += 1 + return sample + + +class FakeLCD: + black = 0 + white = 1 + blue = 2 + palegreen = 3 + red = 4 + + def __init__(self): + self.calls = [] + + def fill(self, *args): + self.calls.append(("fill",) + args) + + def write_centered(self, *args): + self.calls.append(("write_centered",) + args) + + def write_text(self, *args): + self.calls.append(("write_text",) + args) + + def ellipse(self, *args): + self.calls.append(("ellipse",) + args) + + def hline(self, *args): + self.calls.append(("hline",) + args) + + def vline(self, *args): + self.calls.append(("vline",) + args) + + def line(self, *args): + self.calls.append(("line",) + args) + + def pixel(self, *args): + self.calls.append(("pixel",) + args) + + def show(self): + self.calls.append(("show",)) + + +class GMeterTests(unittest.TestCase): + def test_stationary_calibration_averages_all_axes(self): + clock = FakeClock() + sensor = FakeSensor( + [ + (1.0, 2.0, 3.0, 0, 0, 0), + (3.0, 4.0, 5.0, 0, 0, 0), + ] + ) + + baseline = calibrate_baseline( + sensor, + samples=2, + interval_ms=10, + clock=clock, + ) + + self.assertEqual((2.0, 3.0, 4.0), baseline) + self.assertEqual([10, 10], clock.sleeps) + + def test_filter_removes_baseline_and_preserves_peak(self): + state = GMeterState( + baseline=(1.0, 2.0, 3.0), + filter_alpha=1, + trail_length=2, + ) + + state.update((3.0, 1.0, 3.0)) + self.assertEqual((2.0, -1.0), state.current) + expected_peak = math.sqrt(5) + self.assertAlmostEqual(expected_peak, state.peak_magnitude) + + state.update((1.5, 2.0, 3.0)) + state.update((1.25, 2.0, 3.0)) + self.assertAlmostEqual(expected_peak, state.peak_magnitude) + self.assertEqual((2.0, -1.0), state.peak) + self.assertEqual(2, len(state.trail)) + + def test_default_filter_responds_immediately_without_excessive_lag(self): + state = GMeterState() + + state.update((1.0, 0.0, 0.0)) + + self.assertGreaterEqual(state.current[0], 0.5) + + def test_frame_delay_accounts_for_render_time(self): + clock = FakeClock() + frame_started = clock.ticks_ms() + + clock.now = 20 + self.assertEqual(40, remaining_frame_delay_ms(clock, frame_started, 60)) + clock.now = 65 + self.assertEqual(0, remaining_frame_delay_ms(clock, frame_started, 60)) + + def test_peak_and_trail_can_be_reset(self): + state = GMeterState(filter_alpha=1) + state.update((2.0, 0.0, 0.0)) + + state.reset_peak() + + self.assertEqual(0, state.peak_magnitude) + self.assertEqual((0.0, 0.0), state.peak) + self.assertEqual([], state.trail) + + def test_vectors_are_clamped_to_graphical_plot_radius(self): + edge = vector_point((4.0, 0.0)) + beyond = vector_point((40.0, 0.0)) + diagonal = vector_point((40.0, 40.0)) + + self.assertEqual((DISPLAY_CENTER_X + PLOT_RADIUS, DISPLAY_CENTER_Y), edge) + self.assertEqual(edge, beyond) + distance = math.sqrt( + ((diagonal[0] - DISPLAY_CENTER_X) ** 2) + + ((diagonal[1] - DISPLAY_CENTER_Y) ** 2) + ) + self.assertLessEqual(distance, PLOT_RADIUS + 1) + + def test_graphic_contains_live_trail_peak_marker_and_peak_arc(self): + state = GMeterState(filter_alpha=1) + state.update((1.0, 0.5, 0.0)) + state.update((2.0, -1.0, 0.0)) + lcd = FakeLCD() + + draw_g_meter_frame(lcd, state) + + ellipses = [call for call in lcd.calls if call[0] == "ellipse"] + peak_arc_dots = [ + call + for call in lcd.calls + if call[0] == "ellipse" + and call[3:5] == (PEAK_ARC_DOT_RADIUS, PEAK_ARC_DOT_RADIUS) + and call[-2:] == (lcd.red, True) + ] + labels = [ + call[1] + for call in lcd.calls + if call[0] in ("write_centered", "write_text") + ] + self.assertGreaterEqual(len(ellipses), 7) + self.assertTrue( + any(len(call) == 6 and call[-1] == lcd.red for call in ellipses) + ) + self.assertTrue( + any(len(call) == 7 and call[-1] is True for call in ellipses) + ) + self.assertTrue(peak_arc_dots) + self.assertIn("LIVE", labels) + self.assertIn("MAX", labels) + self.assertFalse(any("0.0g" in label for label in labels)) + self.assertEqual(("show",), lcd.calls[-1]) + + def test_every_graphical_primitive_stays_inside_framebuffer(self): + state = GMeterState(filter_alpha=1) + state.update((100.0, -100.0, 0.0)) + lcd = FakeLCD() + draw_g_meter_frame(lcd, state) + + for call in lcd.calls: + if call[0] == "pixel": + self.assertTrue(0 <= call[1] < 240) + self.assertTrue(0 <= call[2] < 240) + elif call[0] == "ellipse": + _name, x, y, x_radius, y_radius, *_rest = call + self.assertGreaterEqual(x - x_radius, 0) + self.assertLess(x + x_radius, 240) + self.assertGreaterEqual(y - y_radius, 0) + self.assertLess(y + y_radius, 240) + elif call[0] == "line": + _name, x1, y1, x2, y2, _colour = call + self.assertTrue(0 <= x1 < 240 and 0 <= x2 < 240) + self.assertTrue(0 <= y1 < 240 and 0 <= y2 < 240) + + def test_sensor_failure_propagates_for_safe_mode_fallback(self): + failure = PeripheralIOError("QMI8658", "sample read", "disconnected") + sensor = FakeSensor(error=failure) + + with self.assertRaises(PeripheralIOError): + calibrate_baseline(sensor, samples=1, clock=FakeClock()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_operating_modes.py b/tests/test_operating_modes.py new file mode 100644 index 0000000..4fe296a --- /dev/null +++ b/tests/test_operating_modes.py @@ -0,0 +1,219 @@ +import math +import os +import tempfile +import unittest + +from font_renderer import measure_text, pixel_height +from hold_detector import HoldDetector +from operating_modes import ( + apply_brightness, + brightness_duty, + brightness_lines, + confirm_restore_defaults, + configure_operating_mode, + mode_menu_lines, + restore_confirmation_lines, + select_brightness, + select_operating_mode, + settings_menu_lines, +) +from settings import DEFAULT_USER_PARAMS, file_in + + +class FakeClock: + def __init__(self): + self.now = 0 + + def ticks_ms(self): + return self.now + + def ticks_diff(self, current, previous): + return current - previous + + +class FakeLCD: + def __init__(self): + self.duties = [] + + def set_bl_pwm(self, duty): + self.duties.append(duty) + + +class FakeTouch: + def __init__(self, gestures): + self.gestures = iter(gestures) + self.screens = [] + + def ControlScreen(self, lcd, text_array=None, back_colour=None): + self.screens.append((text_array, back_colour)) + + def GetGesture(self, lcd): + return next(self.gestures) + + +class OperatingModeTests(unittest.TestCase): + def assert_round_fit(self, lines): + radius = 120 + for text, _x, y_position, size, _colour in lines: + text_center_y = y_position + (pixel_height(size) / 2) + distance = text_center_y - radius + visible_width = 2 * math.sqrt( + (radius * radius) - (distance * distance) + ) + self.assertLessEqual(measure_text(text, size), visible_width) + + def test_hold_triggers_once_at_five_continuous_seconds(self): + clock = FakeClock() + detector = HoldDetector(5, clock=clock) + + self.assertFalse(detector.update(True)) + clock.now = 4_999 + self.assertFalse(detector.update(True)) + clock.now = 5_000 + self.assertTrue(detector.update(True)) + clock.now = 7_000 + self.assertFalse(detector.update(True)) + + def test_release_before_threshold_resets_hold_progress(self): + clock = FakeClock() + detector = HoldDetector(5, clock=clock) + + self.assertFalse(detector.update(True)) + clock.now = 4_000 + self.assertFalse(detector.update(False)) + clock.now = 10_000 + self.assertFalse(detector.update(True)) + clock.now = 14_999 + self.assertFalse(detector.update(True)) + clock.now = 15_000 + self.assertTrue(detector.update(True)) + + def test_hold_duration_must_be_positive(self): + for invalid in (0, -1): + with self.subTest(invalid=invalid): + with self.assertRaises(ValueError): + HoldDetector(invalid) + + def test_brightness_maps_to_pwm_range(self): + self.assertEqual(0, brightness_duty(0)) + self.assertEqual(65535, brightness_duty(100)) + self.assertEqual(int(round(65535 * 0.25)), brightness_duty(25)) + + lcd = FakeLCD() + apply_brightness(lcd, 50) + self.assertEqual(brightness_duty(50), lcd.duties[-1]) + + def test_mode_navigation_selects_and_cancels(self): + selected = select_operating_mode( + FakeTouch(["right", "up"]), + object(), + "timer", + ) + cancelled = select_operating_mode( + FakeTouch(["left", "down"]), + object(), + "timer", + ) + + self.assertEqual("g", selected) + self.assertIsNone(cancelled) + + def test_all_menu_text_fits_round_display(self): + for index in range(3): + self.assert_round_fit(mode_menu_lines(index)) + self.assert_round_fit(settings_menu_lines(index)) + for brightness in (25, 50, 75, 100): + self.assert_round_fit(brightness_lines(brightness)) + for selected in ("Cancel", "RESTORE"): + self.assert_round_fit(restore_confirmation_lines(selected)) + + def test_brightness_cancel_restores_previous_preview(self): + lcd = FakeLCD() + selected, should_save = select_brightness( + FakeTouch(["left", "down"]), + lcd, + 100, + ) + + self.assertEqual(100, selected) + self.assertFalse(should_save) + self.assertIn(brightness_duty(75), lcd.duties) + self.assertEqual(brightness_duty(100), lcd.duties[-1]) + + def test_selected_mode_is_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + user_params = dict(DEFAULT_USER_PARAMS) + + updated, mode = configure_operating_mode( + FakeTouch(["right", "up"]), + FakeLCD(), + user_params, + path, + ) + + self.assertEqual("g", mode) + self.assertEqual("g", updated["OPERATING_MODE"]) + self.assertEqual("g", file_in(path, debug=False)["OPERATING_MODE"]) + + def test_brightness_setting_is_previewed_and_persisted(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + lcd = FakeLCD() + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "left", "up", # Settings + "up", # Brightness + "left", "up", # 75%, save + "down", # leave Settings + "down", # cancel mode menu + ] + ), + lcd, + dict(DEFAULT_USER_PARAMS), + path, + ) + + self.assertEqual("timer", mode) + self.assertEqual(75, updated["BRIGHTNESS_PERCENT"]) + self.assertEqual(75, file_in(path, debug=False)["BRIGHTNESS_PERCENT"]) + self.assertIn(brightness_duty(75), lcd.duties) + + def test_restore_defaults_requires_confirmation_and_returns_timer(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + changed = dict(DEFAULT_USER_PARAMS) + changed["OPERATING_MODE"] = "g" + changed["BRIGHTNESS_PERCENT"] = 25 + changed["RACE_LENGTH"] = 60 + + updated, mode = configure_operating_mode( + FakeTouch( + [ + "right", "up", # Settings from G Mode + "right", "up", # Restore defaults + "right", "up", # Confirm RESTORE + ] + ), + FakeLCD(), + changed, + path, + ) + + self.assertEqual("timer", mode) + self.assertEqual(DEFAULT_USER_PARAMS, updated) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + + def test_restore_defaults_can_be_cancelled_after_viewing_restore(self): + confirmed = confirm_restore_defaults( + FakeTouch(["right", "down"]), + object(), + ) + + self.assertFalse(confirmed) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_peripherals.py b/tests/test_peripherals.py index 98a83e6..c8a62b0 100644 --- a/tests/test_peripherals.py +++ b/tests/test_peripherals.py @@ -108,6 +108,23 @@ def test_touch_wrong_chip_id_is_incompatible_hardware(self): self.assertEqual(0xB5, raised.exception.expected) self.assertEqual(0x00, raised.exception.actual) + def test_touch_reports_current_physical_press_state(self): + released = self.make_touch(FakeBus({0xA7: 0xB5, 0xA9: 1, 0x02: 0})) + pressed = self.make_touch(FakeBus({0xA7: 0xB5, 0xA9: 1, 0x02: 1})) + + self.assertFalse(released.IsPressed()) + self.assertTrue(pressed.IsPressed()) + + def test_pending_touch_input_can_be_cleared(self): + touch = self.make_touch(FakeBus({0xA7: 0xB5, 0xA9: 1})) + touch.Gestures = 12 + touch.Flag = 1 + + touch.ClearPendingInput() + + self.assertEqual(0, touch.Gestures) + self.assertEqual(0, touch.Flag) + def test_absent_qmi_is_a_retryable_io_failure(self): with self.assertRaises(PeripheralIOError) as raised: qmi8658.QMI8658(bus=FakeBus(read_error=OSError("no device"))) diff --git a/tests/test_ready_screen.py b/tests/test_ready_screen.py index 370d5c4..b98989c 100644 --- a/tests/test_ready_screen.py +++ b/tests/test_ready_screen.py @@ -62,10 +62,16 @@ def test_summary_contains_all_saved_settings_and_start_action(self): "Rest 15m", "Launch 0.5g", "Swipe DOWN to start", + "Hold 5s: modes", ], [line[0] for line in lines], ) + def test_hold_hint_reflects_configured_threshold(self): + lines = ready_screen_lines(20, 15, 0, True, mode_hold_seconds=3.5) + + self.assertEqual("Hold 3.5s: modes", lines[-1][0]) + def test_launch_status_reflects_effective_mode(self): self.assertEqual("Launch OFF", launch_status(0, False)) self.assertEqual("Launch 1g", launch_status(1, True)) diff --git a/tests/test_settings.py b/tests/test_settings.py index 209d82a..c58267a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -13,6 +13,7 @@ load_configuration, normalize_user_params, persist_setting, + restore_user_defaults, update_json, validate_system_params, ) @@ -105,6 +106,12 @@ def test_invalid_system_params_use_known_defaults(self): self.assertFalse(valid) self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + invalid_hold = dict(DEFAULT_SYSTEM_PARAMS) + invalid_hold["MODE_MENU_HOLD_SEC"] = 0 + params, valid = validate_system_params(invalid_hold) + self.assertFalse(valid) + self.assertEqual(DEFAULT_SYSTEM_PARAMS, params) + for duration_key in ( "STARTUP_SPLASH_DURATION_SEC", "HARDWARE_SPLASH_DURATION_SEC", @@ -120,6 +127,15 @@ 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_existing_system_file_gains_default_mode_hold_duration(self): + legacy = dict(DEFAULT_SYSTEM_PARAMS) + del legacy["MODE_MENU_HOLD_SEC"] + + params, valid = validate_system_params(legacy) + + self.assertTrue(valid) + self.assertEqual(5, params["MODE_MENU_HOLD_SEC"]) + def test_legacy_boot_delay_is_migrated(self): legacy = dict(DEFAULT_SYSTEM_PARAMS) del legacy["STARTUP_SPLASH_DURATION_SEC"] @@ -159,10 +175,54 @@ def test_legacy_user_keys_are_migrated_and_removed(self): ) self.assertTrue(changed) self.assertEqual( - {"SENSITIVITY": 0.5, "RACE_LENGTH": 10, "REST_LENGTH": 15}, + { + "SENSITIVITY": 0.5, + "RACE_LENGTH": 10, + "REST_LENGTH": 15, + "OPERATING_MODE": "timer", + "BRIGHTNESS_PERCENT": 100, + }, normalized, ) + def test_existing_user_file_gains_mode_and_brightness_defaults(self): + normalized, changed = normalize_user_params( + {"SENSITIVITY": 0, "RACE_LENGTH": 10, "REST_LENGTH": 15}, + DEFAULT_SYSTEM_PARAMS, + ) + + self.assertTrue(changed) + self.assertEqual("timer", normalized["OPERATING_MODE"]) + self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) + + def test_invalid_mode_and_brightness_use_defaults(self): + invalid = dict(DEFAULT_USER_PARAMS) + invalid["OPERATING_MODE"] = "unsupported" + invalid["BRIGHTNESS_PERCENT"] = 42 + + normalized, changed = normalize_user_params( + invalid, + DEFAULT_SYSTEM_PARAMS, + ) + + self.assertTrue(changed) + self.assertEqual("timer", normalized["OPERATING_MODE"]) + self.assertEqual(100, normalized["BRIGHTNESS_PERCENT"]) + + def test_restore_user_defaults_replaces_complete_file(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "user.json") + changed = dict(DEFAULT_USER_PARAMS) + changed["OPERATING_MODE"] = "g" + changed["BRIGHTNESS_PERCENT"] = 25 + self.assertTrue(file_out(path, changed, debug=False)) + + defaults, saved = restore_user_defaults(path, debug=False) + + self.assertTrue(saved) + self.assertEqual(DEFAULT_USER_PARAMS, defaults) + self.assertEqual(DEFAULT_USER_PARAMS, file_in(path, debug=False)) + def test_documented_configuration_loads_and_round_trips(self): with tempfile.TemporaryDirectory() as directory: params_path = os.path.join(directory, "params.json") diff --git a/timer_mode.py b/timer_mode.py new file mode 100644 index 0000000..5957319 --- /dev/null +++ b/timer_mode.py @@ -0,0 +1,190 @@ +"""Existing track/rest timer workflow, loaded only while Timer Mode is active.""" + +import time + +from battery import BatteryMonitor +from configuration import set_sensitivity, set_session +from hardware import PeripheralError +from hold_detector import HoldDetector +from launch import accel_launch +from live_display import ( + draw_live_frame, + rest_live_frame, + run_live_display, + track_live_frame, +) +from ready_screen import draw_ready_screen +from settings import persist_setting +from timing import SessionTracker + + +USER_FILE = "user.json" +PIT_SESSION_MSG = ["Cool down!", "Rest in pits"] +PLINE1 = [PIT_SESSION_MSG[0], None, 88, 3, "white"] +PLINE2 = [PIT_SESSION_MSG[1], None, 145, 2, "red"] + + +def run_timer_mode( + lcd, + touch, + user_params, + system_params, + qmi8658, + initialize_imu, + show_imu_degraded, +): + """Run complete timer sessions until a safe Ready-screen hold requests menu.""" + duration_values = system_params["DURATION_VALUES"] + launch_sense_values = system_params["LAUNCH_SENSE_VALUES"] + mode_menu_hold_sec = system_params["MODE_MENU_HOLD_SEC"] + display_delay_rest = system_params["DISPLAY_DELAY_REST"] + display_delay_rest_colour = system_params["DISPLAY_DELAY_REST_COLOUR"] + battery_monitor = BatteryMonitor() + + while True: + configured_sensitivity = user_params["SENSITIVITY"] + sensitivity = configured_sensitivity if qmi8658 is not None else 0 + race_length = user_params["RACE_LENGTH"] + rest_length = user_params["REST_LENGTH"] + + launch = False + ready_screen_dirty = True + hold_detector = HoldDetector(mode_menu_hold_sec) + track_session = SessionTracker(duration_mins=race_length, stype="track") + rest_session = SessionTracker( + duration_mins=rest_length, + stype="rest", + debug=True, + ) + + while not launch: + if ready_screen_dirty: + draw_ready_screen( + touch=touch, + lcd=lcd, + track_minutes=track_session.duration_mins, + rest_minutes=rest_session.duration_mins, + sensitivity=configured_sensitivity, + imu_available=qmi8658 is not None, + battery_status=battery_monitor.read_status(), + mode_hold_seconds=mode_menu_hold_sec, + ) + ready_screen_dirty = False + + if hold_detector.update(touch.IsPressed()): + touch.ClearPendingInput() + return user_params, qmi8658 + + gesture = touch.GetGesture(lcd, debounce_time=0.05) + + if gesture == "left": + race_length = set_session( + LCD=lcd, + Touch=touch, + session=track_session, + session_values=duration_values, + session_name="Track", + back_colour="palegreen", + ) + user_params, _ = persist_setting( + USER_FILE, + user_params, + "RACE_LENGTH", + race_length, + ) + ready_screen_dirty = True + hold_detector.reset() + elif gesture == "right": + rest_length = set_session( + LCD=lcd, + Touch=touch, + session=rest_session, + session_values=duration_values, + session_name="Rest", + back_colour="paleblue", + ) + user_params, _ = persist_setting( + USER_FILE, + user_params, + "REST_LENGTH", + rest_length, + ) + ready_screen_dirty = True + hold_detector.reset() + elif gesture == "up": + configured_sensitivity = set_sensitivity( + LCD=lcd, + Touch=touch, + sensitivity_values=launch_sense_values, + sensitivity=configured_sensitivity, + operation="Config", + back_colour="palegreen", + ) + user_params, _ = persist_setting( + USER_FILE, + user_params, + "SENSITIVITY", + configured_sensitivity, + ) + if configured_sensitivity > 0 and qmi8658 is None: + qmi8658, imu_error = initialize_imu(configured_sensitivity) + if imu_error is not None: + show_imu_degraded(lcd, imu_error) + time.sleep(2) + sensitivity = ( + configured_sensitivity if qmi8658 is not None else 0 + ) + ready_screen_dirty = True + hold_detector.reset() + elif gesture == "down": + print("Timer go!") + launch = True + + time.sleep(0.05) + + if sensitivity > 0: + touch.GoScreen( + lcd, + text="lights!", + subtitle="Double tap to cancel", + ) + else: + touch.GoScreen(lcd) + + try: + launch_detected = accel_launch( + qmi8658, + sensitivity=sensitivity, + cancel_check=lambda: touch.StopGesture(lcd), + ) + except PeripheralError as error: + qmi8658 = None + show_imu_degraded(lcd, error) + time.sleep(2) + continue + if not launch_detected: + print("Launch mode cancelled or timed out.") + continue + + track_session.start_session() + run_live_display( + track_session, + frame_builder=lambda now: track_live_frame(track_session, now, lcd), + draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), + stop_check=lambda: touch.StopGesture(lcd), + ) + + touch.ControlScreen( + lcd, + text_array=[PLINE1, PLINE2], + back_colour=display_delay_rest_colour, + ) + time.sleep(display_delay_rest) + + rest_session.start_session(debug=True) + run_live_display( + rest_session, + frame_builder=lambda now: rest_live_frame(rest_session, now, lcd), + draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), + stop_check=lambda: touch.ClearGesture(lcd), + ) diff --git a/touch_drive.py b/touch_drive.py index 7dcfe6c..894dfeb 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -1,5 +1,5 @@ # Touch drive -# v3.5 +# v4.0.0 from machine import I2C, Pin, Timer import time @@ -157,6 +157,15 @@ def get_point(self): self.X_point=x_point self.Y_point=y_point + + def IsPressed(self): + """Return whether the controller currently reports a physical touch.""" + return bool(self._read_byte(0x02) & 0x0F) + + def ClearPendingInput(self): + """Discard input that completed a hold before opening another screen.""" + self.Gestures = 0 + self.Flag = 0 def Int_Callback(self,pin): if self.Mode == 0 : diff --git a/user.json b/user.json index afbe4e4..3215743 100644 --- a/user.json +++ b/user.json @@ -1,5 +1,7 @@ { "SENSITIVITY": 0, "RACE_LENGTH": 10, - "REST_LENGTH": 10 + "REST_LENGTH": 10, + "OPERATING_MODE": "timer", + "BRIGHTNESS_PERCENT": 100 }