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
12 changes: 6 additions & 6 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,7 +89,7 @@ Press and continuously hold the touchscreen for five seconds from the Timer Read

* **Timer Mode** retains the existing track, rest, and Launch Mode workflow.
* **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.
* **Settings** provides 25%, 50%, 75%, and 100% brightness choices with immediate preview. It also supports 0°, 90°, 180°, and 270° clockwise mounting angles: the LCD counter-rotates its output and 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, 0° rotation, 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.

Expand DownExpand Up@@ -160,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 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 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 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@@ -171,7 +171,7 @@ On a fresh installation, the firmware creates `user.json` with safe defaults. To
mpremote connect auto fs cp user.json :
```

When upgrading an existing device, omit that command so its saved track duration, rest duration, and launch sensitivity are preserved.
When upgrading an existing device, omit that command so all of its saved user settings are preserved. The firmware adds a safe 0° rotation automatically when upgrading an older `user.json`.

### 4. Verify first boot

Expand All@@ -197,11 +197,11 @@ The QMI8658 IMU is optional unless a non-zero Launch Mode sensitivity is selecte
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`, `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`.
* `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`, and `DISPLAY_ROTATION_DEG` (clockwise device mounting angle: `0`, `90`, `180`, or `270`).

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, while older files gain Timer Mode and 100% brightness defaults.
The firmware has built-in system and user defaults. Missing, malformed, or unsupported user values are replaced with safe defaults and saved using the canonical keys above. Existing `TRACK_LENGTH`, `TRACK_SESSION_LENGTH`, and `REST_SESSION_LENGTH` user keys are migrated automatically, while older files gain Timer Mode, 100% brightness, and 0° rotation defaults.

## Host-side tests

Expand All@@ -211,4 +211,4 @@ Run the hardware-independent regression suite with:
python -m unittest discover -s tests -v
```

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.
The suite uses fakes for time, continuous holds, touch gestures, all four display rotations, 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.
5 changes: 3 additions & 2 deletions User Guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ The following describes general operation of both the ``Track Session`` and ``Re
* Once the ``Rest in Pits Session`` is complete, the timer will return to the ``Primary screen``. The ``Rest in Pits Session`` can be terminated with a ``Double Tap``.

## Configuration / Setup
Track duration, rest duration, launch sensitivity, operating mode, 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.
Track duration, rest duration, launch sensitivity, operating mode, brightness, and display rotation 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

Expand DownExpand Up@@ -44,7 +44,8 @@ The graph removes the stationary gravity/mounting baseline and does not rely on
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.
* ``Rotation`` supports mounting the device at 0°, 90°, 180°, or 270°. The value is the physical device rotation clockwise from its original 0° position. Left/right previews the orientation immediately; the text and directional gestures rotate together, so swipes stay relative to the displayed instructions. ``Swipe UP`` saves, while ``Swipe DOWN`` safely restores the previous orientation.
* ``Restore defaults`` requires an explicit confirmation. Confirming restores Timer Mode, 100% brightness, 0° rotation, 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
Expand Down
7 changes: 6 additions & 1 deletion application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,11 +108,16 @@ def run_application(lcd):

system_params, user_params = load_configuration(PARAMS_FILE, USER_FILE)
print("User Parameters: " + str(user_params))
lcd.set_rotation(user_params["DISPLAY_ROTATION_DEG"])
_apply_brightness(lcd, user_params["BRIGHTNESS_PERCENT"])

try:
touch = initialize_with_retry(
lambda: Touch_CST816T(mode=1, LCD=lcd),
lambda: Touch_CST816T(
mode=1,
LCD=lcd,
rotation=user_params["DISPLAY_ROTATION_DEG"],
),
"CST816T",
)
run_startup_screens(
Expand Down
15 changes: 15 additions & 0 deletions lcd_1inch28.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,6 +25,7 @@ class LCD_1inch28(framebuf.FrameBuffer):
def __init__(self): #SPI initialization SPI
self.width = 240
self.height = 240
self.rotation = 0

self.cs = Pin(CS,Pin.OUT)
self.rst = Pin(RST,Pin.OUT)
Expand DownExpand Up@@ -85,6 +86,20 @@ def write_data(self, buf): #Write data

def set_bl_pwm(self,duty): #Set screen brightness
self.pwm.duty_u16(duty)#max 65535

def set_rotation(self, rotation):
"""Counter-rotate output for the selected clockwise mount angle."""
# Import after framebuffer construction to protect the RP2040's largest
# contiguous allocation during startup.
from orientation import madctl_for_rotation

value = madctl_for_rotation(rotation)
# MADCTL changes are ignored while tearing output is active on GC9A01.
self.write_cmd(0x34)
self.write_cmd(0x36)
self.write_data(value)
self.write_cmd(0x35)
self.rotation = rotation

def init_display(self): #LCD initialization LCD
"""Initialize display"""
Expand Down
73 changes: 73 additions & 0 deletions operating_modes.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
from settings import (
BRIGHTNESS_VALUES,
DEFAULT_USER_PARAMS,
DISPLAY_ROTATION_VALUES,
OPERATING_MODES,
persist_setting,
restore_user_defaults,
Expand All@@ -21,6 +22,7 @@

SETTINGS_CHOICES = (
("Brightness", "brightness"),
("Rotation", "rotation"),
("Restore defaults", "restore"),
("Back", "back"),
)
Expand DownExpand Up@@ -153,6 +155,56 @@ def draw():
return original, False


def apply_rotation(lcd, touch, degrees):
"""Apply one mount angle to rendering and directional gestures."""
lcd.set_rotation(degrees)
touch.Set_Rotation(degrees)


def rotation_lines(degrees):
return [
["Mount rotation", None, 35, 2, "white"],
["{} deg".format(degrees), None, 88, 4, "white"],
["Device clockwise", None, 150, 1, "white"],
["L/R: rotate", None, 180, 1, "white"],
["UP: save", None, 202, 1, "white"],
["DOWN: cancel", None, 220, 1, "white"],
]


def select_rotation(touch, lcd, current):
"""Preview mount rotations and return ``(value, should_save)``."""
values = list(DISPLAY_ROTATION_VALUES)
try:
index = values.index(current)
except ValueError:
index = values.index(DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"])
original = values[index]

def draw():
apply_rotation(lcd, touch, values[index])
touch.ControlScreen(
lcd,
text_array=rotation_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_rotation(lcd, touch, original)
return original, False


def restore_confirmation_lines(selected):
return [
["Restore defaults?", None, 35, 2, "white"],
Expand DownExpand Up@@ -209,10 +261,31 @@ def _run_settings(touch, lcd, user_params, user_file):
else:
apply_brightness(lcd, previous)

elif action == "rotation":
previous = user_params["DISPLAY_ROTATION_DEG"]
selected, should_save = select_rotation(touch, lcd, previous)
if not should_save:
continue
updated, saved = persist_setting(
user_file,
user_params,
"DISPLAY_ROTATION_DEG",
selected,
)
if saved:
user_params = updated
else:
apply_rotation(lcd, touch, 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"])
apply_rotation(
lcd,
touch,
defaults["DISPLAY_ROTATION_DEG"],
)
return defaults, True


Expand Down
38 changes: 38 additions & 0 deletions orientation.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
"""Shared display and touch orientation helpers."""


ROTATION_VALUES = (0, 90, 180, 270)

# The native Waveshare initialization uses MY, ML, and BGR (0x98). The
# remaining values counter-rotate the display for a device mounted clockwise
# by the corresponding number of degrees while preserving ML and BGR.
MADCTL_BY_MOUNT_ROTATION = {
0: 0x98,
90: 0xF8,
180: 0x58,
270: 0x38,
}

_DIRECTIONS_CLOCKWISE = ("up", "right", "down", "left")


def validate_rotation(rotation):
"""Return a supported clockwise mount rotation or raise ``ValueError``."""
if isinstance(rotation, bool) or rotation not in ROTATION_VALUES:
raise ValueError("Rotation must be 0, 90, 180, or 270 degrees")
return rotation


def madctl_for_rotation(rotation):
"""Return the GC9A01 MADCTL value for a clockwise mount rotation."""
return MADCTL_BY_MOUNT_ROTATION[validate_rotation(rotation)]


def map_gesture_direction(direction, rotation):
"""Map a board-relative gesture to the viewer at ``rotation`` degrees."""
validate_rotation(rotation)
if direction not in _DIRECTIONS_CLOCKWISE:
return direction
index = _DIRECTIONS_CLOCKWISE.index(direction)
steps = rotation // 90
return _DIRECTIONS_CLOCKWISE[(index + steps) % 4]
13 changes: 13 additions & 0 deletions settings.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,10 +25,12 @@
"REST_LENGTH": 20,
"OPERATING_MODE": "timer",
"BRIGHTNESS_PERCENT": 100,
"DISPLAY_ROTATION_DEG": 0,
}

OPERATING_MODES = ("timer", "g")
BRIGHTNESS_VALUES = (25, 50, 75, 100)
DISPLAY_ROTATION_VALUES = (0, 90, 180, 270)

LEGACY_USER_KEYS = {
"TRACK_LENGTH": "RACE_LENGTH",
Expand DownExpand Up@@ -160,6 +162,10 @@ def normalize_user_params(data, system_params=None):
"BRIGHTNESS_PERCENT",
DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"],
)
display_rotation = migrated.get(
"DISPLAY_ROTATION_DEG",
DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"],
)

if (
not isinstance(race_length, int)
Expand All@@ -183,13 +189,20 @@ def normalize_user_params(data, system_params=None):
or brightness_percent not in BRIGHTNESS_VALUES
):
brightness_percent = DEFAULT_USER_PARAMS["BRIGHTNESS_PERCENT"]
if (
not isinstance(display_rotation, int)
or isinstance(display_rotation, bool)
or display_rotation not in DISPLAY_ROTATION_VALUES
):
display_rotation = DEFAULT_USER_PARAMS["DISPLAY_ROTATION_DEG"]

normalized = {
"SENSITIVITY": sensitivity,
"RACE_LENGTH": race_length,
"REST_LENGTH": rest_length,
"OPERATING_MODE": operating_mode,
"BRIGHTNESS_PERCENT": brightness_percent,
"DISPLAY_ROTATION_DEG": display_rotation,
}
return normalized, normalized != source

Expand Down
86 changes: 86 additions & 0 deletions tests/test_display_rotation.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
import importlib
import sys
import types
import unittest

from orientation import madctl_for_rotation, map_gesture_direction


def import_lcd_driver():
machine = types.ModuleType("machine")
for name in ("Pin", "I2C", "SPI", "PWM", "Timer", "ADC"):
setattr(machine, name, object)
framebuf = types.ModuleType("framebuf")
framebuf.FrameBuffer = object
framebuf.RGB565 = 1

original_machine = sys.modules.get("machine")
original_framebuf = sys.modules.get("framebuf")
sys.modules["machine"] = machine
sys.modules["framebuf"] = framebuf
try:
return importlib.import_module("lcd_1inch28")
finally:
if original_machine is None:
del sys.modules["machine"]
else:
sys.modules["machine"] = original_machine
if original_framebuf is None:
del sys.modules["framebuf"]
else:
sys.modules["framebuf"] = original_framebuf


lcd_driver = import_lcd_driver()


class DisplayRotationTests(unittest.TestCase):
def test_all_mount_angles_have_expected_controller_values(self):
self.assertEqual(
{0: 0x98, 90: 0xF8, 180: 0x58, 270: 0x38},
{
rotation: madctl_for_rotation(rotation)
for rotation in (0, 90, 180, 270)
},
)

def test_controller_rotation_safely_toggles_tearing_output(self):
for rotation in (0, 90, 180, 270):
with self.subTest(rotation=rotation):
lcd = lcd_driver.LCD_1inch28.__new__(
lcd_driver.LCD_1inch28
)
lcd.commands = []
lcd.data = []
lcd.write_cmd = lcd.commands.append
lcd.write_data = lcd.data.append

lcd.set_rotation(rotation)

self.assertEqual([0x34, 0x36, 0x35], lcd.commands)
self.assertEqual([madctl_for_rotation(rotation)], lcd.data)
self.assertEqual(rotation, lcd.rotation)

def test_invalid_rotation_is_rejected_before_hardware_write(self):
lcd = lcd_driver.LCD_1inch28.__new__(lcd_driver.LCD_1inch28)
lcd.commands = []
lcd.write_cmd = lcd.commands.append
lcd.write_data = lambda _value: None

with self.assertRaises(ValueError):
lcd.set_rotation(45)

self.assertEqual([], lcd.commands)

def test_unknown_gestures_remain_unmapped_at_every_rotation(self):
for rotation in (0, 90, 180, 270):
with self.subTest(rotation=rotation):
self.assertIsNone(map_gesture_direction(None, rotation))
self.assertEqual(
"double",
map_gesture_direction("double", rotation),
)


if __name__ == "__main__":
unittest.main()
Loading
Loading