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
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ jobs:
with:
python-version: "3.11"
- name: Check Python syntax
run: python -m compileall -q .
run: python -m compileall -q firmware tests tools
- name: Run host-side tests
run: python -m unittest discover -s tests -v
run: python -m unittest discover -s tests -t . -v
52 changes: 33 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Trackday or race session timer.
* Kept Auto-Dim disabled by default and preserved normal brightness if motion sensing is disabled or unavailable.
* Moved the Ready-screen dim level into `params.json` as the validated `AUTO_DIM_PERCENT` system setting, defaulting to 25%.
* Added a hardware Bill of Materials identifying the cased Waveshare RP2040-Touch-LCD-1.28-B reference variant.
* Expanded hardware-independent regression coverage to 186 tests.
* 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.
* Expanded hardware-independent regression coverage to 189 tests.

## Version 4.2
### v4.2.0
Expand Down Expand Up @@ -63,7 +64,7 @@ Trackday or race session timer.
* Greater control over session settings, includes ability to define track session length and rest session length.
* Improved UI colours for 85% and 95% session expiry.

See user guide.
See the [User Guide](docs/USER_GUIDE.md).

### Fixes
* Incorrect pin out.
Expand All @@ -77,16 +78,31 @@ The timer is designed to support common session lengths, making it quick and eas

The timer utilizes the Waveshare 1.28-inch round touch display, allowing for configuration and operation through intuitive swipe gestures.

## Repository structure

The repository separates device code from documentation, generated-source inputs, host tooling, and tests:

```text
firmware/ MicroPython source, configuration, fonts and splash payload
tests/ Hardware-independent regression tests
tools/ Deployment and asset-generation utilities
assets/ Editable splash source and preview
docs/ User Guide and Hardware Bill of Materials
.github/ CI workflow and contribution templates
```

MicroPython still receives the contents of `firmware/` at its filesystem root. This preserves the existing `main.py` entry point and sibling imports; the repository directory itself is not copied to the device. See [firmware/README.md](firmware/README.md) for the payload contract.

## Display font

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, 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:
`firmware/font_data.py` and its flash-backed `firmware/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:

```sh
python tools/generate_font.py /path/to/Montserrat-SemiBold.otf font_data.py
python tools/generate_font.py /path/to/Montserrat-SemiBold.otf firmware/font_data.py
```

The generated font data is distributed under the SIL Open Font License 1.1 in `FONT_LICENSE.txt`.
Expand All @@ -106,7 +122,7 @@ The second screen uses high-contrast white text on black and identifies the hard
The original artwork and a device-layout preview are kept under `assets/`. To regenerate the runtime asset after changing the source image, install Pillow and run:

```sh
python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \
python tools/convert_splash.py assets/startup_splash.gif firmware/startup_splash.rgb565 \
--preview assets/startup_splash_preview.png
```

Expand All @@ -130,7 +146,7 @@ An accelerometer cannot determine rotation around gravity when the screen is nea

The physical reference build uses the integrated [Waveshare RP2040-Touch-LCD-1.28-B](https://www.waveshare.com/RP2040-Touch-LCD-1.28-B.htm), SKU 26371. The `-B` variant is supplied as one unit containing the RP2040 board, 1.28-inch round touch display, CNC metal case, and acrylic bottom plate; a separate Raspberry Pi Pico, display, or enclosure is not required. Its electronics combine the RP2040, GC9A01A 240x240 LCD, CST816S touchscreen, and QMI8658 IMU used by the firmware.

See the [Hardware Bill of Materials](HARDWARE_BOM.md) for the exact part, included subsystems, installation items, optional battery specification, and a comparison with similar but unsuitable variants. The standalone 1.28-inch Touch LCD connected to a separate Raspberry Pi Pico uses a different pin map and is not currently supported.
See the [Hardware Bill of Materials](docs/HARDWARE_BOM.md) for the exact part, included subsystems, installation items, optional battery specification, and a comparison with similar but unsuitable variants. 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

Expand Down Expand Up @@ -192,21 +208,19 @@ The second command should identify an RP2040 MicroPython board.

### 3. Upload the application

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.
From the repository root, run the deployment tool. It copies the flat contents of `firmware/` to the MicroPython filesystem root, installs `main.py` last, and resets the board:

```sh
mpremote connect auto fs cp application.py auto_dim.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 session_summary.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
python tools/deploy.py
```

On a fresh installation, the firmware creates `user.json` with safe defaults. To start with the example preferences in this repository, copy it before `main.py`:
The default deployment deliberately preserves the `user.json` already on the device. Select a specific connection when automatic discovery is unsuitable:

```sh
mpremote connect auto fs cp user.json :
python tools/deploy.py --port /dev/ttyACM0
```

When upgrading an existing device, omit that command so all of its saved user settings are preserved. The firmware adds safe fixed 0° rotation and disabled Auto-Dim defaults automatically when upgrading an older `user.json`.
On a fresh or intentionally reset device, add `--include-user` to install the example `firmware/user.json`. Otherwise a missing file is created with safe defaults during startup. Use `--dry-run` to inspect every ordered command without connecting. Existing files gain safe fixed 0° rotation and disabled Auto-Dim defaults automatically when upgraded.

### 4. Verify first boot

Expand All @@ -215,8 +229,8 @@ The display should show the Caterham v4.3.0 splash, the hardware-information spl
If first boot fails:

* `OSError: Font bitmap is missing or truncated` means one or more `font_data*.bin` files were not copied.
* The original text-only splash means `startup_splash.rgb565` is missing or has the wrong size; repeat the application upload command.
* An import error generally means a `.py` support module was omitted; repeat the upload command and keep `main.py` last.
* The original text-only splash means `startup_splash.rgb565` is missing or has the wrong size; rerun `python tools/deploy.py`.
* An import error generally means a `.py` support module was omitted; rerun `python tools/deploy.py`, which installs `main.py` last automatically.
* 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, Ready-screen Auto-Dim, 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. Auto-Dim safely leaves the display at its saved brightness.
Expand All @@ -231,10 +245,10 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity, G Mode, A

Version 4.3.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`, `HARDWARE_SPLASH_DURATION_SEC`, `MODE_MENU_HOLD_SEC`, and `AUTO_DIM_PERCENT` (an integer from 1 to 100, default 25).
* `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`), `BRIGHTNESS_PERCENT`, `DISPLAY_ROTATION_DEG` (`auto` or the fixed clockwise device mounting angle `0`, `90`, `180`, or `270`), and `AUTO_DIM_ENABLED` (`true` or `false`).
* `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`), and `AUTO_DIM_ENABLED` (`true` or `false`).

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.
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.

Expand All @@ -245,7 +259,7 @@ The firmware has built-in system and user defaults. Missing, malformed, or unsup
Run the hardware-independent regression suite with:

```sh
python -m unittest discover -s tests -v
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, 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.
2 changes: 1 addition & 1 deletion assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ https://static.wixstatic.com/media/467f72_bdf20d6c823c42a18bc41c04e17e7345~mv2.g
The runtime asset and preview are regenerated with:

```sh
python tools/convert_splash.py assets/startup_splash.gif startup_splash.rgb565 \
python tools/convert_splash.py assets/startup_splash.gif firmware/startup_splash.rgb565 \
--preview assets/startup_splash_preview.png
```
2 changes: 1 addition & 1 deletion HARDWARE_BOM.md → docs/HARDWARE_BOM.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ When checking a listing, require all three identifiers: **RP2040**, **Touch**, a

- [Waveshare RP2040-Touch-LCD-1.28-B product page](https://www.waveshare.com/RP2040-Touch-LCD-1.28-B.htm) — exact part number, SKU, case, onboard hardware and package contents.
- [Waveshare RP2040-Touch-LCD-1.28 wiki](https://www.waveshare.com/wiki/RP2040-Touch-LCD-1.28) — shared electronics specifications, display/touch controllers, IMU and battery connector requirements.
- [Track Session Timer supported hardware and pin map](README.md#supported-hardware) — firmware-specific compatibility and fixed internal connections.
- [Track Session Timer supported hardware and pin map](../README.md#supported-hardware) — firmware-specific compatibility and fixed internal connections.

Manufacturer details were last checked on 12 August 2026. Product listings can change; verify the part number and SKU before purchasing.
File renamed without changes.
18 changes: 18 additions & 0 deletions firmware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Device firmware

This directory is the complete MicroPython filesystem payload for Track Session Timer.

The source is intentionally flat **inside this directory** because the supported device loads `main.py` and its sibling modules from the MicroPython filesystem root. The repository deployment tool copies these files to that root without installing a `firmware` package or changing runtime imports.

- `main.py` is the device entry point and is uploaded last.
- `params.json` contains system-owned defaults and release metadata.
- `user.json` is an example for fresh installations; upgrades preserve the copy already stored on the device.
- `font_data*.bin` and `startup_splash.rgb565` are runtime assets, not source-code modules.

From the repository root, deploy while preserving existing user settings with:

```sh
python tools/deploy.py
```

Use `python tools/deploy.py --help` for explicit serial-port, fresh-install, and dry-run options.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
3 changes: 2 additions & 1 deletion font_data.py → firmware/font_data.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Generated TrackTimer Sans metadata.

Derived from Montserrat SemiBold, Copyright 2024 The Montserrat.Git Project
Authors. Licensed under the SIL Open Font License 1.1; see FONT_LICENSE.txt.
Authors. Licensed under the SIL Open Font License 1.1; see ../FONT_LICENSE.txt
in the source repository.
Regenerate with ``tools/generate_font.py`` rather than editing this file.
"""

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
8 changes: 8 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
"""Host-side test suite for hardware-independent timer behavior."""

import sys
from pathlib import Path


REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
FIRMWARE_ROOT = REPOSITORY_ROOT / "firmware"
sys.path.insert(0, str(FIRMWARE_ROOT))
9 changes: 8 additions & 1 deletion tests/test_font_renderer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import unittest
from pathlib import Path

Expand Down Expand Up @@ -44,6 +45,11 @@ def blit(self, *args):


class FontRendererTests(unittest.TestCase):
def setUp(self):
repository_working_directory = Path.cwd()
os.chdir(Path(font_data.__file__).parent)
self.addCleanup(os.chdir, repository_working_directory)

def test_font_metadata_covers_printable_ascii(self):
glyph_count = font_data.LAST_CODE_POINT - font_data.FIRST_CODE_POINT + 1
self.assertEqual(95, glyph_count)
Expand All @@ -63,7 +69,8 @@ def test_font_metadata_covers_printable_ascii(self):
last_offset = (offsets[-2] << 8) | offsets[-1]
last_stride = (widths[-1] + 7) // 8
self.assertEqual(bitmap_size, last_offset + (last_stride * height))
self.assertEqual(bitmap_size, Path(bitmap_file).stat().st_size)
bitmap_path = Path(font_data.__file__).parent / bitmap_file
self.assertEqual(bitmap_size, bitmap_path.stat().st_size)

def test_sizes_are_native_pixel_heights(self):
self.assertEqual(12, pixel_height(1))
Expand Down
6 changes: 4 additions & 2 deletions tests/test_release_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ def ControlScreen(self, _lcd, text_array, back_colour):

class ReleaseVersionTests(unittest.TestCase):
def test_build_defaults_docs_and_both_startup_screens_report_release(self):
with (REPOSITORY_ROOT / "params.json").open(encoding="utf-8") as source:
with (REPOSITORY_ROOT / "firmware" / "params.json").open(
encoding="utf-8"
) as source:
params = json.load(source)
readme = (REPOSITORY_ROOT / "README.md").read_text(encoding="utf-8")
user_guide = (REPOSITORY_ROOT / "User Guide.md").read_text(
user_guide = (REPOSITORY_ROOT / "docs" / "USER_GUIDE.md").read_text(
encoding="utf-8"
)

Expand Down
52 changes: 52 additions & 0 deletions tests/test_repository_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import unittest
from pathlib import Path

from tools.deploy import (
ENTRY_POINT,
FIRMWARE_ROOT,
REPOSITORY_ROOT,
USER_CONFIG,
deployment_commands,
support_files,
)


class RepositoryLayoutTests(unittest.TestCase):
def test_runtime_code_is_contained_in_firmware_directory(self):
self.assertTrue(ENTRY_POINT.is_file())
self.assertTrue((FIRMWARE_ROOT / "application.py").is_file())
self.assertEqual([], sorted(REPOSITORY_ROOT.glob("*.py")))

def test_deployment_payload_covers_runtime_assets_and_preserves_user(self):
payload = set(support_files())
expected = {
path
for path in FIRMWARE_ROOT.iterdir()
if path.is_file()
and path.suffix in {".py", ".json", ".bin", ".rgb565"}
and path not in {ENTRY_POINT, USER_CONFIG}
}

self.assertEqual(expected, payload)
self.assertNotIn(USER_CONFIG, payload)
self.assertIn(USER_CONFIG, support_files(include_user=True))

def test_deployment_copies_main_last_then_resets(self):
support, entry_point, reset = deployment_commands(
port="/dev/test",
executable="python",
)

self.assertEqual(
["python", "-m", "mpremote", "connect", "/dev/test"],
support[:5],
)
self.assertEqual(["fs", "cp"], support[5:7])
self.assertEqual(":", support[-1])
self.assertEqual(str(ENTRY_POINT), entry_point[-2])
self.assertEqual(":", entry_point[-1])
self.assertEqual("reset", reset[-1])


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion tests/test_splash.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def test_wrong_sized_asset_returns_false_without_changing_buffer(self):
self.assertEqual(bytes([9] * 32), surface.buffer)

def test_generated_device_asset_matches_framebuffer_size(self):
asset = Path(__file__).parents[1] / "startup_splash.rgb565"
asset = Path(__file__).parents[1] / "firmware" / "startup_splash.rgb565"

self.assertEqual(240 * 240 * 2, asset.stat().st_size)

Expand Down
Loading
Loading