QDomyos-Zwift is a Qt-based application that bridges fitness equipment (treadmills, bikes, ellipticals, rowers) with virtual training platforms like Zwift. It acts as a Bluetooth intermediary, connecting physical equipment to fitness apps while providing enhanced features like Peloton integration, power zone training, and workout programs.
- Ask, dont assume. If something's unclear, ask before writing a line and no silent guesses about intent, architecture, or requirements.
- Simplest solution first and implement the minimum thing that works. No abstractions you didn't request.
- Dont touch unrelated code and if a file isnt part of the current task, leave it.
- Flag uncertainty explicitly or if you're not confident, say so before proceeding as confidence without certainty causes more damage than admitting a gap.
The application follows a hierarchical device architecture:
-
Base Class:
bluetoothdevice- Abstract base for all fitness devices- Manages Bluetooth connectivity via Qt's QLowEnergyController
- Defines common metrics (speed, cadence, heart rate, power, distance)
- Integrates with virtual devices for app connectivity
-
Device Type Classes: Inherit from
bluetoothdevicebike- Bike-specific features (resistance, gears, power zones)treadmill- Treadmill features (speed control, inclination, pace)elliptical- Combined bike/treadmill featuresrower- Rowing metrics (stroke count, 500m pace)stairclimber- Step counting and climbing metricsjumprope- Jump sequence tracking
-
Concrete Implementations: Inherit from device type classes
- Located in
src/devices/[devicename]/folders - Examples:
domyosbike,pelotonbike,ftmsbike
- Located in
virtualdevice- Abstract base for virtual representationsvirtualbike,virtualtreadmill, etc. - Advertise to external apps- Enables bidirectional communication between physical and virtual devices
bluetoothclass acts as device factory and connection managerdiscoveryoptionsconfigures device discovery process- Supports multiple connection types (Bluetooth LE, TCP, UDP)
- Create device folder in
src/devices/[devicename]/ - Implement device class inheriting from appropriate base type
- Add device detection logic to
bluetooth.cpp - Update
qdomyos-zwift.priwith new source files - Add tests in
tst/Devices/following existing patterns
CRITICAL: Always verify device pattern conflicts before adding to bluetooth.cpp
When adding a new device pattern to src/devices/bluetooth.cpp, you MUST follow these verification steps:
-
Search for Similar Patterns: Use grep/search to find all existing device patterns that might conflict
- Search for device name prefixes (e.g., if adding "KS-NG-", search for all "KS-" patterns)
- Check patterns in all device type cases (bikes, treadmills, ellipticals, rowers, etc.)
-
Analyze Pattern Specificity: Understand the pattern hierarchy
- More specific patterns should be checked BEFORE less specific ones
- Example: "KS-NGCH-" is more specific than "KS-NG-"
- The order matters: devices are matched by the FIRST matching pattern in the if-else chain
-
Check Case Order: Verify the order of device type cases in bluetooth.cpp
- Earlier cases take precedence over later cases
- Ensure more specific patterns in earlier cases won't prevent your pattern from matching
- Ensure your pattern won't incorrectly match devices intended for other cases
-
Document Conflicts: When conflicts exist, verify they are intentional
- More specific patterns earlier in the chain should catch specific devices
- Your pattern should only catch devices not matched by more specific patterns
- Example: "KS-NGCH-X21C" (kingsmithR2Treadmill) should match before "KS-NG-" (horizontreadmill)
-
Test Pattern Matching: Consider these scenarios
- Will your pattern match the intended device? (e.g., "KS-NG-X218")
- Will it incorrectly match other devices? (e.g., "KS-NGCH-X21C")
- Are there existing patterns that would match your device first?
Example Verification Process:
# Search for similar patterns
grep -n "KS-" src/devices/bluetooth.cpp
# Review each match for conflicts
# - kingsmithR2Treadmill has "KS-NGCH-X21C" (line 1323)
# - horizontreadmill has "KS-MC" (line 1562)
# - Adding "KS-NG-" to horizontreadmill is safe because:
# 1. "KS-NGCH-" patterns are more specific
# 2. kingsmithR2Treadmill case comes first (line 1312 vs 1560)
# 3. "KS-NG-X218" won't match "KS-NGCH-" patternsCommon Pitfalls:
- Adding a pattern without checking existing patterns
- Not considering pattern order in the if-else chain
- Adding overly broad patterns that match unintended devices
- Not testing with actual device names
- Bluetooth characteristics handlers in
src/characteristics/ - FTMS (Fitness Machine Service) protocol support
- ANT+ integration for sensors
- Custom protocol implementations for specific brands
- QML-based UI with Qt Quick Controls 2
- Main QML files in
src/(main.qml, settings.qml, etc.) - Platform-specific UI adaptations (iOS, Android, desktop)
- Peloton workout/resistance integration (
peloton.cpp) - Zwift workout parsing (
zwiftworkout.cpp) - GPX file support for route following (
gpx.cpp) - Training program support (ZWO, XML formats)
- Swift bridge files in
src/ios/ - Apple Watch integration via
WatchKitConnection.swift - HealthKit integration for fitness data
- ConnectIQ SDK for Garmin devices
- Java bridge files in
src/android/src/ - ANT+ integration via Android ANT SDK
- Foreground service for background operation
- USB serial support for wired connections
Use tools/build_android_debug_aab.sh as the reference recipe (qmake → make →
make install → androiddeployqt --aux-mode --no-build → gradle). Known
Qt/NDK toolchain on this machine: QT_ANDROID_DIR=/home/cagnulein/qt5.15.0-android-page16/usr/local/Qt-5.15.0,
ANDROID_SDK_ROOT=/home/cagnulein/Android/Sdk, NDK ndk/21.3.6528147.
Gotchas that will silently produce a broken/crashing APK if skipped (each one cost 15-30+ min to diagnose the hard way — check these BEFORE spending time in logcat):
ANDROID_ABIS=<one-abi>is ignored by the top-levelmake/alltarget — qmake's generated Makefile always aggregatesarmeabi-v7a-all arm64-v8a-all x86-all x86_64-allregardless of the override. To build only the ABI you need, invoke the specific submake target directly, e.g.make x86_64-all(make INSTALL_ROOT=... installwill also silently rebuild all 4 ABIs — no known workaround short of patching the generated Makefile). Check the device's actual ABI first:adb shell getprop ro.product.cpu.abi.src/translations/*.qmare untracked build artifacts, not present in a freshgit worktreecheckout —qrc_translations.cppwill fail withNo rule to make target '...qdomyos-zwift_vi.qm'. Copy them from an existing checkout (cp .../src/translations/*.qm <worktree>/src/translations/) before running qmake/make.androiddeployqt --aux-modeon this Qt/NDK combo does NOT reliably bundle the Qt runtime.sos or the QML plugin RCC bundle, even though it logs no error. Two distinct silent failures, each with a different symptom:- Missing
lib/<abi>/libQt5*.so(Charts, Widgets, Gui, ...) → APK install succeeds but the app crashes instantly withUnsatisfiedLinkError: libQt5Charts_<abi>.so not found. - Missing
assets/android_rcc_bundle.rcc(androiddeployqt logsSkipping createRCC) → the app does NOT crash with a native error; insteadQQmlApplicationEnginefails to resolve every QML module (module "QtQuick.Controls" is not installed, etc.),engine.load()produces an empty root object list, andhomeform's constructor immediately dereferencesengine->rootObjects().constFirst()→Q_ASSERT/abort()with a backtrace that only showsQList::first()andhomeform::homeform(...), giving zero hint about the real QML cause. - Fix: reuse
libs/<abi>/*.soandassets/android_rcc_bundle.rccfrom any previously successfulandroid-buildoutput directory on the same machine (same Qt version → same Qt module/plugin binaries, safe to copy) instead of relying on androiddeployqt to regenerate them. - Root-causing tip: the custom message handler in
src/main.cpp(myMessageOutput) swallows ALLqWarning/qDebug(including QML load errors) unless thelog_debugQSettings key istrue— so QML errors are invisible inadb logcatby default. Temporarily forcelogdebug = truethere to see them, then revert.
- Missing
gradlew assembleDebugrequires Java 17, not 11, despite CI usingtemurin 11.0.23(AGP 8.13 requirement) —export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64.- Waydroid containers have no functional Bluetooth adapter
(
adb shell dumpsys bluetooth_managershowsenabled: false, un-fixable viaadb shell svc bluetooth enable).QBluetoothDeviceDiscoveryAgent::start()is created (a virtual adapter object exists) but its scan never emitsfinished(), sobluetooth::finished()— which is what actually activatesFake Device/Fake Treadmill/etc — never runs, and the app hangs on "Connecting..." forever. Real Android devices/emulators with working BT don't hit this. adb shell run-asdoes not work on Waydroid (setegid(AID_PACKAGE_INFO) failed), so editingqDomyos-Zwift.confdirectly is unavailable there — drive settings through the UI instead (adb shell input tap/swipe).- QtWebView
<canvas>content (chartjs live charts) doesn't visually repaint on Waydroid after the first paint, even though the underlying JS/Chart.js state is provably correct and updating. Verified by instrumentingdotreadmillchartlive.jswithconsole.logof the dataset array,chart.scales.x.getPixelForValue(), andcanvas.width/heighton every tick: the array keeps growing every second with correct x/y values and correct computed pixel positions,chart.update()(both animated and'none'mode) runs with no JS error — yetadb shell screencapandadb shell screenrecord(a different capture path, rules out a screencap-specific bug) both show the canvas frozen at whatever it looked like a few seconds after the chart first opened. A manual touch/swipe on the WebView momentarily forces a real repaint (briefly shows the correct up-to-date line) before freezing again. This means: don't trustscreencap/screenrecordsnapshots as proof a Chart.js-based live chart is broken on this environment — instrument the JS directly (console.logpiped throughadb logcat | grep chromium) to check the actual data/redraw state, and treat visual confirmation as only possible on a real Android device.
- ADB integration for Nordic Track iFit devices
- PaddleOCR integration for Zwift workout detection
- Windows-specific networking features
src/devices/[devicename]/
├── [devicename].h # Header file
├── [devicename].cpp # Implementation
└── README.md # Device-specific documentation (optional)
tst/Devices/
├── DeviceTestData.h # Test data definitions
├── Test[DeviceName].h # Device-specific test cases
└── TestBluetooth.cpp # Main device detection test suite
- Uses Google Test (gtest) with Google Mock
- Comprehensive device detection testing
- Configuration-based test scenarios
- XML output support for CI/CD integration
- Tests must be built after main application (links against libqdomyos-zwift.a)
- Settings managed via
qzsettings.cpp(QSettings wrapper) - Platform-specific configuration paths
- Profile system for multiple users/devices
- Extensive customization options for device behavior
src/settings-catalog.jsonis the manually maintained cross-platform catalog of every persistent setting declared insrc/settings.qmland its settings child QML files. When adding, removing, or renaming any settings QML property, updatesrc/settings-catalog.jsonby hand in the same change. KeepsettingCountsynchronized and add new settings at the end of the catalog.
- Qt 5.15.2+ (Bluetooth, WebSockets, Charts, Quick, etc.)
- Google Test (submodule for testing)
- Platform SDKs (Android ANT+, iOS HealthKit, Windows ADB)
- Protocol Buffers for Zwift API integration
- MQTT client for IoT integration
- Various fitness platform APIs (Strava, Garmin Connect, etc.)
This section provides a complete guide for adding new ProForm treadmill models to the codebase, based on the ProForm 995i implementation.
- Bluetooth Frame Capture File: A file containing raw Bluetooth frames from the target treadmill
- Frame Analysis: Understanding of which frames are initialization vs. sendPoll frames
- BLE Header Knowledge: Each frame has an 11-byte BLE header that must be removed
First, process the raw Bluetooth frames by removing the first 11 bytes (BLE header) from each frame:
# Example: if you have "proform_model.c" with raw frames
# Process each frame by removing first 11 bytes
# Separate initialization frames from sendPoll framesKey Requirements:
- Remove exactly 11 bytes from each frame (BLE header)
- Identify the boundary between initialization and sendPoll frames
- Initialization frames come first, sendPoll frames follow
- Document which packet number starts the sendPoll sequence
Add the new model flag to src/devices/proformtreadmill/proformtreadmill.h:
// Add before #ifdef Q_OS_IOS section
bool proform_treadmill_newmodel = false;Update the following files for settings integration:
In src/qzsettings.h:
static const QString proform_treadmill_newmodel;
static constexpr bool default_proform_treadmill_newmodel = false;In src/qzsettings.cpp:
const QString QZSettings::proform_treadmill_newmodel = QStringLiteral("proform_treadmill_newmodel");- Update the
allSettingsCountinqzsettings.cpp
In src/settings.qml:
- Add property at the END of properties list:
property bool proform_treadmill_newmodel: false- Update ComboBox model array:
model: ["Disabled", "Proform New Model", ...]- Add case selection logic (find next available case number):
currentIndex: settings.proform_treadmill_newmodel ? XX : 0;- Add reset logic:
settings.proform_treadmill_newmodel = false;- Add switch case:
case XX: settings.proform_treadmill_newmodel = true; break;In src/devices/proformtreadmill/proformtreadmill.cpp:
- Load Settings (in constructor):
proform_treadmill_newmodel = settings.value(QZSettings::proform_treadmill_newmodel, QZSettings::default_proform_treadmill_newmodel).toBool();- Add Initialization Case (in
btinit()method):
} else if (proform_treadmill_newmodel) {
// ALL initialization frames go here
uint8_t initData1[] = {0x00, 0xfe, 0x02, 0x08, 0x02};
writeCharacteristic(initData1, sizeof(initData1), QStringLiteral("init"), false, true);
// ... continue with ALL init frames from capture file
// Use frames from beginning until sendPoll boundary
}- Add SendPoll Case (in
sendPoll()method):
} else if (proform_treadmill_newmodel) {
switch (counterPoll) {
case 0:
// First sendPoll frame
break;
case 1:
// Second sendPoll frame
break;
// ... continue with pattern from sendPoll frames
default:
// Reset counter and cycle
counterPoll = -1;
break;
}
}- Update Force Functions - Add flag to conditional checks in
forceIncline()andforceSpeed():
} else if (proform_treadmill_8_0 || ... || proform_treadmill_newmodel) {
write[14] = write[11] + write[12] + 0x12;
}- Exactly 11 bytes must be removed from each frame (BLE header)
- All initialization frames must be included in the btinit() case
- All sendPoll frames must be included in the sendPoll() switch statement
- Frame order must be preserved exactly as captured
- Property placement: Always add new properties at the END of the properties list in settings.qml
- Case numbering: Find the next available case number in the ComboBox switch statement
- Naming convention: Use descriptive names following existing patterns
- Initialization: All init frames go in btinit() method
- Communication: All sendPoll frames go in sendPoll() method with switch/case structure
- Force functions: Add new model flag to existing conditional chains
- Problem: Removing wrong number of bytes (12 instead of 11)
- Solution: Always remove exactly 11 bytes (BLE header)
- Problem: Using initialization frames in sendPoll logic
- Solution: Identify exact packet number where sendPoll starts
- Problem: Missing initialization frames
- Solution: Include ALL frames from start until sendPoll boundary
- Problem: Adding property in wrong location in settings.qml
- Solution: Always add at END of properties list
- All 11 bytes removed from each frame
- Initialization frames correctly identified and included
- SendPoll frames correctly identified and implemented
- Settings properly integrated in all required files
- ComboBox updated with new model option
- Force functions updated with new model flag
- Property added at END of settings.qml properties list
The ProForm 995i implementation serves as the reference example:
- 25 initialization frames (pkt4658-pkt4756)
- 33 sendPoll frames (pkt4761-pkt4897)
- 6-case sendPoll switch statement with cycling logic
- Complete settings integration across all required files
- Use Qt Creator for development with proper project file support
- The project uses Qt's signal/slot mechanism extensively
- Device implementations should follow existing patterns for consistency
- Add comprehensive logging using the project's logging framework
- Test device detection thoroughly using the existing test infrastructure
- Consider platform differences when adding new features
When releasing a new version of QDomyos-Zwift, you must update the version number in 3 files:
File: src/android/AndroidManifest.xml
Update both versionName and versionCode:
<manifest ... android:versionName="X.XX.XX" android:versionCode="XXXX" ...>versionName: The human-readable version (e.g., "2.20.26")versionCode: Integer build number that must be incremented (e.g., 1274)
File: src/main.qml
Update the version text displayed in the UI (around line 938):
ItemDelegate {
text: "version X.XX.XX"
width: parent.width
}File: src/qdomyos-zwift.pri
Update the VERSION variable (around line 1011):
VERSION = X.XX.XX- Major.Minor.Patch format (e.g., 2.20.26)
- Build number must always increment, never reuse
- Update all 3 files together to keep versions synchronized
iOS version is managed through Xcode project variables:
MARKETING_VERSIONin project.pbxproj (corresponds to versionName)CURRENT_PROJECT_VERSIONin project.pbxproj (corresponds to versionCode)
These are typically updated via Xcode IDE rather than manually editing files.
- When adding a new setting in QML (setting-tiles.qml), you must:
- Add the property at the END of the properties list