From 71729e07d3d86db7870dceb192f4eb873f1362d4 Mon Sep 17 00:00:00 2001 From: mrsqr Date: Wed, 12 Aug 2026 18:36:07 +0100 Subject: [PATCH] Organize firmware into dedicated directories --- .github/workflows/tests.yml | 4 +- README.md | 52 ++++++---- assets/README.md | 2 +- HARDWARE_BOM.md => docs/HARDWARE_BOM.md | 2 +- User Guide.md => docs/USER_GUIDE.md | 0 firmware/README.md | 18 ++++ application.py => firmware/application.py | 0 auto_dim.py => firmware/auto_dim.py | 0 auto_rotation.py => firmware/auto_rotation.py | 0 battery.py => firmware/battery.py | 0 configuration.py => firmware/configuration.py | 0 font_data.bin => firmware/font_data.bin | Bin font_data.py => firmware/font_data.py | 3 +- font_data_12.bin => firmware/font_data_12.bin | Bin font_data_20.bin => firmware/font_data_20.bin | Bin font_data_30.bin => firmware/font_data_30.bin | Bin font_data_44.bin => firmware/font_data_44.bin | Bin font_data_54.bin => firmware/font_data_54.bin | Bin font_data_74.bin => firmware/font_data_74.bin | Bin font_data_84.bin => firmware/font_data_84.bin | Bin font_renderer.py => firmware/font_renderer.py | 0 g_force.py => firmware/g_force.py | 0 g_meter.py => firmware/g_meter.py | 0 hardware.py => firmware/hardware.py | 0 .../hardware_splash.py | 0 hold_detector.py => firmware/hold_detector.py | 0 launch.py => firmware/launch.py | 0 lcd_1inch28.py => firmware/lcd_1inch28.py | 0 live_display.py => firmware/live_display.py | 0 main.py => firmware/main.py | 0 .../operating_modes.py | 0 orientation.py => firmware/orientation.py | 0 params.json => firmware/params.json | 0 qmi8658.py => firmware/qmi8658.py | 0 ready_screen.py => firmware/ready_screen.py | 0 .../session_summary.py | 0 settings.py => firmware/settings.py | 0 splash.py => firmware/splash.py | 0 .../startup_splash.rgb565 | Bin timer_mode.py => firmware/timer_mode.py | 0 timing.py => firmware/timing.py | 0 touch_drive.py => firmware/touch_drive.py | 0 user.json => firmware/user.json | 0 tests/__init__.py | 8 ++ tests/test_font_renderer.py | 9 +- tests/test_release_version.py | 6 +- tests/test_repository_layout.py | 52 ++++++++++ tests/test_splash.py | 2 +- tools/deploy.py | 92 ++++++++++++++++++ tools/generate_font.py | 3 +- 50 files changed, 224 insertions(+), 29 deletions(-) rename HARDWARE_BOM.md => docs/HARDWARE_BOM.md (97%) rename User Guide.md => docs/USER_GUIDE.md (100%) create mode 100644 firmware/README.md rename application.py => firmware/application.py (100%) rename auto_dim.py => firmware/auto_dim.py (100%) rename auto_rotation.py => firmware/auto_rotation.py (100%) rename battery.py => firmware/battery.py (100%) rename configuration.py => firmware/configuration.py (100%) rename font_data.bin => firmware/font_data.bin (100%) rename font_data.py => firmware/font_data.py (98%) rename font_data_12.bin => firmware/font_data_12.bin (100%) rename font_data_20.bin => firmware/font_data_20.bin (100%) rename font_data_30.bin => firmware/font_data_30.bin (100%) rename font_data_44.bin => firmware/font_data_44.bin (100%) rename font_data_54.bin => firmware/font_data_54.bin (100%) rename font_data_74.bin => firmware/font_data_74.bin (100%) rename font_data_84.bin => firmware/font_data_84.bin (100%) rename font_renderer.py => firmware/font_renderer.py (100%) rename g_force.py => firmware/g_force.py (100%) rename g_meter.py => firmware/g_meter.py (100%) rename hardware.py => firmware/hardware.py (100%) rename hardware_splash.py => firmware/hardware_splash.py (100%) rename hold_detector.py => firmware/hold_detector.py (100%) rename launch.py => firmware/launch.py (100%) rename lcd_1inch28.py => firmware/lcd_1inch28.py (100%) rename live_display.py => firmware/live_display.py (100%) rename main.py => firmware/main.py (100%) rename operating_modes.py => firmware/operating_modes.py (100%) rename orientation.py => firmware/orientation.py (100%) rename params.json => firmware/params.json (100%) rename qmi8658.py => firmware/qmi8658.py (100%) rename ready_screen.py => firmware/ready_screen.py (100%) rename session_summary.py => firmware/session_summary.py (100%) rename settings.py => firmware/settings.py (100%) rename splash.py => firmware/splash.py (100%) rename startup_splash.rgb565 => firmware/startup_splash.rgb565 (100%) rename timer_mode.py => firmware/timer_mode.py (100%) rename timing.py => firmware/timing.py (100%) rename touch_drive.py => firmware/touch_drive.py (100%) rename user.json => firmware/user.json (100%) create mode 100644 tests/test_repository_layout.py create mode 100644 tools/deploy.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9ea6df..f6ae0dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index 0a79dea..d0dc39e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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`. @@ -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 ``` @@ -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 @@ -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 @@ -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. @@ -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. @@ -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. diff --git a/assets/README.md b/assets/README.md index 4bd0b5f..e6a27de 100644 --- a/assets/README.md +++ b/assets/README.md @@ -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 ``` diff --git a/HARDWARE_BOM.md b/docs/HARDWARE_BOM.md similarity index 97% rename from HARDWARE_BOM.md rename to docs/HARDWARE_BOM.md index 8c837c5..7f4ffbb 100644 --- a/HARDWARE_BOM.md +++ b/docs/HARDWARE_BOM.md @@ -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. diff --git a/User Guide.md b/docs/USER_GUIDE.md similarity index 100% rename from User Guide.md rename to docs/USER_GUIDE.md diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..d5662fb --- /dev/null +++ b/firmware/README.md @@ -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. diff --git a/application.py b/firmware/application.py similarity index 100% rename from application.py rename to firmware/application.py diff --git a/auto_dim.py b/firmware/auto_dim.py similarity index 100% rename from auto_dim.py rename to firmware/auto_dim.py diff --git a/auto_rotation.py b/firmware/auto_rotation.py similarity index 100% rename from auto_rotation.py rename to firmware/auto_rotation.py diff --git a/battery.py b/firmware/battery.py similarity index 100% rename from battery.py rename to firmware/battery.py diff --git a/configuration.py b/firmware/configuration.py similarity index 100% rename from configuration.py rename to firmware/configuration.py diff --git a/font_data.bin b/firmware/font_data.bin similarity index 100% rename from font_data.bin rename to firmware/font_data.bin diff --git a/font_data.py b/firmware/font_data.py similarity index 98% rename from font_data.py rename to firmware/font_data.py index 86d1aca..5bcf9c6 100644 --- a/font_data.py +++ b/firmware/font_data.py @@ -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. """ diff --git a/font_data_12.bin b/firmware/font_data_12.bin similarity index 100% rename from font_data_12.bin rename to firmware/font_data_12.bin diff --git a/font_data_20.bin b/firmware/font_data_20.bin similarity index 100% rename from font_data_20.bin rename to firmware/font_data_20.bin diff --git a/font_data_30.bin b/firmware/font_data_30.bin similarity index 100% rename from font_data_30.bin rename to firmware/font_data_30.bin diff --git a/font_data_44.bin b/firmware/font_data_44.bin similarity index 100% rename from font_data_44.bin rename to firmware/font_data_44.bin diff --git a/font_data_54.bin b/firmware/font_data_54.bin similarity index 100% rename from font_data_54.bin rename to firmware/font_data_54.bin diff --git a/font_data_74.bin b/firmware/font_data_74.bin similarity index 100% rename from font_data_74.bin rename to firmware/font_data_74.bin diff --git a/font_data_84.bin b/firmware/font_data_84.bin similarity index 100% rename from font_data_84.bin rename to firmware/font_data_84.bin diff --git a/font_renderer.py b/firmware/font_renderer.py similarity index 100% rename from font_renderer.py rename to firmware/font_renderer.py diff --git a/g_force.py b/firmware/g_force.py similarity index 100% rename from g_force.py rename to firmware/g_force.py diff --git a/g_meter.py b/firmware/g_meter.py similarity index 100% rename from g_meter.py rename to firmware/g_meter.py diff --git a/hardware.py b/firmware/hardware.py similarity index 100% rename from hardware.py rename to firmware/hardware.py diff --git a/hardware_splash.py b/firmware/hardware_splash.py similarity index 100% rename from hardware_splash.py rename to firmware/hardware_splash.py diff --git a/hold_detector.py b/firmware/hold_detector.py similarity index 100% rename from hold_detector.py rename to firmware/hold_detector.py diff --git a/launch.py b/firmware/launch.py similarity index 100% rename from launch.py rename to firmware/launch.py diff --git a/lcd_1inch28.py b/firmware/lcd_1inch28.py similarity index 100% rename from lcd_1inch28.py rename to firmware/lcd_1inch28.py diff --git a/live_display.py b/firmware/live_display.py similarity index 100% rename from live_display.py rename to firmware/live_display.py diff --git a/main.py b/firmware/main.py similarity index 100% rename from main.py rename to firmware/main.py diff --git a/operating_modes.py b/firmware/operating_modes.py similarity index 100% rename from operating_modes.py rename to firmware/operating_modes.py diff --git a/orientation.py b/firmware/orientation.py similarity index 100% rename from orientation.py rename to firmware/orientation.py diff --git a/params.json b/firmware/params.json similarity index 100% rename from params.json rename to firmware/params.json diff --git a/qmi8658.py b/firmware/qmi8658.py similarity index 100% rename from qmi8658.py rename to firmware/qmi8658.py diff --git a/ready_screen.py b/firmware/ready_screen.py similarity index 100% rename from ready_screen.py rename to firmware/ready_screen.py diff --git a/session_summary.py b/firmware/session_summary.py similarity index 100% rename from session_summary.py rename to firmware/session_summary.py diff --git a/settings.py b/firmware/settings.py similarity index 100% rename from settings.py rename to firmware/settings.py diff --git a/splash.py b/firmware/splash.py similarity index 100% rename from splash.py rename to firmware/splash.py diff --git a/startup_splash.rgb565 b/firmware/startup_splash.rgb565 similarity index 100% rename from startup_splash.rgb565 rename to firmware/startup_splash.rgb565 diff --git a/timer_mode.py b/firmware/timer_mode.py similarity index 100% rename from timer_mode.py rename to firmware/timer_mode.py diff --git a/timing.py b/firmware/timing.py similarity index 100% rename from timing.py rename to firmware/timing.py diff --git a/touch_drive.py b/firmware/touch_drive.py similarity index 100% rename from touch_drive.py rename to firmware/touch_drive.py diff --git a/user.json b/firmware/user.json similarity index 100% rename from user.json rename to firmware/user.json diff --git a/tests/__init__.py b/tests/__init__.py index 5312d8b..854c1b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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)) diff --git a/tests/test_font_renderer.py b/tests/test_font_renderer.py index fe0c3e4..adcab62 100644 --- a/tests/test_font_renderer.py +++ b/tests/test_font_renderer.py @@ -1,3 +1,4 @@ +import os import unittest from pathlib import Path @@ -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) @@ -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)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 45ac43a..554968f 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -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" ) diff --git a/tests/test_repository_layout.py b/tests/test_repository_layout.py new file mode 100644 index 0000000..24aeb73 --- /dev/null +++ b/tests/test_repository_layout.py @@ -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() diff --git a/tests/test_splash.py b/tests/test_splash.py index 6c2bb82..a3373b2 100644 --- a/tests/test_splash.py +++ b/tests/test_splash.py @@ -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) diff --git a/tools/deploy.py b/tools/deploy.py new file mode 100644 index 0000000..2d9a2fb --- /dev/null +++ b/tools/deploy.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Deploy the repository's flat firmware payload to a MicroPython device.""" + +import argparse +import shlex +import subprocess +import sys +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FIRMWARE_ROOT = REPOSITORY_ROOT / "firmware" +ENTRY_POINT = FIRMWARE_ROOT / "main.py" +USER_CONFIG = FIRMWARE_ROOT / "user.json" +DEPLOYED_SUFFIXES = {".py", ".json", ".bin", ".rgb565"} + + +def support_files(include_user=False): + """Return deterministic payload paths, excluding the entry point.""" + excluded = {ENTRY_POINT} + if not include_user: + excluded.add(USER_CONFIG) + return tuple( + path + for path in sorted(FIRMWARE_ROOT.iterdir(), key=lambda item: item.name) + if path.is_file() + and path.suffix in DEPLOYED_SUFFIXES + and path not in excluded + ) + + +def mpremote_command(arguments, port, executable=None): + """Build one mpremote command without relying on the current directory.""" + return [ + executable or sys.executable, + "-m", + "mpremote", + "connect", + port, + *[str(argument) for argument in arguments], + ] + + +def deployment_commands(port="auto", include_user=False, executable=None): + """Build ordered copy and reset commands; main.py is always copied last.""" + files = support_files(include_user=include_user) + return ( + mpremote_command( + ("fs", "cp", *files, ":"), + port, + executable=executable, + ), + mpremote_command( + ("fs", "cp", ENTRY_POINT, ":"), + port, + executable=executable, + ), + mpremote_command(("reset",), port, executable=executable), + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--port", + default="auto", + help="mpremote connection target (default: auto)", + ) + parser.add_argument( + "--include-user", + action="store_true", + help="install firmware/user.json; use only for a fresh or reset device", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="print the ordered commands without connecting to a device", + ) + args = parser.parse_args() + + commands = deployment_commands( + port=args.port, + include_user=args.include_user, + ) + for command in commands: + print(shlex.join(command)) + if not args.dry_run: + subprocess.run(command, check=True) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_font.py b/tools/generate_font.py index 639e42b..3fa02a5 100644 --- a/tools/generate_font.py +++ b/tools/generate_font.py @@ -98,7 +98,8 @@ def main(): module = '''"""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. """