Audience: New contributors, AI coding agents, and senior reviewers.
Purpose: Define the system architecture, design patterns, cross-platform strategy, testing approach, and operational best-practices for the MaximumTrainer codebase so that every change remains consistent, maintainable, and safe.
- Project Overview
- Code Architecture & Design Patterns
- Target Runtimes & Cross-Platform Strategy
- Testing Approach & Quality Assurance
- Best Practices for Maintainability & Scalability
- Performance & Safety
MaximumTrainer is a cross-platform cycling and rowing training application built with Qt / C++17. It connects to smart trainers, power meters, heart rate monitors, cadence sensors, and rowing ergometers via Bluetooth Low Energy (BLE), plays structured interval workouts in ERG-mode, and exports completed activities to the Garmin FIT format.
| Attribute | Value |
|---|---|
| Language | C++17 |
| UI Toolkit | Qt 6 (Widgets + WebEngineWidgets) |
| Plotting | QWT 6.3 |
| Serialisation | Garmin FIT SDK, TCX, GPX, XML |
| Hardware protocols | BLE (Qt Bluetooth) |
| Audio/Video | VLC-Qt (desktop), SFML (audio), platform stubs for WASM |
| Build system | qmake .pro / .pri |
| CI/CD | GitHub Actions (Linux · Windows · macOS · WebAssembly) |
MaximumTrainer is organised into four horizontal layers. Each layer depends only on the layers below it; the UI and hardware layers are isolated from each other by the domain model.
┌─────────────────────────────────────────────────────────┐
│ UI Layer │
│ src/ui/ (MainWindow, WorkoutDialog, WorkoutCreator) │
│ Qt Widgets · QWT plots · .ui form files │
└────────────────────┬────────────────────────────────────┘
│ Qt signals / slots
┌────────────────────▼────────────────────────────────────┐
│ Domain / Training Engine │
│ src/model/ (Workout, Interval, DataWorkout, …) │
│ src/workout/ (WorkoutUtil — format conversion) │
│ src/fitness/ (FIT SDK, Achievements) │
│ Pure C++ — no Qt hardware or UI dependencies │
└───────────┬──────────────────────────┬──────────────────┘
│ │
┌───────────▼──────────┐ ┌────────────▼─────────────────┐
│ Hardware │ │ Persistence │
│ Abstraction Layer │ │ src/persistence/ │
│ (HAL) │ │ SQLite DAOs (users, sensors,│
│ src/btle/ │ │ achievements), FIT/TCX/GPX │
│ BtleHub · SimHub │ │ file writers/readers │
└──────────────────────┘ └──────────────────────────────┘
Key invariant: src/model/, src/workout/, and src/fitness/ must
never #include anything from src/btle/ or src/ui/.
Hardware and UI concerns are injected via Qt signals/slots and constructor
parameters.
All hardware communication goes through one of three interchangeable hub classes that present identical signal/slot contracts:
| Class | Location | Purpose |
|---|---|---|
BtleHub |
src/btle/btle_hub.h |
Real BLE hardware via Qt Bluetooth |
BtleHubWasm |
src/btle/btle_hub_wasm.h |
Web Bluetooth API via JS bridge |
SimulatorHub |
src/btle/simulator_hub.h |
Synthetic data for CI & demos |
The signal contract shared by every hub:
signals:
void signal_hr (int userID, int hr);
void signal_cadence(int userID, int cadence);
void signal_speed (int userID, double speed); // km/h
void signal_power (int userID, int power); // watts
void signal_oxygen (int userID, double smo2, double thb);
void deviceConnected();
void deviceDisconnected();
void connectionError(const QString &errorString);public slots:
void setLoad (int antID, double watts);
void setSlope(int antID, double grade);
void stopDecodingMsg();WorkoutDialog connects to whichever hub is active and is unaware of the
underlying implementation. Swapping from real hardware to simulation is
a single connect() call change in MainWindow::executeWorkout().
High-frequency sensor data (≥10 Hz) is processed with the Observer pattern using Qt's signal/slot mechanism:
BtleHub (producer)
──signal_power(userId, watts)──► WorkoutDialog (consumer)
──signal_hr(userId, bpm)───────► WorkoutDialog
──signal_cadence(userId, rpm)──► WorkoutDialog
│
▼
DataWorkout (recorder)
│
▼
WorkoutPlot (renderer)
All signal emissions from hardware hubs happen on the main Qt thread.
BtleHub uses QLowEnergyController callbacks that Qt dispatches on the
thread that owns the controller object — always MainWindow's thread — so no
explicit thread-safety guards are needed in the current single-threaded-UI
design.
Each subsystem is modelled as an autonomous agent that owns its state and communicates exclusively through Qt signals/slots:
| Agent | Owns | Communicates via |
|---|---|---|
BtleHub |
BLE connection state, characteristic subscriptions | Signals: signal_*, deviceConnected, connectionError |
SimulatorHub |
Timer-driven synthetic telemetry | Same signals as BtleHub |
WorkoutDialog |
Active workout session state (elapsed time, target power, lap index) | Receives sensor signals; emits setLoad / setSlope to hub |
DataWorkout |
Per-second telemetry recording | Populated by WorkoutDialog during playback |
AchievementChecker |
Achievement evaluation logic | Called post-workout by WorkoutDialog |
FitActivityCreator |
FIT file serialisation | Invoked by WorkoutDialog on session save |
This agent topology means that each component can be unit-tested in isolation by connecting test spies instead of real counterparts.
All list data exposed in the UI follows Qt's Model/View pattern:
| Model class | Data | Used by |
|---|---|---|
WorkoutTableModel |
QList<Workout> |
MainWindow workout browser |
IntervalTableModel |
QList<Interval> |
WorkoutCreator interval editor |
CourseTableModel |
QList<Course> |
Course browser |
RadioTableModel |
QList<Radio> |
Sensor configuration dialog |
SortFilterProxyModel |
Wraps any above | Search boxes |
Views (QTableView, QListView) are never given raw data — only model
pointers — preventing direct coupling between UI and persistence layers.
| Platform | Qt Version | Compiler | Status |
|---|---|---|---|
| Linux (Ubuntu 22.04+) | 6.5+ | GCC 11+ | ✅ Primary development target |
| Windows 10/11 | 6.5.3 | MSVC 2019 (x64) | ✅ Release build |
| macOS 13+ (Apple Silicon / Intel) | 6.5.3 | Clang | ✅ Release build |
| WebAssembly (browser) | 6.6.3 | Emscripten 3.1.43 | continue-on-error) |
The strategy is "maximise shared C++ core; isolate platform differences behind compile-time guards or swappable adapters".
┌────────────────────────────────────┐
│ Shared Core (all targets) │
│ model/ · workout/ · fitness/ │
│ persistence/ · ui/ │
└──────────┬─────────────────────────┘
│
┌─────▼──────────────────────────────────────────┐
│ Platform adapters (selected at build) │
│ │
│ BLE: btle_hub.cpp (desktop) │
│ btle_hub_wasm.cpp (WASM) │
│ webbluetooth_bridge (WASM JS bridge) │
│ │
│ Audio: myvlcplayer.cpp (desktop+VLC) │
│ soundplayer.cpp (SFML desktop) │
│ soundplayer_wasm.cpp (WASM stub) │
│ │
│ Scanner: btle_scanner_dialog.cpp (desktop) │
│ btle_scanner_dialog_wasm (WASM) │
│ │
│ WebEngine: real QtWebEngineWidgets (desktop) │
│ src/ui/wasm_stubs/ header stubs │
└─────────────────────────────────────────────────┘
Platform selection is controlled at qmake time using .pro / .pri
scopes (wasm, win32, macx, linux) and optional defines
(GC_HAVE_VLCQT). No runtime #ifdef branching inside shared logic files.
| Concern | Constraint | Mitigation |
|---|---|---|
| No native BLE APIs | Web Bluetooth is promise-based | BtleHubWasm + WebBluetoothBridge (Emscripten embind + JS callbacks) |
| No file-system access | QFile writes are in-memory |
Persist via browser download prompt |
| No VLC / SFML | Shared libs unavailable | soundplayer_wasm.cpp stub; no video |
| Single-threaded Emscripten | pthread unavailable (singlethread build) |
No QThread use in WASM paths |
QWebEngineWidgets absent |
Not ported to WASM | Stub headers in src/ui/wasm_stubs/ |
Desktop (Qt Bluetooth) WASM (Web Bluetooth)
───────────────────── ──────────────────────
BtleHub BtleHubWasm
└─ QLowEnergyController └─ WebBluetoothBridge (C++)
└─ Qt platform plugin └─ JavaScript embind bindings
└─ OS BLE stack └─ navigator.bluetooth API
Both paths emit identical signals to WorkoutDialog, ensuring zero
divergence in training-engine logic between platforms.
╔══════════════════╗
║ System / E2E ║ (Playwright WASM, integration screenshots)
╚══════╤═══════════╝
╔═════════╧════════════════╗
║ Integration Tests ║ (BtleHub + WorkoutDialog + SimulatorHub)
╚═══════╤══════════════════╝
╔══════════╧═══════════════════════╗
║ Unit Tests ║ (BtleHub parsing, model, workout util)
╚══════════════════════════════════╝
Project file: tests/btle/btle_tests.pro
Runner: tests/btle/tst_btle_hub.cpp (28 test cases)
Framework: Qt Test (QTest, QSignalSpy)
Tests exercise BtleHub::simulateNotification() — a dedicated test hook that
injects raw BLE characteristic byte arrays as if received from hardware:
// Inject a Heart Rate Measurement characteristic notification
hub.simulateNotification(0x2A37, QByteArray::fromHex("0060")); // 96 bpm
QCOMPARE(spy.count(), 1);
QCOMPARE(spy.takeFirst().at(1).toInt(), 96);Coverage areas:
| Group | Tests | What is verified |
|---|---|---|
| HR parsing | 5 | 8-bit/16-bit flags, RR-interval presence, zero, max |
| CSC parsing | 6 | Crank-only, wheel-only, combined, uint16 rollover, standstill, first-measurement discard |
| Power parsing | 3 | Positive, zero, negative (track-stand) |
| FTMS parsing | 5 | Speed-only, cadence-only, power-only, all-fields, zero values |
| Trainer sims | 3 | Elite (FTMS), Wahoo KICKR (Power+CSC), Garmin Tacx (FTMS+CSC) |
| SimulatorHub | 6 | Signal emission, drift-within-bounds, slot no-ops |
Build & run:
cd tests/btle
qmake btle_tests.pro && make -j$(nproc)
./build/tests/btle_tests -v2Project file: tests/integration/btle_integration_tests.pro
Runner: tests/integration/tst_btle_integration.cpp
Framework: Qt Test + Xvfb (virtual X display in CI)
Integration tests launch the full WorkoutDialog against SimulatorHub,
drive it through a workout session, and assert on the resulting DataWorkout
object. Screenshots are captured at key lifecycle points and uploaded as CI
artifacts:
# CI command (build.yml: test_btle_integration job)
Xvfb :99 -screen 0 1920x1080x24 &
DISPLAY=:99 ./build/tests/btle_integration_tests -v2What integration tests cover:
- Full workout session lifecycle (start → pause → resume → stop)
SimulatorHubdata flowing throughWorkoutDialogintoDataWorkout- ERG-mode load commands sent from
WorkoutDialogback to the hub - Lap transitions and interval advancement logic
| Mechanism | File | Used in |
|---|---|---|
SimulatorHub |
src/btle/simulator_hub.cpp |
UI simulation mode, integration tests |
BtleDeviceSimulator |
tests/btle/btle_device_simulator.h |
Unit test byte-level fake device |
| WASM audio stub | src/app/soundplayer_wasm.cpp |
WASM build (no SFML) |
| WASM WebEngine stub | src/ui/wasm_stubs/ |
WASM build (no QtWebEngineWidgets) |
The design principle is: no test should require physical hardware.
SimulatorHub can replace any real hub at the MainWindow level.
BtleHub::simulateNotification() covers byte-level parsing without a BLE
adapter.
Config: playwright.config.js (root)
Spec: tests/playwright/wasm_webapp.spec.js
Framework: Playwright (Chromium headless)
Playwright tests run against the live GitHub Pages deployment
(https://maximumtrainer.github.io/MaximumTrainer_Redux/app/).
They validate:
- Asset availability —
qtloader.js,MaximumTrainer.js,MaximumTrainer.wasmall return HTTP 200. - Page load — Loading screen or Qt canvas becomes visible within 4 s.
- No "not deployed" sentinel — Confirms the WASM artefact has been published.
A navigator.bluetooth stub is injected via addInitScript() so the app
does not abort immediately on browsers without real BLE.
Run locally (after WASM deployment):
npx playwright install chromium
npx playwright testPush to branch
│
├─► build_linux ──► test_btle_integration (Xvfb, screenshots)
├─► build_windows
├─► build_mac
└─► build_wasm (continue-on-error)
│
(master only, all non-wasm pass)
│
▼
tag_release (auto-increment semver, dispatch release.yml)
│
▼
release.yml: create GitHub Release + attach artefacts
│
▼
pages.yml: deploy docs/ + WASM to GitHub Pages
│
▼
Playwright tests run against deployed WASM
All jobs run in parallel. WASM failure does not block release publication
(continue-on-error: true + always() guard on publish job).
- Hub injection:
WorkoutDialogreceives its hub viaconnect()calls inMainWindow::executeWorkout(). It never instantiatesBtleHuborSimulatorHubdirectly. - DAO injection: All database access objects (
UserDAO,SensorDAO, …) are constructed once inEnvironnement(note: French spelling — the actual class name insrc/persistence/db/environnement.h) and passed to consumers. No static/singleton DAO access. - Settings injection:
AccountandSettingsobjects are passed into dialogs via constructor parameters orsetAccount()setters, not obtained from global state inside the dialog. - Test guideline: Any class that cannot be unit-tested by replacing its hardware or database dependency with a stub/mock is a dependency-injection violation.
| Boundary | Mechanism | Recovery |
|---|---|---|
| BLE connection loss | BtleHub::connectionError(QString) signal |
WorkoutDialog shows reconnect dialog; pauses ERG commands |
| BLE reconnect | BtleHub internal QTimer (5 s) re-invokes connectToDevice |
Up to 3 automatic retries |
| File I/O failure | Return false + qWarning() in all XmlUtil, FitActivityCreator methods |
UI shows save-failure dialog |
| Database error | QSqlQuery::lastError() checked after every exec; logged |
Operation skipped; no crash |
| WASM asset load failure | qtloader.js error callback sets #loading-screen error text |
User sees graceful "load failed" page |
Rule: No hardware or I/O error should propagate as an unhandled exception
or cause an undefined-behaviour crash. Errors are logged with qWarning() /
qCritical() and surfaced to the user via a status label or dialog.
The codebase is segmented into qmake .pri modules, each with a single
responsibility:
Module (.pri) |
Responsibility | Internal dependencies |
|---|---|---|
src/model/model.pri |
Pure domain model (Workout, Interval, Course, …) | None |
src/workout/workout.pri |
Workout file conversion utilities | model |
src/btle/btle.pri |
BLE HAL (hub + scanner + simulator) | model |
src/persistence/persistence.pri |
SQLite DAOs + file readers/writers | model, fitness |
src/fitness/fitness.pri |
FIT SDK + Achievement logic | model |
src/ui/ui.pri |
All UI: MainWindow, WorkoutDialog, plots, editors | All above |
src/app/app.pri |
Entry point + global state (VLC, audio, utils) | ui, btle, model |
Adding a new feature:
- Define domain types in
model/. - Add persistence in
persistence/. - Add business logic in
workout/orfitness/. - Wire UI in
ui/. - Never skip layers.
- C++17 throughout (
std::optional, structured bindings,if constexpr). - Include paths are resolved by
INCLUDEPATHin.pro— use bare filenames without path prefixes in#includedirectives for files within the same module (e.g.,#include "btle_hub.h"). For cross-module includes or third-party headers, prefer the shortest unambiguous path (e.g.,#include "../../src/btle/btle_hub.h"in test files that live outsidesrc/). - UI files (
.ui) are machine-edited by Qt Designer; hand-edit only for property additions that Designer cannot express. Q_OBJECTis required on every class that uses signals or slots.- Signal parameter types must be value types or
const &— never raw pointers — to avoid lifetime issues across thread boundaries. - Use
qint64/quint16etc. (notlong/unsigned short) for any value that maps to a BLE/FIT wire type.
DataWorkout accumulates one TrackPoint per second. A 2-hour workout
generates ~7 200 points. At ~64 bytes each, peak heap usage for telemetry
is < 500 KB — well within any platform's capacity.
Guidelines:
- Do not store raw BLE notification byte arrays long-term; parse
immediately in
onCharacteristicChanged()and discard. WorkoutPlotcurves are backed byQwtSeriesDatathat references the sameDataWorkoutvector — no copies. Append-only; never remove mid-workout.FitActivityCreatorwrites the FIT file in a single pass at session end. It does not buffer the entire file in memory; records are encoded and written incrementally.- After session save,
DataWorkoutis owned by the history model and not retained byWorkoutDialog(it is passed viastd::moveor pointer transfer to the history-list owner).
MaximumTrainer uses a single-threaded event loop design:
- All BLE callbacks (
QLowEnergyController,QLowEnergyService) are delivered on the thread that owns the controller — the main thread. SimulatorHubfires aQTimeron the main thread.WorkoutDialogprocesses signals on the main thread.- No shared mutable state is accessed from multiple threads, so no mutex locking is needed in the hot path.
If a future change introduces a worker thread (e.g., for FIT file export):
- Use
QThread+QObject::moveToThread()— never subclassQThread. - All cross-thread communication must use queued signal/slot connections (automatic when objects live on different threads in Qt).
- Any shared data structure updated from a worker thread must be protected by
QMutex/QReadWriteLock. - Never call
QWidgetmethods from a non-main thread.
BtleHub implements automatic reconnection:
onControllerDisconnected()
│
▼ start m_reconnectTimer (5 s)
│
▼ connectToDevice() [re-entry, up to 3 attempts]
│
success ──► serviceDiscoveryFinished() ──► re-subscribe notifications
failure ──► emit connectionError(...) ──► WorkoutDialog informs user
- BLE operations that may stall emit
connectionErrorafter a configurable timeout (QTimerguard ononDiscoveryFinished). - ERG commands (
setLoad/setSlope) are fire-and-forget; they do not block. If the device is disconnected, the command is silently dropped and the reconnect flow is already in progress.
- All WASM paths are compiled with Emscripten's single-threaded runtime
(
Qt WASM singlethread). - No
QThread, nostd::thread, no POSIX threads. - Asynchronous BLE operations (Web Bluetooth) are handled via
emscripten::valcallbacks marshalled back to the Qt event loop throughWebBluetoothBridge. - Large synchronous operations (e.g., FIT file creation) must be kept
< 16 ms per invocation to avoid blocking the browser's main thread.
If ever they exceed this, split with
QTimer::singleShot(0, …)to yield.
This document should be updated alongside any architectural change. When adding a new hardware protocol, runtime target, or test tier, update the relevant section and add an entry to the layer diagram.