Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,7 +68,7 @@ The timer utilizes the Waveshare 1.28-inch round touch display, allowing for con

The firmware includes a compact proportional bitmap font rendered directly at the display's native resolution. It replaces enlargement of MicroPython's 8x8 framebuffer font, so large countdown digits and labels retain smooth shapes instead of scaling into square pixels.

The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. Timer digits use equal-width cells, so changing figures do not move the centered countdown or elapsed-time positions.
The running track and rest countdown uses the 74-pixel native font, the closest available pre-rendered size to a 10% increase from the previous 64-pixel countdown. Timer digits use equal-width cells, so changing figures do not move the centered countdown, maximum-G, or elapsed-time positions.

`font_data.py` and its flash-backed `font_data*.bin` glyph assets are generated from Montserrat SemiBold. The assets contain pre-rasterized native UI sizes, allowing the Pico to use its fast framebuffer blitter without holding the complete font in RAM. To regenerate them, install Pillow and run:

Expand All@@ -80,7 +80,7 @@ The generated font data is distributed under the SIL Open Font License 1.1 in `F

## Live display refresh

Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, font size, background, and text colour) with the previous frame. Track colour is interpolated from whole elapsed seconds, making the blend proportional to the selected duration while retaining a maximum of one normal full-screen transfer per displayed second. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes.
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.

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.

Expand All@@ -101,7 +101,7 @@ python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \

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.
* **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. 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. In every case, touch gestures remain relative to the text on screen. Swipe up saves a preview; swipe down cancels and restores the previous brightness or orientation. **Restore defaults** requires confirmation, then restores Timer Mode, 100% brightness, fixed 0° rotation, 20-minute track/rest sessions, and disabled Launch Mode.

Expand DownExpand Up@@ -180,7 +180,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 application.py auto_rotation.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 orientation.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 application.py auto_rotation.py battery.py configuration.py font_data.py font_renderer.py g_force.py g_meter.py hardware.py hardware_splash.py hold_detector.py launch.py lcd_1inch28.py live_display.py operating_modes.py orientation.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
```
Expand All@@ -204,7 +204,7 @@ If first boot fails:
* An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last.
* No serial device after flashing usually indicates a charge-only USB cable, an incorrect UF2, or a board still in BOOT mode.
* A touchscreen hardware error is a controlled stop: check that this is the supported integrated board, then restart it. The serial message includes the failed operation or unexpected chip ID.
* An IMU hardware error disables Launch Mode for the current run. Swipe down to use the normal timer, which remains available without the IMU.
* An IMU hardware error disables Launch Mode and the session maximum-G reading for the current run. Swipe down to use the normal timer; `MAX --` confirms that timing remains available without the IMU.

### Peripheral failure policy

Expand Down
5 changes: 3 additions & 2 deletions User Guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,10 +5,11 @@ The following describes general operation of both the ``Track Session`` and ``Re

* 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``. 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.
* After swiping down, keep the device still for the brief ``SESSION G - Calibrating`` screen. This establishes the acceleration baseline for both the session peak and Launch Mode. ``Go`` will then display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer 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.
* 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.
* Timer text automatically uses whichever of black or white has the greater contrast against the current background colour.
* Once the ``Track Session`` has completed, i.e. >100%, the background becomes deep purple with white text and the timer remains running to provide visibility of any overrun. ``Double Tap`` to complete/exit. A ``Double Tap`` can be used to terminate any running timer.
* Following termination, a ``Rest in Pits`` splash will display, followed by commencement of the ``Rest in Pits Session`` timer.
Expand DownExpand Up@@ -66,7 +67,7 @@ It is possible to change the duration of both the ``Track Session`` and the ``Re
* Swipe ``UP`` to save and enable ``Launch mode``.
* Select `0` and swipe ``UP`` to save and disable ``Launch mode``.

Keep the timer stationary while ``Lights`` first appears. The firmware averages 20 samples over 0.4 seconds to remove gravity and the device's mounting orientation. It then measures the filtered change in the three-axis acceleration vector, so forward or reverse acceleration can trigger regardless of which way the display is mounted. A launch must remain above the threshold for three consecutive 20 ms samples; isolated vibration and bumps are ignored.
Keep the timer stationary while ``SESSION G - Calibrating`` appears. The firmware averages 20 samples over 0.4 seconds to remove gravity and the device's mounting orientation, then reuses that baseline for Launch Mode. It measures the filtered change in the three-axis acceleration vector, so forward or reverse acceleration can trigger regardless of which way the display is mounted. A launch must remain above the threshold for three consecutive 20 ms samples; isolated vibration and bumps are ignored.

Sensitivity values are acceleration changes in **g**, where approximately 1 g is Earth's gravitational acceleration. Lower non-zero values trigger more easily:

Expand Down
5 changes: 3 additions & 2 deletions application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@
"hold_detector",
"launch",
"live_display",
"g_force",
"ready_screen",
"timing",
)
Expand DownExpand Up@@ -265,7 +266,7 @@ def run_application(lcd):
)
except PeripheralError as error:
del run_g_mode
_unload_modules(("g_meter", "hold_detector"))
_unload_modules(("g_meter", "g_force", "hold_detector"))
if error.peripheral == "CST816T":
_show_touch_failure(lcd, error)
return False
Expand All@@ -278,7 +279,7 @@ def run_application(lcd):
continue

del run_g_mode
_unload_modules(("g_meter", "hold_detector"))
_unload_modules(("g_meter", "g_force", "hold_detector"))
user_params, active_mode, qmi8658 = _open_mode_menu(
touch,
lcd,
Expand Down
124 changes: 124 additions & 0 deletions g_force.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
"""Shared baseline-corrected G calculations for live operating modes."""

import math
import time


FILTER_ALPHA = 0.60
CALIBRATION_SAMPLES = 20
CALIBRATION_INTERVAL_MS = 20
MAX_G_UNAVAILABLE = "MAX --"


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 _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)


class PlanarGState:
"""Filtered planar acceleration and peak independent of presentation."""

def __init__(self, baseline=(0.0, 0.0, 0.0), filter_alpha=FILTER_ALPHA):
alpha = float(filter_alpha)
if alpha <= 0 or alpha > 1:
raise ValueError("Filter alpha must be greater than 0 and at most 1")
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.current = (0.0, 0.0)
self.peak = (0.0, 0.0)
self.peak_magnitude = 0.0

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
return self.current

def reset_peak(self):
self.peak = (0.0, 0.0)
self.peak_magnitude = 0.0


class SessionGPeak:
"""Bounded session sampler with a once-per-visible-second label."""

def __init__(self, sensor=None, baseline=(0.0, 0.0, 0.0)):
self.sensor = sensor
self.state = PlanarGState(baseline=baseline) if sensor is not None else None
self._display_second = None
self._display_label = MAX_G_UNAVAILABLE

@property
def available(self):
return self.state is not None

@property
def peak_magnitude(self):
return 0.0 if self.state is None else self.state.peak_magnitude

def sample(self):
"""Take one sample; the caller controls the bounded polling rate."""
if self.state is not None:
self.state.update(self.sensor.Read_XYZ())

def disable(self):
self.sensor = None
self.state = None
self._display_second = None
self._display_label = MAX_G_UNAVAILABLE

def display_label(self, elapsed_seconds):
"""Return a stable label so peak sampling does not add redraws."""
if self.state is None:
return MAX_G_UNAVAILABLE
visible_second = max(0, int(elapsed_seconds))
if visible_second != self._display_second:
# The supported IMU cannot reach 100 g. Capping protects the round
# display safe area if a corrupt sample reports an extreme value.
visible_peak = min(99.99, self.state.peak_magnitude)
self._display_label = "MAX {:.2f} g".format(visible_peak)
self._display_second = visible_second
return self._display_label
62 changes: 5 additions & 57 deletions g_meter.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
import math
import time

from g_force import FILTER_ALPHA, PlanarGState, calibrate_baseline
from hold_detector import HoldDetector


Expand All@@ -12,10 +13,7 @@
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


Expand DownExpand Up@@ -50,31 +48,6 @@ def remaining_frame_delay_ms(clock, frame_started, period_ms=FRAME_PERIOD_MS):
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,
Expand All@@ -99,7 +72,7 @@ def vector_point(
)


class GMeterState:
class GMeterState(PlanarGState):
"""Bounded filtered live/peak state independent of display hardware."""

def __init__(
Expand All@@ -109,49 +82,24 @@ def __init__(
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")
super().__init__(baseline=baseline, filter_alpha=filter_alpha)
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
super().update(sample)
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
super().reset_peak()
self.trail = []


Expand Down
Loading
Loading