Compare commits

..

39 Commits

Author SHA1 Message Date
Roberto Viola
6e8ff06c18 Update project.pbxproj 2026-02-12 08:10:52 +01:00
Roberto Viola
12019383c0 Update qzsettings.cpp 2026-02-11 15:42:42 +01:00
Roberto Viola
9707f72d5e Merge branch 'master' into Nordictrack-VR21 2026-02-11 15:42:37 +01:00
Roberto Viola
ee31c1a84f Toputure TEB3 2026-02-11 14:15:54 +01:00
Roberto Viola
92f774ff01 Refactor NordicTrack VR21 initialization sequence
Updated NordicTrack VR21 initialization sequence to include additional initialization data and removed redundant code for initialization.
2026-02-11 12:36:44 +01:00
Roberto Viola
78523c3a5e Add PM5 Concept2 protocol support to virtual rower (#4266)
* Add PM5 Concept2 protocol support to virtual rower

Adds a new experimental setting "Virtual Rower as PM5" that enables
the virtual rower to emulate a Concept2 PM5 monitor using the
proprietary PM5 BLE protocol instead of FTMS. This provides
compatibility with apps like Mywhoosh that only support PM5 rowers.

When enabled, the virtual rower:
- Advertises as "PM5 430000000" with Concept2 proprietary service UUIDs
- Implements PM5 Rowing Service (CE060030) with characteristics:
  - General Status (CE060031): elapsed time, distance, workout state
  - Additional Status (CE060032): speed, stroke rate, HR, pace, power
  - Additional Status 2 (CE060033): calories, split data
- Sends data in PM5 format with proper units and encoding

The setting is only visible when "Virtual Rower" is enabled.
Uses OR logic: when PM5 mode is enabled, only PM5 protocol is sent
(FTMS is disabled).

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Add complete PM5 BLE services for better compatibility

Expands PM5 emulation with all required services:
- Generic Access Service (0x1800): device name, appearance, connection params
- PM5 Device Information (CE060010): model, serial, HW/FW revision, manufacturer
- PM5 Control Service (CE060020): CSAFE command receive/transmit
- PM5 Rowing Service (CE060030): additional stroke data characteristics

Also adds Discovery Service UUID (CE060000) to advertising for proper
PM5 device identification by apps like Mywhoosh.

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Fix PM5 advertising on Android and optimize UUID handling

- Add startAdvertisingRowerPM5() method in BleAdvertiser.java that
  uses the PM5 discovery service UUID (CE060000) instead of FTMS
- Call the correct advertising method based on pm5Mode flag
- Reduce advertised UUIDs to only discovery service (128-bit UUIDs
  are large, other services are discovered after connection)

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Remove 128-bit UUID from PM5 advertising to fix visibility

128-bit UUIDs are too large for the BLE advertising packet (31 bytes max).
PM5 will now advertise with device name only. Apps will discover the
PM5 services after establishing a connection.

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Put PM5 UUID in scan response like real PM5 devices

OpenRowingMonitor puts the discovery UUID (CE060000) in the scan
response, not the advertising data. This allows the device name
to fit in the advertising packet while still being discoverable
by PM5-compatible apps.

- Advertising data: device name only
- Scan response: PM5 discovery service UUID (CE060000)

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Add PM5 Stroke Data characteristic with workPerStroke for power

- Add buildPM5StrokeData() function that sends:
  - workPerStroke (Joules) at bytes 16-17 - key for power calculation
  - strokeCount at bytes 18-19 - for cadence tracking
  - driveLength, driveTime, recoveryTime, strokeDistance
  - peakDriveForce, averageDriveForce
- Send Stroke Data characteristic (CE060035) in rowerProvider()
- This should enable Mywhoosh to properly read power from the PM5

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Add PM5 Additional Stroke Data characteristic (CE060036)

- Add buildPM5AdditionalStrokeData() function with:
  - Stroke Power (bytes 3-4) - direct watts value
  - Stroke Calories (bytes 5-6)
  - Stroke Count (bytes 7-8)
  - Work Per Stroke (bytes 15-16) - Joules
- Send Additional Stroke Data in rowerProvider()
- This characteristic has Stroke Power directly which Mywhoosh may need

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Add PM5 Multiplexed Info characteristic (CE060080) support

Some PM5 clients (like Mywhoosh) may only subscribe to the multiplexed
characteristic instead of individual ones. Now sending all data via
CE060080 with proper ID prefixes:
- 0x31: General Status
- 0x32: Additional Status
- 0x33: Additional Status 2
- 0x35: Stroke Data
- 0x36: Additional Stroke Data

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* Add PM5 Concept2 protocol support for iOS Swift virtual rower

- Add PM5 UUIDs and service constants to virtualrower.swift
- Add PM5 services setup (Device Info, Control, Rowing)
- Add PM5 data building functions matching Android implementation:
  - General Status (CE060031)
  - Additional Status (CE060032)
  - Additional Status 2 (CE060033)
  - Stroke Data (CE060035)
  - Additional Stroke Data (CE060036)
  - Multiplexed Info (CE060080)
- Add PM5 mode initialization via constructor parameter
- Update lockscreen.h/mm with virtualrower_ios_pm5() function
- Modify virtualrower.cpp to use PM5 init on iOS when enabled

https://claude.ai/code/session_01XWBgRPWze8Mb7DEEm4oXiF

* fixing ios?

* Update project.pbxproj

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 12:30:30 +01:00
Roberto Viola
4b66c74f08 Update proformbike.cpp 2026-02-11 09:19:32 +01:00
Roberto Viola
9d7a6a8d2d Merge branch 'master' of https://github.com/cagnulein/qdomyos-zwift 2026-02-11 08:42:48 +01:00
Roberto Viola
de78308aba Force USA R3 rower 2026-02-11 08:42:42 +01:00
Roberto Viola
1255d8af04 Update proformbike.cpp 2026-02-11 08:36:35 +01:00
Roberto Viola
2213d3d9b1 Fix double watt_gain application in proformwifibike
Fixed bug where watt_gain was applied twice when reading power from device:
- First application when assigning to m_rawWatt
- Second application when copying to m_watt using m_rawWatt.value()

Changed m_watt = m_rawWatt.value() to m_watt.setValue(m_rawWatt.value(), false)
to prevent reapplying gain/offset transformation.

This bug was introduced in commit 2437c4c (May 19, 2025) which changed from
direct assignment to using m_rawWatt intermediate variable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 21:21:59 +01:00
Roberto Viola
181dbd6d64 feat: Set Domyos treadmill minStepSpeed to 0.1 (#4298) 2026-02-10 19:32:46 +01:00
Roberto Viola
11891f092c Update proformbike.cpp 2026-02-10 14:48:42 +01:00
Roberto Viola
43ad427663 Nordictrack VR21 2026-02-10 14:33:40 +01:00
Roberto Viola
c5b6236de7 Elliptical trainer MRK-E33M 2026-02-10 12:25:38 +01:00
Roberto Viola
b753296632 Mywhoosh compatibility: Add Zwift inclination helper and gear init (#4292) 2026-02-09 19:14:47 +01:00
Roberto Viola
5aa2a310d3 Prevent re-render on interval field updates to maintain focus (#4225)
* Fix workout editor keyboard closing when typing speed #4224

Avoid calling renderIntervals() when speed/pace fields change.
Instead, directly update the synced field's DOM value to keep
focus and keyboard open during input.

https://claude.ai/code/session_01PX2BfeXuZgwAnfHckfS3Gw

* Restore renderIntervals() for pace field changes

Pace field uses 'change' event (fires on blur), so keyboard is already
closed when handler runs. Safe to use renderIntervals() to properly
update the speed field display.

Speed field still uses direct DOM update to keep keyboard open during typing.

https://claude.ai/code/session_01PX2BfeXuZgwAnfHckfS3Gw

* Use 'change' event for number fields to keep keyboard open #4224

Changed speed and other number fields to use 'change' event instead of
'input', matching the behavior of pace and duration fields. This prevents
the keyboard from closing during typing on mobile devices.

The field only updates when the user leaves the input (blur/enter),
which is when the keyboard naturally closes anyway.

https://claude.ai/code/session_01PX2BfeXuZgwAnfHckfS3Gw

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-09 14:35:36 +01:00
Roberto Viola
8816fd105a Revert "Add EW-ST- Bluetooth name prefix for iconsole rower detection (#4289)"
This reverts commit 4fb046d9dc.
2026-02-09 08:18:14 +01:00
Roberto Viola
4fb046d9dc Add EW-ST- Bluetooth name prefix for iconsole rower detection (#4289) 2026-02-08 20:08:00 +01:00
Roberto Viola
1578e25aca Add virtual rower support to cscbike (#4274) 2026-02-08 17:24:28 +01:00
Roberto Viola
bceabd916a Deerun treadmill handle Inclination writing (#4277)
* Deerun treadmill handle Inclination writing

* Update deerruntreadmill.cpp

* Update deerruntreadmill.cpp

* Update deerruntreadmill.cpp

* Update deerruntreadmill.cpp
2026-02-06 16:02:54 +01:00
Roberto Viola
009c806189 Remove garmin_device_serial and garmin_email from debug logs (#4280)
These settings contain sensitive user information and should not be
written to debug logs, similar to how password, token, and username
are already filtered out.

https://claude.ai/code/session_01Bb3K9KzcJGewehcn2x5RXe

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-06 15:03:27 +01:00
Roberto Viola
c22ee74ff4 Fix Garmin Connect IQ INCOMING_MESSAGE crash - v2.20.26 (#4276)
* Fix Garmin Connect IQ INCOMING_MESSAGE crash - v2.20.26

Fixes crash caused by Garmin Connect Mobile app update (v5.20.1, Jan 15, 2026).

The IQMessageReceiverWrapper was not handling the INCOMING_MESSAGE broadcast
action, causing IllegalArgumentException when deserializing IQDevice Parcelable.

Changes:
- Added INCOMING_MESSAGE action handler in IQMessageReceiverWrapper.java
- Updated version to 2.20.26 (build 1274) in all required files:
  * AndroidManifest.xml
  * main.qml
  * qdomyos-zwift.pri
- Updated CLAUDE.md with version update instructions

Error fixed: java.lang.IllegalArgumentException: 8 > -1218333925

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add comprehensive diagnostic logging to Garmin wrapper

This logging will help diagnose the crash by showing:
- All Intent extras and their types
- Parcelable deserialization attempts
- Exact point where exceptions occur
- Full stack traces

This will confirm if the INCOMING_MESSAGE fix is sufficient or if
there are other underlying issues causing the crash.

* ciq library 2.2.0

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 10:34:35 +01:00
Roberto Viola
0772026b7b Add speed and inclination +/- buttons to floating template (#4258)
* Add speed and inclination +/- buttons to floating template

Add new control tiles for speed and inclination in the floating
template (both vertical and horizontal variants). These tiles display
the current value with +/- buttons for adjustment, useful for treadmill
users who want to control speed and incline from the floating overlay.

Changes:
- Add speedcontrol and inclinecontrol tiles to floating.htm and hfloating.htm
- Add SpeedPlus/SpeedMinus and InclinationPlus/InclinationMinus JavaScript functions
- Add WebSocket message handlers (speed_plus, speed_minus, inclination_plus, inclination_minus)
- Add corresponding signals and slots in templateinfosenderbuilder and homeform
- Wire up to existing Plus/Minus functions which handle speed and inclination changes

https://claude.ai/code/session_01QgLyRenM7sSWqqGU1Esy3s

* Integrate speed/inclination buttons into existing rows

Replace AVG/MAX labels with -/+ buttons on speed and inclination rows
instead of having separate control tiles. This provides a cleaner UI
where users can see all metrics (avg, current, max) while also having
control buttons on the same row.

Layout: [-] AVG_VALUE | CURRENT_VALUE | [+] MAX_VALUE

https://claude.ai/code/session_01QgLyRenM7sSWqqGU1Esy3s

* Add AVG/MAX labels under values in speed/inclination rows

Add small labels under the avg and max values so users know what
the values represent. Layout is now:

[-] avg_value | current_value | [+] max_value
      AVG                            MAX

https://claude.ai/code/session_01QgLyRenM7sSWqqGU1Esy3s

* Show speed/inclination +/- buttons only for treadmills

The +/- control buttons on speed and inclination rows are now
only visible when connected to a treadmill device. For other
device types (bike, elliptical, etc.), the standard AVG/MAX
labels are shown instead.

Uses deviceType and TREADMILL_TYPE from workout data to detect
the device type and toggle visibility via CSS classes.

https://claude.ai/code/session_01QgLyRenM7sSWqqGU1Esy3s

* Fix table layout consistency for non-treadmill devices

Wrap the <br> and AVG/MAX label elements together in a span with the
treadmill-only class so they are both hidden for non-treadmill devices.
This ensures the table layout is identical to the original for bikes
and other non-treadmill equipment.

https://claude.ai/code/session_01QgLyRenM7sSWqqGU1Esy3s

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-06 09:08:18 +01:00
Roberto Viola
5bd74260ab Horizon Treadmill FW Update Prevents Connection (Issue #4270) 2026-02-04 13:46:00 +01:00
Roberto Viola
35f7ab636e Update project.pbxproj 2026-02-04 09:45:11 +01:00
Roberto Viola
4d8fd1ce1a 2.20.25 2026-02-04 09:20:09 +01:00
Roberto Viola
b2a28d71e4 DMASUN bike watt HR based 2026-02-04 08:54:21 +01:00
Roberto Viola
2db5683dd2 2.20.24 2026-02-04 08:34:16 +01:00
Roberto Viola
3b81b6d4ee Fix Garmin ConnectIQ crash with malformed messages (#4269)
Fixes app crash when receiving corrupted or malformed messages from
Garmin ConnectIQ devices that cause IllegalArgumentException or
BufferUnderflowException during deserialization.

Changes:
- IQMessageReceiverWrapper: Changed catch block from specific exception
  types to generic Exception to handle all deserialization errors
  including wrapped exceptions
- Garmin: Refactored initialization to create wrapped context BEFORE
  getInstance() call, ensuring ALL BroadcastReceivers registered by
  the SDK (including during getInstance) pass through the wrapper

Root cause: The SDK was receiving the original context in getInstance()
before the wrapper was created, so some receivers bypassed the wrapper's
exception handling. Now the wrapped context is created first and passed
to both getInstance() and initialize().

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 08:31:23 +01:00
Roberto Viola
2e0bd25a4a SunnyFit Stepper (#4245) 2026-02-04 05:00:53 +01:00
Roberto Viola
323c169067 Add average speed to Garmin FIT session fields (#4268) 2026-02-03 22:05:05 +01:00
Roberto Viola
1ff9da34db Fix event propagation in draggable map container (#4227)
* Fix iOS QML scrolling issue when interacting with metrics box

The metrics container drag/resize functionality was causing page scrolling
on iOS within the QML WebView instead of allowing box manipulation.

Changes:
- Add { passive: false } to touchstart event listeners to ensure preventDefault() works on iOS
- Add stopPropagation() to startDrag() and handleMove() to prevent event bubbling
- Add CSS rules to body/html to disable scrolling and overscroll behavior
- Prevent default touch actions on the entire viewport

This ensures that touch events on the metrics box are properly captured
and don't trigger page scrolling on iOS devices.

* Remove overly aggressive CSS that blocked map interaction

The previous fix prevented page scrolling but also disabled
Cesium map interaction (pan, zoom, rotate). The touch-action: none
on metricsContainer combined with passive: false listeners and
stopPropagation() should be sufficient to prevent scrolling only
when interacting with the metrics box.

* Add z-index and chart following functionality for metrics box

Changes:
- Add z-index: 1000 to metricsContainer and 999 to chartContainer to ensure
  they appear above the Cesium map and properly capture touch events
- Add touch-action: none to inner .metrics div and chartContainer for better
  touch event handling on iOS/Android
- Implement updateChartPosition() to make the elevation chart follow the
  metrics box when dragging or resizing
- Chart maintains 10px left offset and bottom alignment with metrics box
- Chart position updates during drag, resize, and on page load

This ensures the metrics box and chart can be interacted with on mobile
devices without interfering with the Cesium map underneath.

* Fix touch event handling to prevent Cesium interference

The previous implementation had critical issues:
1. Global document touchmove listeners were always active, capturing
   all touch events including those on the Cesium map
2. Resize handle detection using e.target was unreliable on touch devices

Changes:
- Remove permanently-active global listeners on document
- Add/remove global listeners dynamically only when drag/resize starts/ends
- Implement isInResizeHandle() function using coordinate-based detection
  instead of e.target checking (checks if touch is in bottom-right 20x20px)
- Consolidate mousedown/touchstart handlers on container to use the same
  coordinate-based logic for determining drag vs resize

This ensures:
- Cesium map remains fully interactive when not touching metrics box
- Resize handle works reliably on touch devices
- No event leakage between UI elements and the map

* Improve touch handling with larger resize handle and better event checks

Changes to fix touch interaction issues:

1. Resize Handle Improvements (50x50px instead of 20x20px):
   - Increased size from 20x20 to 50x50 pixels for easier touch targeting
   - Added visible resize icon (⤡) with better contrast
   - More opaque background for better visibility

2. Event Listener Safety:
   - Added isInsideContainer() check to verify touch starts in container
   - Added early return in handleMove() if not dragging/resizing
   - Always remove listeners in endDragOrResize() to prevent leaks
   - Added touchcancel event handling for system interruptions
   - Fixed logic to check wasResizing before accessing it

3. Better State Management:
   - Always reset isDragging/isResizing before removing listeners
   - Only save state if actually was dragging or resizing
   - Prevents stale listener references

This should fix:
- Difficulty grabbing resize handle on touch devices
- Box moving when interacting with Cesium map
- Event listeners remaining active when they shouldn't

* Update project.pbxproj

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 15:22:13 +01:00
Roberto Viola
361874f1ea Yesoul T3S Plus treadmill 2026-02-02 15:14:52 +01:00
Roberto Viola
33b686bf3e Add weight unit preference for miles users (#4261)
* Add weight unit toggle for UK users (kg with miles)

Adds a new setting "Use kg for weight" that allows users to use
kilograms for weight even when miles is selected for distance.
This is useful for UK users who typically use miles for distance
but kg for body weight.

The toggle appears only when "Use Miles unit in UI" is enabled.
Updated Player Weight, Bike Weight in settings.qml and Wizard.qml.

https://claude.ai/code/session_01B4HhW9pAva8fC7EtgQ8jRo

* Update Wizard.qml

* Update Wizard.qml

* Update settings.qml

* Add weight unit toggle for UK users (kg with miles)

Adds a new setting "Use kg for weight" that allows users to use
kilograms for weight even when miles is selected for distance.
This is useful for UK users who typically use miles for distance
but kg for body weight.

The toggle appears only when "Use Miles unit in UI" is enabled.
Updated Player Weight, Bike Weight in settings.qml and Wizard.qml.

https://claude.ai/code/session_01B4HhW9pAva8fC7EtgQ8jRo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-02 14:21:10 +01:00
Roberto Viola
74e1aba909 horizon gr7 power data bug (Issue #4250) 2026-02-02 12:03:33 +01:00
Roberto Viola
bf75b2bda0 Add Thinkrider VS200 controller support for gear shifting (#4242)
* Add Thinkrider VS200 controller support for gear shifting

Implements support for the Thinkrider VS200 remote controller,
enabling gear up/down functionality similar to Zwift Click.
Uses service UUID 0000fea0 and detects button patterns for
shift up (f3050301fc) and shift down (f3050300fb).

https://claude.ai/code/session_01DK5qQY9wKyHTKfYhAkGECS

* Update allSettingsCount to 857 for thinkrider_controller setting

https://claude.ai/code/session_01DK5qQY9wKyHTKfYhAkGECS

* Update project.pbxproj

* Update project.pbxproj

* Update project.pbxproj

* Add device discovery wait for Thinkrider and create separate settings section

- Add thinkriderDeviceAvaiable() function for discovery wait logic
- Add thinkriderDeviceFound checks in bluetooth constructor and deviceDiscovered
- Create separate "Thinkrider Options" accordion section in settings.qml
- Remove Thinkrider from Zwift Devices Options section

https://claude.ai/code/session_01DK5qQY9wKyHTKfYhAkGECS

* Update project.pbxproj

* Update bluetooth.cpp

* Update project.pbxproj

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-31 21:23:42 +01:00
Roberto Viola
80faa062e1 Add Garmin server selection and debug logging (#4235)
* Add Garmin server selection and debug logging

Introduces a ComboBox in settings to select between global and China Garmin servers, prompting for app restart when changed. Adds debug logging in garminconnect.cpp to trace domain and API URLs, and logs the loaded domain from settings.

* Add verbose debug logging for GarminConnect responses

Introduces a DEBUG_GARMIN_VERBOSE flag to enable detailed logging of HTTP responses and ticket extraction attempts in the GarminConnect authentication flow. This aids in troubleshooting login and MFA issues by providing more insight into response contents and extraction logic.

* Detect MFA via page title and handle CSRF

Instead of scanning the entire response body for "MFA", detect MFA by parsing the HTML <title> (matching the Python garth approach) to avoid false positives from bodies that contain "MFA" text. Extract the page title early, check for "MFA" case-insensitively, and if detected update m_lastError, refresh cookies, extract a new CSRF token using two regex patterns, emit mfaRequired (unless suppressed), and abort the login flow. Also adjust the success check to rely on the title == "Success" and remove the legacy body-based MFA detection block. Added debugging logs for the title, CSRF token, and MFA signal paths.

* Update garminconnect.h

* popup not needed
2026-01-31 20:19:05 +01:00
Roberto Viola
51808cc8a4 Set RepetitionNum to lap_index
In src/qfit.cpp (qfit::save) for JUMPROPE laps, use lap_index when calling lapMesg.SetRepetitionNum instead of session.at(i - 1).inclination. This makes the repetition number reflect the lap index and avoids relying on session data that could be incorrect or out-of-range.
2026-01-31 08:00:07 +01:00
51 changed files with 3674 additions and 763 deletions

View File

@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(tshark:*)",
"Bash(find:*)",
"Bash(python3:*)",
"Bash(git log:*)"
]
}
}

View File

@@ -368,7 +368,55 @@ The ProForm 995i implementation serves as the reference example:
- Test device detection thoroughly using the existing test infrastructure
- Consider platform differences when adding new features
## Updating Version Numbers
When releasing a new version of QDomyos-Zwift, you must update the version number in **3 files**:
### 1. Android Manifest
**File**: `src/android/AndroidManifest.xml`
Update both `versionName` and `versionCode`:
```xml
<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)
### 2. Main QML File
**File**: `src/main.qml`
Update the version text displayed in the UI (around line 938):
```qml
ItemDelegate {
text: "version X.XX.XX"
width: parent.width
}
```
### 3. Qt Project Include File
**File**: `src/qdomyos-zwift.pri`
Update the VERSION variable (around line 1011):
```pri
VERSION = X.XX.XX
```
### Version Numbering Convention
- **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 (Optional)
iOS version is managed through Xcode project variables:
- `MARKETING_VERSION` in project.pbxproj (corresponds to versionName)
- `CURRENT_PROJECT_VERSION` in project.pbxproj (corresponds to versionCode)
These are typically updated via Xcode IDE rather than manually editing files.
## Additional Memories
- When adding a new setting in QML (setting-tiles.qml), you must:
* Add the property at the END of the properties list
* Add the property at the END of the properties list

View File

@@ -557,6 +557,10 @@
87DAE16926E9FF5000B0527E /* moc_shuaa5treadmill.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DAE16626E9FF5000B0527E /* moc_shuaa5treadmill.cpp */; };
87DAE16A26E9FF5000B0527E /* moc_kingsmithr2treadmill.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DAE16726E9FF5000B0527E /* moc_kingsmithr2treadmill.cpp */; };
87DAE16B26E9FF5000B0527E /* moc_solef80treadmill.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DAE16826E9FF5000B0527E /* moc_solef80treadmill.cpp */; };
87DBD5DB2F2CEE1900342F2B /* thinkridercontroller.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DBD5DA2F2CEE1900342F2B /* thinkridercontroller.cpp */; };
87DBD5ED2F2CF22100342F2B /* moc_thinkridercontroller.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DBD5EC2F2CF22100342F2B /* moc_thinkridercontroller.cpp */; };
87DBD6642F333E5700342F2B /* sunnyfitstepper.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DBD6632F333E5700342F2B /* sunnyfitstepper.cpp */; };
87DBD6652F333E5700342F2B /* moc_sunnyfitstepper.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DBD6612F333E5700342F2B /* moc_sunnyfitstepper.cpp */; };
87DC27EA2D9BDB53007A1B9D /* echelonstairclimber.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DC27E72D9BDB53007A1B9D /* echelonstairclimber.cpp */; };
87DC27EB2D9BDB53007A1B9D /* stairclimber.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DC27E92D9BDB53007A1B9D /* stairclimber.cpp */; };
87DC27EE2D9BDB8F007A1B9D /* moc_stairclimber.cpp in Compile Sources */ = {isa = PBXBuildFile; fileRef = 87DC27ED2D9BDB8F007A1B9D /* moc_stairclimber.cpp */; };
@@ -1660,6 +1664,12 @@
87DAE16626E9FF5000B0527E /* moc_shuaa5treadmill.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = moc_shuaa5treadmill.cpp; sourceTree = "<group>"; };
87DAE16726E9FF5000B0527E /* moc_kingsmithr2treadmill.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = moc_kingsmithr2treadmill.cpp; sourceTree = "<group>"; };
87DAE16826E9FF5000B0527E /* moc_solef80treadmill.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = moc_solef80treadmill.cpp; sourceTree = "<group>"; };
87DBD5D92F2CEE1900342F2B /* thinkridercontroller.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = thinkridercontroller.h; path = ../src/devices/thinkridercontroller/thinkridercontroller.h; sourceTree = SOURCE_ROOT; };
87DBD5DA2F2CEE1900342F2B /* thinkridercontroller.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = thinkridercontroller.cpp; path = ../src/devices/thinkridercontroller/thinkridercontroller.cpp; sourceTree = SOURCE_ROOT; };
87DBD5EC2F2CF22100342F2B /* moc_thinkridercontroller.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = moc_thinkridercontroller.cpp; sourceTree = "<group>"; };
87DBD6612F333E5700342F2B /* moc_sunnyfitstepper.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = moc_sunnyfitstepper.cpp; sourceTree = "<group>"; };
87DBD6622F333E5700342F2B /* sunnyfitstepper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = sunnyfitstepper.h; path = ../src/devices/sunnyfitstepper/sunnyfitstepper.h; sourceTree = SOURCE_ROOT; };
87DBD6632F333E5700342F2B /* sunnyfitstepper.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = sunnyfitstepper.cpp; path = ../src/devices/sunnyfitstepper/sunnyfitstepper.cpp; sourceTree = SOURCE_ROOT; };
87DC27E62D9BDB53007A1B9D /* echelonstairclimber.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = echelonstairclimber.h; path = ../src/devices/echelonstairclimber/echelonstairclimber.h; sourceTree = SOURCE_ROOT; };
87DC27E72D9BDB53007A1B9D /* echelonstairclimber.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = echelonstairclimber.cpp; path = ../src/devices/echelonstairclimber/echelonstairclimber.cpp; sourceTree = SOURCE_ROOT; };
87DC27E82D9BDB53007A1B9D /* stairclimber.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = stairclimber.h; path = ../src/devices/stairclimber.h; sourceTree = SOURCE_ROOT; };
@@ -2335,6 +2345,9 @@
2EB56BE3C2D93CDAB0C52E67 /* Sources */ = {
isa = PBXGroup;
children = (
87DBD5EC2F2CF22100342F2B /* moc_thinkridercontroller.cpp */,
87DBD5D92F2CEE1900342F2B /* thinkridercontroller.h */,
87DBD5DA2F2CEE1900342F2B /* thinkridercontroller.cpp */,
87A892572F0C173600811D95 /* sportsplusrower.cpp */,
87A892552F0C12EB00811D95 /* deerruntreadmill.cpp */,
87CBCF0F2EFAA2F8004F5ECE /* garminconnect.h */,
@@ -2883,6 +2896,9 @@
87F1BD652DBFBCE700416506 /* android_antbike.h */,
87F1BD662DBFBCE700416506 /* android_antbike.cpp */,
87F1BD672DBFBCE700416506 /* moc_android_antbike.cpp */,
87DBD6612F333E5700342F2B /* moc_sunnyfitstepper.cpp */,
87DBD6622F333E5700342F2B /* sunnyfitstepper.h */,
87DBD6632F333E5700342F2B /* sunnyfitstepper.cpp */,
);
name = Sources;
sourceTree = "<group>";
@@ -3892,6 +3908,7 @@
87FE5BAF2692F3130056EFC8 /* tacxneo2.cpp in Compile Sources */,
8718CBAC263063CE004BF4EE /* moc_tcpclientinfosender.cpp in Compile Sources */,
873824B527E64707004F1B46 /* moc_provider_p.cpp in Compile Sources */,
87DBD5ED2F2CF22100342F2B /* moc_thinkridercontroller.cpp in Compile Sources */,
87097D2F275EA9A30020EE6F /* sportsplusbike.cpp in Compile Sources */,
333C629F93DB3941862924F7 /* fit_field_base.cpp in Compile Sources */,
87473A9827ECAA0500C203F5 /* moc_proformrower.cpp in Compile Sources */,
@@ -4134,6 +4151,8 @@
87F1BD722DC0D59600416506 /* coresensor.cpp in Compile Sources */,
87DA8467284933DE00B550E9 /* moc_fakeelliptical.cpp in Compile Sources */,
87C5F0D726285E7E0067A1B5 /* moc_mimefile.cpp in Compile Sources */,
87DBD6642F333E5700342F2B /* sunnyfitstepper.cpp in Compile Sources */,
87DBD6652F333E5700342F2B /* moc_sunnyfitstepper.cpp in Compile Sources */,
877FBA29276E684500F6C0C9 /* bowflextreadmill.cpp in Compile Sources */,
877758B62C98629B00BB1697 /* sportstechelliptical.cpp in Compile Sources */,
8762D5102601F7EA00F6F049 /* M3iNSQT.cpp in Compile Sources */,
@@ -4198,6 +4217,7 @@
874D272029AFA11F0007C079 /* apexbike.cpp in Compile Sources */,
8798C8872733E103003148B3 /* strydrunpowersensor.cpp in Compile Sources */,
87C5F0B626285E5F0067A1B5 /* quotedprintable.cpp in Compile Sources */,
87DBD5DB2F2CEE1900342F2B /* thinkridercontroller.cpp in Compile Sources */,
87310B23266FBB78008BA0D6 /* moc_smartrowrower.cpp in Compile Sources */,
EE29228550794460E7654533 /* moc_trxappgateusbtreadmill.cpp in Compile Sources */,
3DB7B5F0CE1E2390CEFFC1E8 /* moc_virtualbike.cpp in Compile Sources */,
@@ -4573,7 +4593,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = NO;
@@ -4774,7 +4794,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = "../src/ios/qdomyos-zwift.entitlements";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEBUG_INFORMATION_FORMAT = dwarf;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
@@ -5011,7 +5031,7 @@
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -5107,7 +5127,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
@@ -5199,7 +5219,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
ENABLE_PREVIEWS = YES;
@@ -5315,7 +5335,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = "watchkit Extension/WatchKit Extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
@@ -5425,7 +5445,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -5516,7 +5536,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1282;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_NS_ASSERTIONS = NO;

View File

@@ -34,6 +34,7 @@ Page {
property string heart_rate_belt_name: "Disabled"
property bool garmin_companion: false
property string filter_device: "Disabled"
property bool weight_kg_unit: false
}
background: Rectangle {
@@ -1181,7 +1182,7 @@ Page {
Text {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Weight (" + (settings.miles_unit ? "lbs" : "kg") + ")")
text: qsTr("Weight (" + ((settings.miles_unit && !settings.weight_kg_unit) ? "lbs" : "kg") + ")")
font.pixelSize: 20
color: "white"
}
@@ -1189,13 +1190,13 @@ Page {
SpinBox {
id: weightSpinBox
Layout.alignment: Qt.AlignHCenter
from: settings.miles_unit ? 660 : 300 // 66.0 lbs or 30.0 kg
to: settings.miles_unit ? 4400 : 2000 // 440.0 lbs or 200.0 kg
value: settings.miles_unit ? (settings.weight * 2.20462 * 10).toFixed(0) : (settings.weight * 10)
from: (settings.miles_unit && !settings.weight_kg_unit) ? 660 : 300 // 66.0 lbs or 30.0 kg
to: (settings.miles_unit && !settings.weight_kg_unit) ? 4400 : 2000 // 440.0 lbs or 200.0 kg
value: (settings.miles_unit && !settings.weight_kg_unit) ? (settings.weight * 2.20462 * 10).toFixed(0) : (settings.weight * 10)
stepSize: 1
editable: true
property real realValue: settings.miles_unit ? value / 22.0462 : value / 10
property real realValue: (settings.miles_unit && !settings.weight_kg_unit) ? value / 22.0462 : value / 10
textFromValue: function(value, locale) {
return Number(value / 10).toLocaleString(locale, 'f', 1)

View File

@@ -1,5 +1,5 @@
<?xml version="1.0"?>
<manifest package="org.cagnulen.qdomyoszwift" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:versionName="2.20.23" android:versionCode="1264" android:installLocation="auto">
<manifest package="org.cagnulen.qdomyoszwift" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:versionName="2.20.26" android:versionCode="1274" android:installLocation="auto">
<!-- The following comment will be replaced upon deployment with default permissions based on the dependencies of the application.
Remove the comment if you do not require these default permissions. -->
<!-- %%INSERT_PERMISSIONS -->

View File

@@ -57,6 +57,7 @@ dependencies {
implementation 'com.jakewharton.timber:timber:5.0.1'
implementation 'org.bouncycastle:bcpkix-jdk15on:1.60'
implementation 'org.bouncycastle:bcprov-jdk15on:1.60'
implementation("com.garmin.connectiq:ciq-companion-app-sdk:2.2.0@aar")
}
import org.apache.tools.ant.taskdefs.condition.Os

View File

@@ -37,6 +37,9 @@ import java.util.UUID;
public class BleAdvertiser {
private static final UUID SERVICE_UUID = UUID.fromString("00001826-0000-1000-8000-00805f9b34fb");
// PM5 Concept2 UUIDs
private static final UUID PM5_DISCOVERY_SERVICE_UUID = UUID.fromString("CE060000-43E5-11E4-916C-0800200C9A66");
private static final UUID PM5_ROWING_SERVICE_UUID = UUID.fromString("CE060030-43E5-11E4-916C-0800200C9A66");
private static final byte[] SERVICE_DATA_ROWER = {0x01, 0x10, 0x00};
private static final byte[] SERVICE_DATA_TREADMILL = {0x01, 0x01, 0x00};
@@ -63,6 +66,36 @@ public class BleAdvertiser {
}
}
public static void startAdvertisingRowerPM5(Context context) {
BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
if (bluetoothManager != null) {
android.bluetooth.le.BluetoothLeAdvertiser advertiser = bluetoothManager.getAdapter().getBluetoothLeAdvertiser();
AdvertiseSettings settings = new AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build();
// PM5 advertising data - device name only (to save space)
// Full name "PM5 430000000" is set via Bluetooth adapter
AdvertiseData advertiseData = new AdvertiseData.Builder()
.setIncludeDeviceName(true)
.build();
// Scan response contains the PM5 discovery service UUID (CE060000)
// This is how real PM5 devices advertise - UUID in scan response
AdvertiseData scanResponse = new AdvertiseData.Builder()
.addServiceUuid(new ParcelUuid(PM5_DISCOVERY_SERVICE_UUID))
.build();
if (advertiser != null) {
QLog.d("BleAdvertiser", "Starting PM5 advertising with scan response UUID: " + PM5_DISCOVERY_SERVICE_UUID.toString());
advertiser.startAdvertising(settings, advertiseData, scanResponse, advertiseCallback);
}
}
}
public static void startAdvertisingTreadmill(Context context) {
BluetoothManager bluetoothManager = (BluetoothManager) context.getSystemService(Context.BLUETOOTH_SERVICE);
if (bluetoothManager != null) {

View File

@@ -82,10 +82,13 @@ public class Garmin {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
connectIQ = ConnectIQ.getInstance(c, ConnectIQ.IQConnectType.WIRELESS);
// Create wrapped context BEFORE getInstance to ensure all SDK operations use it
context = createWrappedContext(c);
connectIQ = ConnectIQ.getInstance(context, ConnectIQ.IQConnectType.WIRELESS);
// init a wrapped SDK with fix for "Cannot cast to Long" issue viz https://forums.garmin.com/forum/developers/connect-iq/connect-iq-bug-reports/158068-?p=1278464#post1278464
context = initializeConnectIQWrapped(c, connectIQ, false, new ConnectIQ.ConnectIQListener() {
initializeConnectIQWithContext(connectIQ, false, new ConnectIQ.ConnectIQListener() {
@Override
public void onInitializeError(ConnectIQ.IQSdkErrorStatus errStatus) {
@@ -158,12 +161,8 @@ public class Garmin {
connectIQ.sendMessage(getDevice(), getApp(), message, listener);
}
private static Context initializeConnectIQWrapped(Context context, ConnectIQ connectIQ, boolean autoUI, ConnectIQ.ConnectIQListener listener) {
if (connectIQ instanceof ConnectIQAdbStrategy) {
connectIQ.initialize(context, autoUI, listener);
return context;
}
Context wrappedContext = new ContextWrapper(context) {
private static Context createWrappedContext(Context context) {
return new ContextWrapper(context) {
private HashMap<BroadcastReceiver, BroadcastReceiver> receiverToWrapper = new HashMap<>();
@Override
@@ -190,6 +189,18 @@ public class Garmin {
if (wrappedReceiver != null) super.unregisterReceiver(wrappedReceiver);
}
};
}
private static void initializeConnectIQWithContext(ConnectIQ connectIQ, boolean autoUI, ConnectIQ.ConnectIQListener listener) {
connectIQ.initialize(context, autoUI, listener);
}
private static Context initializeConnectIQWrapped(Context context, ConnectIQ connectIQ, boolean autoUI, ConnectIQ.ConnectIQListener listener) {
if (connectIQ instanceof ConnectIQAdbStrategy) {
connectIQ.initialize(context, autoUI, listener);
return context;
}
Context wrappedContext = createWrappedContext(context);
connectIQ.initialize(wrappedContext, autoUI, listener);
return wrappedContext;
}

View File

@@ -20,32 +20,73 @@ public class IQMessageReceiverWrapper extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
QLog.d(TAG, "onReceive intent " + intent.getAction());
if ("com.garmin.android.connectiq.SEND_MESSAGE_STATUS".equals(intent.getAction())) {
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE");
} else if ("com.garmin.android.connectiq.OPEN_APPLICATION".equals(intent.getAction())) {
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_OPEN_APPLICATION_DEVICE");
} else if ("com.garmin.android.connectiq.DEVICE_STATUS".equals(intent.getAction())) {
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE");
}
try {
QLog.d(TAG, "=== GARMIN INTENT DEBUG START ===");
QLog.d(TAG, "Action: " + intent.getAction());
// Log all extras in the intent
if (intent.getExtras() != null) {
QLog.d(TAG, "Extras bundle: " + intent.getExtras());
try {
for (String key : intent.getExtras().keySet()) {
Object value = intent.getExtras().get(key);
QLog.d(TAG, " Extra[" + key + "] = " + value + " (type: " + (value != null ? value.getClass().getName() : "null") + ")");
}
} catch (Exception e) {
QLog.e(TAG, "Error iterating extras: " + e.toString());
}
} else {
QLog.d(TAG, "No extras in intent");
}
// Process known actions
if ("com.garmin.android.connectiq.SEND_MESSAGE_STATUS".equals(intent.getAction())) {
QLog.d(TAG, "Processing SEND_MESSAGE_STATUS");
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE");
} else if ("com.garmin.android.connectiq.OPEN_APPLICATION".equals(intent.getAction())) {
QLog.d(TAG, "Processing OPEN_APPLICATION");
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_OPEN_APPLICATION_DEVICE");
} else if ("com.garmin.android.connectiq.DEVICE_STATUS".equals(intent.getAction())) {
QLog.d(TAG, "Processing DEVICE_STATUS");
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE");
} else if ("com.garmin.android.connectiq.INCOMING_MESSAGE".equals(intent.getAction())) {
QLog.d(TAG, "Processing INCOMING_MESSAGE");
replaceIQDeviceById(intent, "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE");
} else {
QLog.d(TAG, "Unknown action, no processing");
}
QLog.d(TAG, "Calling wrapped receiver.onReceive()");
receiver.onReceive(context, intent);
} catch (IllegalArgumentException | BufferUnderflowException e) {
QLog.d(TAG, e.toString());
QLog.d(TAG, "=== GARMIN INTENT DEBUG END (success) ===");
} catch (Exception e) {
QLog.e(TAG, "=== EXCEPTION in wrapper (BEFORE or DURING receiver call) ===");
QLog.e(TAG, "Exception type: " + e.getClass().getName());
QLog.e(TAG, "Exception message: " + e.getMessage());
QLog.e(TAG, "Stack trace:", e);
QLog.e(TAG, "=== GARMIN INTENT DEBUG END (error) ===");
}
}
private static void replaceIQDeviceById(Intent intent, String extraName) {
try {
QLog.d(TAG, " Attempting to get Parcelable for extra: " + extraName);
IQDevice device = intent.getParcelableExtra(extraName);
if (device != null) {
// Logger.logDebug("Replacing " + device.describeContents() + " " + device.getFriendlyName() + " by " + device.getDeviceIdentifier() );
intent.putExtra(extraName, device.getDeviceIdentifier());
QLog.d(TAG, " Found IQDevice: " + device.getFriendlyName() + " (ID: " + device.getDeviceIdentifier() + ")");
long deviceId = device.getDeviceIdentifier();
intent.putExtra(extraName, deviceId);
QLog.d(TAG, " Replaced IQDevice with Long ID: " + deviceId);
} else {
QLog.d(TAG, " Extra '" + extraName + "' is null or not an IQDevice");
}
} catch (ClassCastException e) {
QLog.d(TAG, e.toString());
// It's already a long, i.e. on the simulator.
QLog.d(TAG, " ClassCastException for '" + extraName + "': " + e.toString());
QLog.d(TAG, " (Extra is already a Long, probably on simulator)");
} catch (Exception e) {
QLog.e(TAG, " Unexpected exception in replaceIQDeviceById: " + e.toString());
QLog.e(TAG, " Stack trace:", e);
}
}

View File

@@ -201,12 +201,15 @@ void bluetooth::finished() {
bool sramDeviceFound = !settings.value(QZSettings::sram_axs_controller, QZSettings::default_sram_axs_controller).toBool();
bool thinkriderDeviceFound = !settings.value(QZSettings::thinkrider_controller, QZSettings::default_thinkrider_controller).toBool();
if ((!heartRateBeltFound && !heartRateBeltAvaiable()) || (!ftmsAccessoryFound && !ftmsAccessoryAvaiable()) ||
(!cscFound && !cscSensorAvaiable()) || (!powerSensorFound && !powerSensorAvaiable()) ||
(!eliteRizerFound && !eliteRizerAvaiable()) || (!eliteSterzoSmartFound && !eliteSterzoSmartAvaiable()) ||
(!fitmetriaFanfitFound && !fitmetriaFanfitAvaiable()) ||
(!zwiftDeviceFound && !zwiftDeviceAvaiable()) ||
(!sramDeviceFound && !sramDeviceAvaiable())) {
(!sramDeviceFound && !sramDeviceAvaiable()) ||
(!thinkriderDeviceFound && !thinkriderDeviceAvaiable())) {
// force heartRateBelt off
forceHeartBeltOffForTimeout = true;
@@ -336,6 +339,16 @@ bool bluetooth::sramDeviceAvaiable() {
return false;
}
bool bluetooth::thinkriderDeviceAvaiable() {
Q_FOREACH (QBluetoothDeviceInfo b, devices) {
if (b.name().toUpper().startsWith("THINK VS") || b.name().toUpper().startsWith("THINKRIDER")) {
return true;
}
}
return false;
}
bool bluetooth::powerSensorAvaiable() {
@@ -437,6 +450,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
bool sramDeviceFound = !settings.value(QZSettings::sram_axs_controller, QZSettings::default_sram_axs_controller).toBool();
bool zwiftDeviceFound =
!settings.value(QZSettings::zwift_click, QZSettings::default_zwift_click).toBool() && !settings.value(QZSettings::zwift_play, QZSettings::default_zwift_play).toBool();
bool thinkriderDeviceFound = !settings.value(QZSettings::thinkrider_controller, QZSettings::default_thinkrider_controller).toBool();
bool fitmetriaFanfitFound =
!settings.value(QZSettings::fitmetria_fanfit_enable, QZSettings::default_fitmetria_fanfit_enable).toBool();
bool toorx_ftms = settings.value(QZSettings::toorx_ftms, QZSettings::default_toorx_ftms).toBool();
@@ -549,6 +563,10 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
sramDeviceFound = sramDeviceAvaiable();
}
if(!thinkriderDeviceFound) {
thinkriderDeviceFound = thinkriderDeviceAvaiable();
}
if (!ftmsAccessoryFound) {
ftmsAccessoryFound = ftmsAccessoryAvaiable();
@@ -681,7 +699,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
#endif
bool searchDevices = (heartRateBeltFound && ftmsAccessoryFound && cscFound && powerSensorFound && eliteRizerFound &&
eliteSterzoSmartFound && fitmetriaFanfitFound && zwiftDeviceFound) ||
eliteSterzoSmartFound && fitmetriaFanfitFound && zwiftDeviceFound && sramDeviceFound && thinkriderDeviceFound) ||
forceHeartBeltOffForTimeout;
if (searchDevices) {
@@ -1108,6 +1126,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
b.name().toUpper().startsWith(QStringLiteral("SCH_590E")) ||
b.name().toUpper().startsWith(QStringLiteral("SCH411/510E")) ||
b.name().toUpper().startsWith(QStringLiteral("KETTLER ")) ||
b.name().toUpper().startsWith(QStringLiteral("MRK-E")) ||
b.name().toUpper().startsWith(QStringLiteral("FEIER-EM-")) ||
b.name().toUpper().startsWith(QStringLiteral("MX-AS ")) ||
(b.name().startsWith(QStringLiteral("Domyos-EL")) && settings.value(QZSettings::domyos_elliptical_fmts, QZSettings::default_domyos_elliptical_fmts).toBool()) ||
@@ -1486,6 +1505,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
this->signalBluetoothDeviceConnected(lifefitnessTreadmill);
} else if ((b.name().toUpper().startsWith(QStringLiteral("HORIZON")) ||
b.name().toUpper().startsWith(QStringLiteral("HZ_T101-")) ||
b.name().toUpper().startsWith(QStringLiteral("HZ_7.0AT-")) ||
b.name().toUpper().startsWith(QStringLiteral("AFG SPORT")) ||
b.name().toUpper().startsWith(QStringLiteral("WLT2541")) ||
(b.name().toUpper().startsWith(QStringLiteral("TREADMILL")) && (gem_module_inclination || deviceHasService(b, QBluetoothUuid((quint16)0x1826)))) ||
@@ -1520,7 +1540,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
b.name().toUpper().startsWith(QStringLiteral("TM4500")) ||
b.name().toUpper().startsWith(QStringLiteral("TM6500")) ||
b.name().toUpper().startsWith(QStringLiteral("RUNN ")) ||
b.name().toUpper().startsWith(QStringLiteral("YS_T1MPLUST")) ||
b.name().toUpper().startsWith(QStringLiteral("YS_T")) ||
b.name().toUpper().startsWith(QStringLiteral("YPOO-MINI PRO-")) ||
b.name().toUpper().startsWith(QStringLiteral("BFX_T")) ||
(b.name().toUpper().startsWith("3G PRO ")) ||
@@ -1824,6 +1844,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
(b.name().toUpper().startsWith("VFSPINBIKE")) ||
(b.name().toUpper().startsWith("RIVO COG")) ||
(b.name().toUpper().startsWith("RAVE")) ||
(b.name().toUpper().startsWith("TOPUTURE-")) ||
(b.name().toUpper().startsWith("BESP-")) || // FITFIU BESP 250 indoor bike
(b.name().toUpper().startsWith("GLT") && deviceHasService(b, QBluetoothUuid((quint16)0x1826))) ||
(b.name().toUpper().startsWith("SPORT01-") && deviceHasService(b, QBluetoothUuid((quint16)0x1826))) || // Labgrey Magnetic Exercise Bike https://www.amazon.co.uk/dp/B0CXMF1NPY?_encoding=UTF8&psc=1&ref=cm_sw_r_cp_ud_dp_PE420HA7RD7WJBZPN075&ref_=cm_sw_r_cp_ud_dp_PE420HA7RD7WJBZPN075&social_share=cm_sw_r_cp_ud_dp_PE420HA7RD7WJBZPN075&skipTwisterOG=1
@@ -1836,8 +1857,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
!b.name().compare(ftms_bike, Qt::CaseInsensitive) || (b.name().toUpper().startsWith("SMB1")) ||
(b.name().toUpper().startsWith("UBIKE FTMS")) || (b.name().toUpper().startsWith("INRIDE")) ||
(b.name().toUpper().startsWith("INCONDI")) || // inCondi S150i
(b.name().toUpper().startsWith("YPBM") && b.name().length() == 10) ||
(b.name().toUpper().startsWith("JFICCYCLE"))) &&
(b.name().toUpper().startsWith("YPBM") && b.name().length() == 10)) &&
ftms_rower.contains(QZSettings::default_ftms_rower) &&
!ftmsBike && !ftmsRower && !snodeBike && !fitPlusBike && !stagesBike && filter) {
this->setLastBluetoothDevice(b);
@@ -1985,6 +2005,7 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
b.name().toUpper().startsWith(QStringLiteral("ROWER ")) ||
b.name().toUpper().startsWith(QStringLiteral("ROGUE CONSOLE ")) ||
b.name().toUpper().startsWith(QStringLiteral("DFIT-L-R")) ||
b.name().toUpper().startsWith(QStringLiteral("EW-ST-")) || // EW-ST-3739 Force USA R3 rower
!b.name().compare(ftms_rower, Qt::CaseInsensitive) ||
(b.name().toUpper().startsWith(QStringLiteral("PM5")) &&
b.name().toUpper().endsWith(QStringLiteral("ROW")))) &&
@@ -2016,6 +2037,19 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
connect(echelonStairclimber, &echelonstairclimber::inclinationChanged, this, &bluetooth::inclinationChanged);
echelonStairclimber->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(echelonStairclimber);
} else if (b.name().toUpper().startsWith(QLatin1String("SF-S")) &&
!sunnyfitStepper && filter) {
this->setLastBluetoothDevice(b);
this->stopDiscovery();
sunnyfitStepper = new sunnyfitstepper(this->pollDeviceTime, noConsole, noHeartService);
emit deviceConnected(b);
connect(sunnyfitStepper, &bluetoothdevice::connectedAndDiscovered, this,
&bluetooth::connectedAndDiscovered);
connect(sunnyfitStepper, &sunnyfitstepper::debug, this, &bluetooth::debug);
connect(sunnyfitStepper, &sunnyfitstepper::speedChanged, this, &bluetooth::speedChanged);
connect(sunnyfitStepper, &sunnyfitstepper::inclinationChanged, this, &bluetooth::inclinationChanged);
sunnyfitStepper->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(sunnyfitStepper);
} else if ((b.name().toUpper().startsWith(QLatin1String("ECH-STRIDE")) ||
b.name().toUpper().startsWith(QLatin1String("ECH-UK-")) ||
b.name().toUpper().startsWith(QLatin1String("ECH-FR-")) ||
@@ -2094,15 +2128,6 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
connect(lifespanTreadmill, &lifespantreadmill::inclinationChanged, this, &bluetooth::inclinationChanged);
lifespanTreadmill->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(lifespanTreadmill);
} else if (b.name().startsWith(QStringLiteral("AT-R")) && !mobiRower && filter) {
this->setLastBluetoothDevice(b);
this->stopDiscovery();
mobiRower = new mobirower(noWriteResistance, noHeartService);
emit deviceConnected(b);
connect(mobiRower, &bluetoothdevice::connectedAndDiscovered, this,
&bluetooth::connectedAndDiscovered);
mobiRower->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(mobiRower);
} else if ((b.name().toUpper().startsWith(QStringLiteral("ECH-ROW")) ||
b.name().toUpper().startsWith(QStringLiteral("ROWSPORT")) ||
b.name().toUpper().startsWith(QStringLiteral("ROW-S"))) &&
@@ -3124,6 +3149,24 @@ void bluetooth::connectedAndDiscovered() {
}
}
if(settings.value(QZSettings::thinkrider_controller, QZSettings::default_thinkrider_controller).toBool()) {
for (const QBluetoothDeviceInfo &b : qAsConst(devices)) {
if (((b.name().toUpper().startsWith("THINK VS")) || (b.name().toUpper().startsWith("THINKRIDER"))) && !thinkriderController && this->device() &&
this->device()->deviceType() == BIKE) {
thinkriderController = new thinkridercontroller(this->device());
connect(thinkriderController, &thinkridercontroller::debug, this, &bluetooth::debug);
connect(thinkriderController, &thinkridercontroller::plus, (bike*)this->device(), &bike::gearUp);
connect(thinkriderController, &thinkridercontroller::minus, (bike*)this->device(), &bike::gearDown);
thinkriderController->deviceDiscovered(b);
if(homeform::singleton())
homeform::singleton()->setToastRequested("Thinkrider Controller Connected!");
break;
}
}
}
if(settings.value(QZSettings::zwift_play, QZSettings::default_zwift_play).toBool()) {
for (const QBluetoothDeviceInfo &b : qAsConst(devices)) {
if (((b.name().toUpper().startsWith("SQUARE"))) && !eliteSquareController && this->device() &&
@@ -3614,11 +3657,6 @@ void bluetooth::restart() {
delete echelonRower;
echelonRower = nullptr;
}
if (mobiRower) {
delete mobiRower;
mobiRower = nullptr;
}
if (echelonStride) {
delete echelonStride;
@@ -3629,6 +3667,11 @@ void bluetooth::restart() {
delete echelonStairclimber;
echelonStairclimber = nullptr;
}
if (sunnyfitStepper) {
delete sunnyfitStepper;
sunnyfitStepper = nullptr;
}
if (octaneTreadmill) {
delete octaneTreadmill;
@@ -4054,12 +4097,12 @@ bluetoothdevice *bluetooth::device() {
return echelonConnectSport;
} else if (echelonRower) {
return echelonRower;
} else if (mobiRower) {
return mobiRower;
} else if (echelonStride) {
return echelonStride;
} else if (echelonStairclimber) {
return echelonStairclimber;
} else if (sunnyfitStepper) {
return sunnyfitStepper;
} else if (octaneTreadmill) {
return octaneTreadmill;
} else if (ziproTreadmill) {

View File

@@ -112,6 +112,7 @@
#include "signalhandler.h"
#include "devices/skandikawiribike/skandikawiribike.h"
#include "devices/smartrowrower/smartrowrower.h"
#include "devices/sunnyfitstepper/sunnyfitstepper.h"
#include "devices/smartspin2k/smartspin2k.h"
#include "devices/snodebike/snodebike.h"
#include "devices/strydrunpowersensor/strydrunpowersensor.h"
@@ -154,7 +155,7 @@
#include "zwift_play/zwiftPlayDevice.h"
#include "zwift_play/zwiftclickremote.h"
#include "devices/mobirower/mobirower.h"
#include "devices/thinkridercontroller/thinkridercontroller.h"
#ifdef Q_OS_IOS
#include "ios/lockscreen.h"
@@ -270,7 +271,7 @@ class bluetooth : public QObject, public SignalHandler {
echelonrower *echelonRower = nullptr;
ftmsrower *ftmsRower = nullptr;
smartrowrower *smartrowRower = nullptr;
mobirower *mobiRower = nullptr;
sunnyfitstepper *sunnyfitStepper = nullptr;
echelonstride *echelonStride = nullptr;
echelonstairclimber *echelonStairclimber = nullptr;
lifefitnesstreadmill *lifefitnessTreadmill = nullptr;
@@ -308,6 +309,7 @@ class bluetooth : public QObject, public SignalHandler {
QList<eliteariafan *> eliteAriaFan;
QList<zwiftclickremote* > zwiftPlayDevice;
zwiftclickremote* zwiftClickRemote = nullptr;
thinkridercontroller* thinkriderController = nullptr;
sramaxscontroller* sramAXSController = nullptr;
elitesquarecontroller* eliteSquareController = nullptr;
QString filterDevice = QLatin1String("");
@@ -345,6 +347,7 @@ class bluetooth : public QObject, public SignalHandler {
bool fitmetriaFanfitAvaiable();
bool zwiftDeviceAvaiable();
bool sramDeviceAvaiable();
bool thinkriderDeviceAvaiable();
bool fitmetria_fanfit_isconnected(QString name);
#ifdef Q_OS_WIN

View File

@@ -1,5 +1,6 @@
#include "cscbike.h"
#include "virtualdevices/virtualbike.h"
#include "virtualdevices/virtualrower.h"
#include <QBluetoothLocalDevice>
#include <QDateTime>
#include <QFile>
@@ -459,6 +460,8 @@ void cscbike::stateChanged(QLowEnergyService::ServiceState state) {
QSettings settings;
bool virtual_device_enabled =
settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool();
bool virtual_device_rower =
settings.value(QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower).toBool();
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
bool cadence =
@@ -473,11 +476,17 @@ void cscbike::stateChanged(QLowEnergyService::ServiceState state) {
#endif
#endif
if (virtual_device_enabled) {
emit debug(QStringLiteral("creating virtual bike interface..."));
auto virtualBike = new virtualbike(this, noWriteResistance, noHeartService);
connect(virtualBike, &virtualbike::changeInclination, this, &cscbike::changeInclination);
// connect(virtualBike,&virtualbike::debug ,this,&cscbike::debug);
this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY);
if (virtual_device_rower) {
emit debug(QStringLiteral("creating virtual rower interface..."));
auto virtualRower = new virtualrower(this, noWriteResistance, noHeartService);
this->setVirtualDevice(virtualRower, VIRTUAL_DEVICE_MODE::ALTERNATIVE);
} else {
emit debug(QStringLiteral("creating virtual bike interface..."));
auto virtualBike = new virtualbike(this, noWriteResistance, noHeartService);
connect(virtualBike, &virtualbike::changeInclination, this, &cscbike::changeInclination);
// connect(virtualBike,&virtualbike::debug ,this,&cscbike::debug);
this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY);
}
}
}
firstStateChanged = 1;

View File

@@ -160,22 +160,24 @@ uint8_t deerruntreadmill::calculatePitPatChecksum(uint8_t arr[], size_t size) {
}
void deerruntreadmill::forceSpeed(double requestSpeed) {
void deerruntreadmill::forceSpeedAndInclination(double requestSpeed, double requestInclination) {
QSettings settings;
if (pitpat) {
// PitPat speed template
// Pattern: 6a 17 00 00 00 00 [speed_high] [speed_low] 01 00 8a 00 04 00 00 00 00 00 12 2e 0c [checksum] 43
// Speed encoding: speed value * 1000 (e.g., 2.0 km/h = 2000 = 0x07d0)
uint8_t writeSpeed[] = {0x6a, 0x17, 0x00, 0x00, 0x00, 0x00, 0x07, 0x6c, 0x01, 0x00, 0x8a, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x2e, 0x0c, 0xc3, 0x43};
uint8_t writeSpeed[] = {0x6a, 0x17, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x01, 0x08, 0x64, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x16, 0x7a, 0x67, 0x96, 0x43};
uint16_t speed = (uint16_t)(requestSpeed * 1000.0);
uint16_t incline = (uint16_t)(requestInclination);
writeSpeed[6] = (speed >> 8) & 0xFF; // High byte
writeSpeed[7] = speed & 0xFF; // Low byte
writeSpeed[9] = incline & 0xFF; // Low byte
writeSpeed[21] = calculatePitPatChecksum(writeSpeed, sizeof(writeSpeed)); // Checksum at byte 21
writeCharacteristic(gattWriteCharacteristic, writeSpeed, sizeof(writeSpeed),
QStringLiteral("forceSpeed PitPat speed=") + QString::number(requestSpeed), false, true);
QStringLiteral("forceSpeed PitPat speed=") + QString::number(requestSpeed) + QStringLiteral(" incline=") + QString::number(requestInclination), false, true);
} else if (superun_ba04) {
// Superun BA04 speed template
uint8_t writeSpeed[] = {0x4d, 0x00, 0x14, 0x17, 0x6a, 0x17, 0x00, 0x00, 0x00, 0x00, 0x04, 0x4c, 0x01, 0x00, 0x50, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0xb5, 0x7c, 0xdb, 0x43};
@@ -201,8 +203,12 @@ void deerruntreadmill::forceSpeed(double requestSpeed) {
}
}
void deerruntreadmill::forceIncline(double requestIncline) {
void deerruntreadmill::forceSpeed(double requestSpeed) {
forceSpeedAndInclination(requestSpeed, currentInclination().value());
}
void deerruntreadmill::forceIncline(double requestIncline) {
forceSpeedAndInclination(currentSpeed().value(), requestIncline);
}
void deerruntreadmill::changeInclinationRequested(double grade, double percentage) {
@@ -385,6 +391,9 @@ void deerruntreadmill::characteristicChanged(const QLowEnergyCharacteristic &cha
speed = ((double)((value[3] << 8) | ((uint8_t)value[4])) / 1000.0);
}
double incline = 0.0;
if(pitpat) {
incline = (double)(value[11] & 0xFF);
}
#ifdef Q_OS_ANDROID
if (settings.value(QZSettings::ant_heart, QZSettings::default_ant_heart).toBool())

View File

@@ -46,6 +46,7 @@ class deerruntreadmill : public treadmill {
private:
void forceSpeed(double requestSpeed);
void forceIncline(double requestIncline);
void forceSpeedAndInclination(double requestSpeed, double requestInclination);
void btinit(bool startTape);
void writeCharacteristic(const QLowEnergyCharacteristic characteristic, uint8_t *data, uint8_t data_len,
const QString &info, bool disable_log = false, bool wait_for_response = false);

View File

@@ -983,3 +983,5 @@ bool domyostreadmill::connected() {
}
void domyostreadmill::searchingStop() { searchStopped = true; }
double domyostreadmill::minStepSpeed() { return 0.1; }

View File

@@ -42,6 +42,7 @@ class domyostreadmill : public treadmill {
double forceInitSpeed = 0.0, double forceInitInclination = 0.0);
bool connected() override;
bool changeFanSpeed(uint8_t speed) override;
double minStepSpeed() override;
private:
// Structure for async write queue

View File

@@ -297,6 +297,42 @@ void ftmsbike::forceInclination(double requestInclination) {
QStringLiteral("forceInclination ") + QString::number(requestInclination));
}
void ftmsbike::sendZwiftPlayInclination(double inclination) {
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
QByteArray message = lockscreen::zwift_hub_inclinationCommand(inclination);
#else
QByteArray message;
#endif
#elif defined(Q_OS_ANDROID)
QAndroidJniObject result = QAndroidJniObject::callStaticObjectMethod(
"org/cagnulen/qdomyoszwift/ZwiftHubBike",
"inclinationCommand",
"(D)[B",
inclination);
if(!result.isValid()) {
qDebug() << "inclinationCommand returned invalid value";
return;
}
jbyteArray array = result.object<jbyteArray>();
QAndroidJniEnvironment env;
jbyte* bytes = env->GetByteArrayElements(array, nullptr);
jsize length = env->GetArrayLength(array);
QByteArray message((char*)bytes, length);
env->ReleaseByteArrayElements(array, bytes, JNI_ABORT);
#else
QByteArray message;
qDebug() << "implement zwift hub protobuf!";
return;
#endif
writeCharacteristicZwiftPlay((uint8_t*)message.data(), message.length(), "gearInclination", false, false);
gearInclinationSent = true;
}
void ftmsbike::update() {
QSettings settings;
@@ -387,18 +423,24 @@ void ftmsbike::update() {
}
if(zwiftPlayService && gears_zwift_ratio && lastGearValue != gears()) {
// Workaround: gear commands don't work until an inclination command has been sent first
if (!gearInclinationSent) {
qDebug() << "Sending initial inclination command (0.4%) before first gear command";
sendZwiftPlayInclination(0.4);
}
QSettings settings;
wheelCircumference::GearTable table;
wheelCircumference::GearTable::GearInfo g = table.getGear((int)gears());
double original_ratio = ((double)settings.value(QZSettings::gear_crankset_size, QZSettings::default_gear_crankset_size).toDouble()) /
((double)settings.value(QZSettings::gear_cog_size, QZSettings::default_gear_cog_size).toDouble());
double current_ratio = ((double)g.crankset / (double)g.rearCog);
uint32_t gear_value = static_cast<uint32_t>(10000.0 * (current_ratio/original_ratio) * (42.0/14.0));
qDebug() << "zwift hub gear current ratio" << current_ratio << g.crankset << g.rearCog << "gear_value" << gear_value << "original_ratio" << original_ratio;
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
QByteArray proto = lockscreen::zwift_hub_setGearsCommand(gear_value);
@@ -753,11 +795,7 @@ void ftmsbike::characteristicChanged(const QLowEnergyCharacteristic &characteris
} else if (MRK_S26C) {
m_watt = Cadence.value() * (Resistance.value() * 1.16);
emit debug(QStringLiteral("Current Watt (MRK-S26C formula): ") + QString::number(m_watt.value()));
} else if (JFICCYCLE) {
// JFICCYCLE sends power but always at 0, so calculate from cadence or heart rate
m_watt = wattFromHR(true);
emit debug(QStringLiteral("Current Watt (JFICCYCLE calculated): ") + QString::number(m_watt.value()));
} else if (LYDSTO && watt_ignore_builtin) {
} else if ((LYDSTO || DMASUN) && watt_ignore_builtin) {
m_watt = wattFromHR(true);
emit debug(QStringLiteral("Current Watt: ") + QString::number(m_watt.value()));
} else {
@@ -1533,39 +1571,7 @@ void ftmsbike::ftmsCharacteristicChanged(const QLowEnergyCharacteristic &charact
} else if(b.at(0) == FTMS_SET_INDOOR_BIKE_SIMULATION_PARAMS && zwiftPlayService != nullptr && gears_zwift_ratio) {
int16_t slope = (((uint8_t)b.at(3)) + (b.at(4) << 8));
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
QByteArray message = lockscreen::zwift_hub_inclinationCommand(((double)slope) / 100.0);
#else
QByteArray message;
#endif
#elif defined(Q_OS_ANDROID)
QAndroidJniObject result = QAndroidJniObject::callStaticObjectMethod(
"org/cagnulen/qdomyoszwift/ZwiftHubBike",
"inclinationCommand",
"(D)[B",
((double)slope) / 100.0);
if(!result.isValid()) {
qDebug() << "inclinationCommand returned invalid value";
return;
}
jbyteArray array = result.object<jbyteArray>();
QAndroidJniEnvironment env;
jbyte* bytes = env->GetByteArrayElements(array, nullptr);
jsize length = env->GetArrayLength(array);
QByteArray message((char*)bytes, length);
env->ReleaseByteArrayElements(array, bytes, JNI_ABORT);
#else
QByteArray message;
qDebug() << "implement zwift hub protobuf!";
return;
#endif
writeCharacteristicZwiftPlay((uint8_t*)message.data(), message.length(), "gearInclination", false, false);
sendZwiftPlayInclination(((double)slope) / 100.0);
return;
} else if(b.at(0) == FTMS_SET_TARGET_POWER && !ergModeSupported) {
qDebug() << "discarding";
@@ -1751,6 +1757,9 @@ void ftmsbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
} else if ((bluetoothDevice.name().toUpper().startsWith("LYDSTO"))) {
qDebug() << QStringLiteral("LYDSTO found");
LYDSTO = true;
} else if ((bluetoothDevice.name().toUpper().startsWith("DMASUN-") && bluetoothDevice.name().toUpper().endsWith("-BIKE"))) {
qDebug() << QStringLiteral("DMASUN bike found");
DMASUN = true;
} else if ((bluetoothDevice.name().toUpper().startsWith("SL010-"))) {
qDebug() << QStringLiteral("SL010 found");
SL010 = true;
@@ -1829,9 +1838,6 @@ void ftmsbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
qDebug() << QStringLiteral("S18 found");
S18 = true;
max_resistance = 24;
} else if(device.name().toUpper().startsWith("JFICCYCLE")) {
qDebug() << QStringLiteral("JFICCYCLE found");
JFICCYCLE = true;
}
@@ -1903,6 +1909,7 @@ void ftmsbike::controllerStateChanged(QLowEnergyController::ControllerState stat
if (state == QLowEnergyController::UnconnectedState && m_control) {
qDebug() << QStringLiteral("trying to connect back again...");
initDone = false;
gearInclinationSent = false;
m_control->connectToDevice();
}
}

View File

@@ -96,6 +96,7 @@ class ftmsbike : public bike {
void forceResistance(resistance_t requestResistance);
void forcePower(int16_t requestPower);
void forceInclination(double requestInclination);
void sendZwiftPlayInclination(double inclination);
uint16_t wattsFromResistance(double resistance);
QTimer *refresh;
@@ -154,6 +155,7 @@ class ftmsbike : public bike {
bool BIKE_ = false;
bool SMB1 = false;
bool LYDSTO = false;
bool DMASUN = false;
bool SL010 = false;
bool REEBOK = false;
bool TITAN_7000 = false;
@@ -172,7 +174,6 @@ class ftmsbike : public bike {
bool SPORT01 = false;
bool FS_YK = false;
bool S18 = false;
bool JFICCYCLE = false;
bool ZIPRO_RAVE = false;
uint8_t secondsToResetTimer = 5;
@@ -182,6 +183,7 @@ class ftmsbike : public bike {
uint8_t battery_level = 0;
bool wattReceived = false;
bool gearInclinationSent = false;
uint16_t oldLastCrankEventTime = 0;
uint16_t oldCrankRevs = 0;

View File

@@ -1,353 +0,0 @@
#include "mobirower.h"
#ifdef Q_OS_ANDROID
#include "keepawakehelper.h"
#endif
#include "virtualdevices/virtualbike.h"
#include "virtualdevices/virtualrower.h"
#include <QBluetoothLocalDevice>
#include <QDateTime>
#include <QFile>
#include <QMetaEnum>
#include <QSettings>
#include <chrono>
#include <math.h>
using namespace std::chrono_literals;
#ifdef Q_OS_IOS
extern quint8 QZ_EnableDiscoveryCharsAndDescripttors;
#endif
mobirower::mobirower(bool noWriteResistance, bool noHeartService) {
#ifdef Q_OS_IOS
QZ_EnableDiscoveryCharsAndDescripttors = true;
#endif
m_watt.setType(metric::METRIC_WATT, deviceType());
Speed.setType(metric::METRIC_SPEED);
refresh = new QTimer(this);
this->noWriteResistance = noWriteResistance;
this->noHeartService = noHeartService;
initDone = false;
connect(refresh, &QTimer::timeout, this, &mobirower::update);
refresh->start(200ms);
}
void mobirower::update() {
if (m_control == nullptr)
return;
if (m_control->state() == QLowEnergyController::UnconnectedState) {
emit disconnected();
return;
}
if (bluetoothDevice.isValid() && m_control->state() == QLowEnergyController::DiscoveredState &&
gattCommunicationChannelService && gattNotifyCharacteristic.isValid() && initDone) {
update_metrics(true, watts());
if (requestStart != -1) {
qDebug() << QStringLiteral("starting...");
requestStart = -1;
emit bikeStarted();
}
if (requestStop != -1) {
qDebug() << QStringLiteral("stopping...");
requestStop = -1;
}
}
}
void mobirower::serviceDiscovered(const QBluetoothUuid &gatt) {
qDebug() << QStringLiteral("serviceDiscovered ") + gatt.toString();
}
void mobirower::characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
Q_UNUSED(characteristic);
QSettings settings;
QString heartRateBeltName =
settings.value(QZSettings::heart_rate_belt_name, QZSettings::default_heart_rate_belt_name).toString();
qDebug() << QStringLiteral(" << ") + newValue.toHex(' ');
// Validate packet: 13 bytes, starts with 0xab 0x04
if (newValue.length() < 13 ||
(uint8_t)newValue.at(0) != 0xab ||
(uint8_t)newValue.at(1) != 0x04) {
qDebug() << QStringLiteral("Invalid packet format");
return;
}
// Parse power from bytes 9-10 (big-endian uint16)
uint16_t power = ((uint8_t)newValue.at(9) << 8) | (uint8_t)newValue.at(10);
// Parse stroke count from bytes 11-12 (big-endian uint16)
uint16_t strokeCount = ((uint8_t)newValue.at(11) << 8) | (uint8_t)newValue.at(12);
// Calculate cadence from stroke delta
double timeDelta = lastRefreshCharacteristicChanged.msecsTo(QDateTime::currentDateTime());
if (timeDelta > 0 && strokeCount >= lastStrokeCount) {
uint16_t strokeDelta = strokeCount - lastStrokeCount;
// Convert to strokes per minute (SPM)
double cadence = (strokeDelta / (timeDelta / 60000.0));
if (cadence < 200) { // sanity check
Cadence = cadence;
}
}
lastStrokeCount = strokeCount;
m_watt = power;
StrokesCount = strokeCount;
// Calculate speed from strokes (standard rower formula)
// Using a simplified formula: speed in km/h derived from cadence
if (Cadence.value() > 0) {
// Typical rower: ~10m per stroke at normal pace
// Speed = (cadence * meters_per_stroke * 60) / 1000 for km/h
double metersPerStroke = 8.0; // approximate
Speed = (Cadence.value() * metersPerStroke * 60.0) / 1000.0;
} else {
Speed = 0;
}
StrokesLength =
((Speed.value() / 60.0) * 1000.0) /
Cadence.value(); // this is just to fill the tile
if (watts())
KCal +=
((((0.048 * ((double)watts()) + 1.19) *
settings.value(QZSettings::weight, QZSettings::default_weight).toFloat() * 3.5) /
200.0) /
(60000.0 / ((double)lastRefreshCharacteristicChanged.msecsTo(
QDateTime::currentDateTime()))));
Distance += ((Speed.value() / 3600000.0) *
((double)lastRefreshCharacteristicChanged.msecsTo(QDateTime::currentDateTime())));
if (Cadence.value() > 0) {
CrankRevs++;
LastCrankEventTime += (uint16_t)(1024.0 / (((double)(Cadence.value())) / 60.0));
}
lastRefreshCharacteristicChanged = QDateTime::currentDateTime();
#ifdef Q_OS_ANDROID
if (settings.value(QZSettings::ant_heart, QZSettings::default_ant_heart).toBool())
Heart = (uint8_t)KeepAwakeHelper::heart();
else
#endif
{
if (heartRateBeltName.startsWith(QStringLiteral("Disabled"))) {
update_hr_from_external();
}
}
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
bool cadence = settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool();
bool ios_peloton_workaround =
settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool();
bool virtual_device_rower =
settings.value(QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower).toBool();
if (ios_peloton_workaround && cadence && !virtual_device_rower && h && firstStateChanged) {
h->virtualbike_setCadence(currentCrankRevolutions(), lastCrankEventTime());
h->virtualbike_setHeartRate((uint8_t)metrics_override_heartrate());
}
#endif
#endif
qDebug() << QStringLiteral("Current Power: ") + QString::number(m_watt.value());
qDebug() << QStringLiteral("Current Stroke Count: ") + QString::number(StrokesCount.value());
qDebug() << QStringLiteral("Current Speed: ") + QString::number(Speed.value());
qDebug() << QStringLiteral("Current Cadence: ") + QString::number(Cadence.value());
qDebug() << QStringLiteral("Current Distance: ") + QString::number(Distance.value());
qDebug() << QStringLiteral("Current Watt: ") + QString::number(watts());
if (m_control->error() != QLowEnergyController::NoError) {
qDebug() << QStringLiteral("QLowEnergyController ERROR!!") << m_control->errorString();
}
}
void mobirower::stateChanged(QLowEnergyService::ServiceState state) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceState>();
qDebug() << QStringLiteral("BTLE stateChanged ") + QString::fromLocal8Bit(metaEnum.valueToKey(state));
if (state == QLowEnergyService::ServiceDiscovered) {
// Find the notify characteristic (0xffe4)
QBluetoothUuid notifyCharUuid((quint16)0xffe4);
gattNotifyCharacteristic = gattCommunicationChannelService->characteristic(notifyCharUuid);
if (!gattNotifyCharacteristic.isValid()) {
qDebug() << QStringLiteral("gattNotifyCharacteristic not valid, trying to find by properties");
auto characteristics_list = gattCommunicationChannelService->characteristics();
for (const QLowEnergyCharacteristic &c : qAsConst(characteristics_list)) {
qDebug() << QStringLiteral("c -> ") << c.uuid() << c.properties();
if ((c.properties() & QLowEnergyCharacteristic::Notify) == QLowEnergyCharacteristic::Notify) {
gattNotifyCharacteristic = c;
break;
}
}
}
if (!gattNotifyCharacteristic.isValid()) {
qDebug() << QStringLiteral("gattNotifyCharacteristic still not valid");
return;
}
// establish hook into notifications
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicChanged, this,
&mobirower::characteristicChanged);
connect(gattCommunicationChannelService,
static_cast<void (QLowEnergyService::*)(QLowEnergyService::ServiceError)>(&QLowEnergyService::error),
this, &mobirower::errorService);
connect(gattCommunicationChannelService, &QLowEnergyService::descriptorWritten, this,
&mobirower::descriptorWritten);
// ******************************************* virtual bike/rower init *************************************
if (!firstStateChanged && !this->hasVirtualDevice()
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
&& !h
#endif
#endif
) {
QSettings settings;
bool virtual_device_enabled =
settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool();
bool virtual_device_rower =
settings.value(QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower).toBool();
#ifdef Q_OS_IOS
#ifndef IO_UNDER_QT
bool cadence =
settings.value(QZSettings::bike_cadence_sensor, QZSettings::default_bike_cadence_sensor).toBool();
bool ios_peloton_workaround =
settings.value(QZSettings::ios_peloton_workaround, QZSettings::default_ios_peloton_workaround).toBool();
if (ios_peloton_workaround && cadence && !virtual_device_rower) {
qDebug() << "ios_peloton_workaround activated!";
h = new lockscreen();
h->virtualbike_ios();
} else
#endif
#endif
if (virtual_device_enabled) {
if (!virtual_device_rower) {
qDebug() << QStringLiteral("creating virtual bike interface...");
auto virtualBike = new virtualbike(this, noWriteResistance, noHeartService);
this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY);
} else {
qDebug() << QStringLiteral("creating virtual rower interface...");
auto virtualRower = new virtualrower(this, noWriteResistance, noHeartService);
this->setVirtualDevice(virtualRower, VIRTUAL_DEVICE_MODE::PRIMARY);
}
}
}
firstStateChanged = 1;
// ********************************************************************************************************
QByteArray descriptor;
descriptor.append((char)0x01);
descriptor.append((char)0x00);
gattCommunicationChannelService->writeDescriptor(
gattNotifyCharacteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
}
}
void mobirower::descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue) {
qDebug() << QStringLiteral("descriptorWritten ") + descriptor.name() + " " + newValue.toHex(' ');
initDone = true;
emit connectedAndDiscovered();
}
void mobirower::serviceScanDone(void) {
qDebug() << QStringLiteral("serviceScanDone");
// Service UUID 0xffe0
QBluetoothUuid serviceUuid((quint16)0xffe0);
gattCommunicationChannelService = m_control->createServiceObject(serviceUuid);
if (!gattCommunicationChannelService) {
qDebug() << "service 0xffe0 not found, trying to find any service";
auto services = m_control->services();
for (const QBluetoothUuid &s : qAsConst(services)) {
qDebug() << QStringLiteral("service ") << s.toString();
}
if (!services.isEmpty()) {
gattCommunicationChannelService = m_control->createServiceObject(services.first());
}
}
if (!gattCommunicationChannelService) {
qDebug() << "no service found";
return;
}
connect(gattCommunicationChannelService, &QLowEnergyService::stateChanged, this, &mobirower::stateChanged);
gattCommunicationChannelService->discoverDetails();
}
void mobirower::errorService(QLowEnergyService::ServiceError err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceError>();
qDebug() << QStringLiteral("mobirower::errorService") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString();
}
void mobirower::error(QLowEnergyController::Error err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyController::Error>();
qDebug() << "mobirower::error" + QString::fromLocal8Bit(metaEnum.valueToKey(err)) + m_control->errorString();
}
void mobirower::deviceDiscovered(const QBluetoothDeviceInfo &device) {
qDebug() << "Found new device: " + device.name() + " (" + device.address().toString() + ')';
bluetoothDevice = device;
m_control = QLowEnergyController::createCentral(bluetoothDevice, this);
connect(m_control, &QLowEnergyController::serviceDiscovered, this, &mobirower::serviceDiscovered);
connect(m_control, &QLowEnergyController::discoveryFinished, this, &mobirower::serviceScanDone);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, &mobirower::error);
connect(m_control, &QLowEnergyController::stateChanged, this, &mobirower::controllerStateChanged);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, [this](QLowEnergyController::Error error) {
Q_UNUSED(error);
Q_UNUSED(this);
qDebug() << QStringLiteral("Cannot connect to remote device.");
emit disconnected();
});
connect(m_control, &QLowEnergyController::connected, this, [this]() {
Q_UNUSED(this);
qDebug() << QStringLiteral("Controller connected. Search services...");
m_control->discoverServices();
});
connect(m_control, &QLowEnergyController::disconnected, this, [this]() {
Q_UNUSED(this);
qDebug() << QStringLiteral("LowEnergy controller disconnected");
emit disconnected();
});
// Connect
m_control->connectToDevice();
return;
}
bool mobirower::connected() {
if (!m_control) {
return false;
}
return m_control->state() == QLowEnergyController::DiscoveredState;
}
uint16_t mobirower::watts() {
return m_watt.value();
}
void mobirower::controllerStateChanged(QLowEnergyController::ControllerState state) {
qDebug() << QStringLiteral("controllerStateChanged") << state;
if (state == QLowEnergyController::UnconnectedState && m_control) {
qDebug() << QStringLiteral("trying to connect back again...");
initDone = false;
m_control->connectToDevice();
}
}

View File

@@ -629,8 +629,8 @@ void proformbike::forceResistance(resistance_t requestResistance) {
case 22:
writeCharacteristic((uint8_t *)res22, sizeof(res22), QStringLiteral("resistance22"), false, true);
break;
}
} else if(nordictrack_GX4_5_bike || nordictrack_gx_44_pro) {
}
} else if(nordictrack_GX4_5_bike || nordictrack_gx_44_pro || nordictrack_vr21) {
const uint8_t res25[] = {0xff, 0x0d, 0x02, 0x04, 0x02, 0x09, 0x07, 0x09, 0x02, 0x01, 0x04, 0xe8, 0x26, 0x00, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00};
const uint8_t res24[] = {0xff, 0x0d, 0x02, 0x04, 0x02, 0x09, 0x07, 0x09, 0x02, 0x01, 0x04, 0x58, 0x25, 0x00, 0x94, 0x00, 0x00, 0x00, 0x00, 0x00};
const uint8_t res23[] = {0xff, 0x0d, 0x02, 0x04, 0x02, 0x09, 0x07, 0x09, 0x02, 0x01, 0x04, 0xc8, 0x23, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00};
@@ -2080,12 +2080,15 @@ void proformbike::btinit() {
proform_xbike = settings.value(QZSettings::proform_xbike, QZSettings::default_proform_xbike).toBool();
proform_225_csx_PFEX32925_INT_0 = settings.value(QZSettings::proform_225_csx_PFEX32925_INT_0, QZSettings::default_proform_225_csx_PFEX32925_INT_0).toBool();
proform_csx210 = settings.value(QZSettings::proform_csx210, QZSettings::default_proform_csx210).toBool();
nordictrack_vr21 = settings.value(QZSettings::nordictrack_vr21, QZSettings::default_nordictrack_vr21).toBool();
if(nordictrack_GX4_5_bike)
max_resistance = 25;
if(proform_csx210)
max_resistance = 16;
if(nordictrack_vr21)
max_resistance = 25;
if (settings.value(QZSettings::proform_studio, QZSettings::default_proform_studio).toBool()) {
@@ -3227,6 +3230,123 @@ void proformbike::btinit() {
QThread::msleep(400);
writeCharacteristic(initData37, sizeof(initData37), QStringLiteral("init"), false, false);
QThread::msleep(400);
} else if (nordictrack_vr21) {
// NordicTrack VR21 initialization sequence with 25 max resistance
uint8_t initData1[] = {0xfe, 0x02, 0x08, 0x02};
uint8_t initData2[] = {0xff, 0x08, 0x02, 0x04, 0x02, 0x04, 0x02, 0x04, 0x81, 0x87,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData3[] = {0xfe, 0x02, 0x08, 0x02};
uint8_t initData4[] = {0xff, 0x08, 0x02, 0x04, 0x02, 0x04, 0x07, 0x04, 0x80, 0x8b,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData5[] = {0xfe, 0x02, 0x08, 0x02};
uint8_t initData6[] = {0xff, 0x08, 0x02, 0x04, 0x02, 0x04, 0x07, 0x04, 0x88, 0x93,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData7[] = {0xfe, 0x02, 0x0a, 0x02};
uint8_t initData8[] = {0xff, 0x0a, 0x02, 0x04, 0x02, 0x06, 0x02, 0x06, 0x82, 0x00,
0x00, 0x8a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData9[] = {0xfe, 0x02, 0x0a, 0x02};
uint8_t initData10[] = {0xff, 0x0a, 0x02, 0x04, 0x02, 0x06, 0x02, 0x06, 0x84, 0x00,
0x00, 0x8c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData11[] = {0xfe, 0x02, 0x08, 0x02};
uint8_t initData12[] = {0xff, 0x08, 0x02, 0x04, 0x02, 0x04, 0x02, 0x04, 0x95, 0x9b,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData13[] = {0xfe, 0x02, 0x2c, 0x04};
uint8_t initData14[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x28, 0x07, 0x28, 0x90, 0x04,
0x00, 0xda, 0xd4, 0xcc, 0xc2, 0xc6, 0xc8, 0xc8, 0xce, 0xc2};
uint8_t initData15[] = {0x01, 0x12, 0xdc, 0xd4, 0xea, 0xfe, 0x10, 0x00, 0x36, 0x2a,
0x44, 0x7c, 0x92, 0xb6, 0xd8, 0xf8, 0x1e, 0x32, 0x4c, 0x64};
uint8_t initData16[] = {0xff, 0x08, 0xba, 0xce, 0x20, 0x80, 0x02, 0x00, 0x00, 0xef,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData17[] = {0xfe, 0x02, 0x19, 0x03};
uint8_t initData18[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x15, 0x07, 0x15, 0x02, 0x0e,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData19[] = {0xff, 0x07, 0x00, 0x00, 0x00, 0x10, 0x01, 0x00, 0x3d, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData20[] = {0xfe, 0x02, 0x17, 0x03};
uint8_t initData21[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x13, 0x07, 0x13, 0x02, 0x0c,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData22[] = {0xff, 0x05, 0x00, 0x80, 0x01, 0x00, 0xa9, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData23[] = {0xfe, 0x02, 0x17, 0x03};
uint8_t initData24[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x13, 0x07, 0x13, 0x02, 0x0c,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData25[] = {0xff, 0x05, 0x00, 0x80, 0x01, 0x00, 0xa9, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData26[] = {0xfe, 0x02, 0x17, 0x03};
uint8_t initData27[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x13, 0x07, 0x13, 0x02, 0x00,
0x0d, 0x00, 0x10, 0x00, 0xc0, 0x1c, 0x4c, 0x00, 0x00, 0xe0};
uint8_t initData28[] = {0xff, 0x05, 0x00, 0x00, 0x00, 0x10, 0x51, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
uint8_t initData29[] = {0xfe, 0x02, 0x17, 0x03};
uint8_t initData30[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x13, 0x07, 0x13, 0x02, 0x00,
0x0d, 0x3c, 0x9e, 0x31, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80};
uint8_t initData31[] = {0xff, 0x05, 0x00, 0x00, 0x00, 0x85, 0xb9, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
// Execute initialization sequence
writeCharacteristic(initData1, sizeof(initData1), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData2, sizeof(initData2), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData3, sizeof(initData3), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData4, sizeof(initData4), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData5, sizeof(initData5), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData6, sizeof(initData6), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData7, sizeof(initData7), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData8, sizeof(initData8), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData9, sizeof(initData9), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData10, sizeof(initData10), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData11, sizeof(initData11), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData12, sizeof(initData12), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData13, sizeof(initData13), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData14, sizeof(initData14), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData15, sizeof(initData15), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData16, sizeof(initData16), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData17, sizeof(initData17), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData18, sizeof(initData18), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData19, sizeof(initData19), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData20, sizeof(initData20), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData21, sizeof(initData21), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData22, sizeof(initData22), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData23, sizeof(initData23), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData24, sizeof(initData24), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData25, sizeof(initData25), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData26, sizeof(initData26), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData27, sizeof(initData27), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData28, sizeof(initData28), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData29, sizeof(initData29), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData30, sizeof(initData30), QStringLiteral("init"), false, false);
QThread::msleep(400);
writeCharacteristic(initData31, sizeof(initData31), QStringLiteral("init"), false, false);
QThread::msleep(400);
} else {
uint8_t initData10[] = {0x00, 0x12, 0x02, 0x04, 0x02, 0x28, 0x07, 0x28, 0x90, 0x07,

View File

@@ -97,6 +97,7 @@ class proformbike : public bike {
bool proform_xbike = false;
bool proform_225_csx_PFEX32925_INT_0 = false;
bool proform_csx210 = false;
bool nordictrack_vr21 = false;
#ifdef Q_OS_IOS
lockscreen *h = 0;

View File

@@ -488,7 +488,7 @@ void proformwifibike::characteristicChanged(const QString &newValue) {
if (settings.value(QZSettings::power_sensor_name, QZSettings::default_power_sensor_name)
.toString()
.startsWith(QStringLiteral("Disabled")))
m_watt = m_rawWatt.value();
m_watt.setValue(m_rawWatt.value(), false);
emit debug(QStringLiteral("Current Watt: ") + QString::number(watts()));
} else if (!values[QStringLiteral("Watt attuali")].isUndefined()) {
double watt = values[QStringLiteral("Watt attuali")].toString().toDouble();

View File

@@ -0,0 +1,389 @@
#include "sunnyfitstepper.h"
#ifdef Q_OS_ANDROID
#include "keepawakehelper.h"
#endif
#include "virtualdevices/virtualbike.h"
#include "virtualdevices/virtualtreadmill.h"
#include <QBluetoothLocalDevice>
#include <QDateTime>
#include <QFile>
#include <QMetaEnum>
#include <QSettings>
#include <chrono>
using namespace std::chrono_literals;
sunnyfitstepper::sunnyfitstepper(uint32_t pollDeviceTime, bool noConsole, bool noHeartService, double forceInitSpeed,
double forceInitInclination) {
m_watt.setType(metric::METRIC_WATT, deviceType());
Speed.setType(metric::METRIC_SPEED);
this->noConsole = noConsole;
this->noHeartService = noHeartService;
this->pollDeviceTime = pollDeviceTime;
refresh = new QTimer(this);
initDone = false;
frameBuffer.clear();
expectingSecondPart = false;
connect(refresh, &QTimer::timeout, this, &sunnyfitstepper::update);
refresh->start(pollDeviceTime);
}
bool sunnyfitstepper::connected() {
if (!m_control)
return false;
return m_control->state() == QLowEnergyController::DiscoveredState;
}
void sunnyfitstepper::writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log,
bool wait_for_response) {
QEventLoop loop;
QTimer timeout;
if (wait_for_response) {
connect(this, &sunnyfitstepper::packetReceived, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
} else {
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicWritten, &loop, &QEventLoop::quit);
timeout.singleShot(300ms, &loop, &QEventLoop::quit);
}
if (gattCommunicationChannelService->state() != QLowEnergyService::ServiceState::ServiceDiscovered ||
m_control->state() == QLowEnergyController::UnconnectedState) {
emit debug(QStringLiteral("writeCharacteristic error because the connection is closed"));
return;
}
if (writeBuffer) {
delete writeBuffer;
}
writeBuffer = new QByteArray((const char *)data, data_len);
gattCommunicationChannelService->writeCharacteristic(gattWriteCharacteristic, *writeBuffer);
if (!disable_log) {
emit debug(QStringLiteral(" >> ") + writeBuffer->toHex(' ') +
QStringLiteral(" // ") + info);
}
loop.exec();
if (timeout.isActive() == false) {
emit debug(QStringLiteral(" exit for timeout"));
}
}
void sunnyfitstepper::sendPoll() {
// Alternate between two poll commands
counterPoll++;
}
void sunnyfitstepper::changeInclinationRequested(double grade, double percentage) {
if (percentage < 0)
percentage = 0;
changeInclination(grade, percentage);
}
void sunnyfitstepper::processDataFrame(const QByteArray &completeFrame) {
if (completeFrame.length() != 32) {
qDebug() << "ERROR: Frame length is not 32 bytes:" << completeFrame.length();
return;
}
if ((uint8_t)completeFrame.at(0) != 0x5a) {
qDebug() << "ERROR: Frame doesn't start with 0x5a";
return;
}
if ((uint8_t)completeFrame.at(1) != 0x05) {
qDebug() << "WARNING: Expected 0x05 at byte 1, got:" << QString::number((uint8_t)completeFrame.at(1), 16);
}
QDateTime now = QDateTime::currentDateTime();
QSettings settings;
// Extract cadence (bytes 6-7, little-endian)
uint16_t rawCadence = ((uint8_t)completeFrame.at(7) << 8) | (uint8_t)completeFrame.at(6);
Cadence = (double)rawCadence;
// Extract step count (bytes 10-12, little-endian)
uint32_t steps = ((uint32_t)(uint8_t)completeFrame.at(12) << 16) |
((uint32_t)(uint8_t)completeFrame.at(11) << 8) |
(uint32_t)(uint8_t)completeFrame.at(10);
StepCount = steps;
// Calculate elevation manually (0.2 meters per step)
elevationAcc = (double)steps * 0.20;
// Calculate speed from cadence (stairclimber convention)
Speed = Cadence.value() / 3.2;
qDebug() << QStringLiteral("Current Cadence (SPM): ") + QString::number(Cadence.value());
qDebug() << QStringLiteral("Current StepCount: ") + QString::number(StepCount.value());
qDebug() << QStringLiteral("Current Speed: ") + QString::number(Speed.value());
qDebug() << QStringLiteral("Current Elevation: ") + QString::number(elevationAcc.value());
// Calculate metrics
if (!firstCharacteristicChanged) {
if (watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat())) {
KCal += ((((0.048 * ((double)watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat())) +
1.19) *
settings.value(QZSettings::weight, QZSettings::default_weight).toFloat() * 3.5) /
200.0) /
(60000.0 / ((double)lastTimeCharacteristicChanged.msecsTo(now))));
}
Distance += ((Speed.value() / 3600.0) / (1000.0 / (lastTimeCharacteristicChanged.msecsTo(now))));
}
qDebug() << QStringLiteral("Current Distance: ") + QString::number(Distance.value());
qDebug() << QStringLiteral("Current KCal: ") + QString::number(KCal.value());
qDebug() << QStringLiteral("Current Watt: ") +
QString::number(watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat()));
if (m_control->error() != QLowEnergyController::NoError)
qDebug() << QStringLiteral("QLowEnergyController ERROR!!") << m_control->errorString();
lastTimeCharacteristicChanged = now;
firstCharacteristicChanged = false;
}
void sunnyfitstepper::update() {
if (m_control->state() == QLowEnergyController::UnconnectedState) {
emit disconnected();
return;
}
if (initRequest) {
initRequest = false;
btinit();
} else if (m_control->state() == QLowEnergyController::DiscoveredState && gattCommunicationChannelService &&
gattWriteCharacteristic.isValid() && gattNotify1Characteristic.isValid() &&
gattNotify4Characteristic.isValid() && initDone) {
QSettings settings;
// *********** virtual treadmill init *************************************
if (!this->hasVirtualDevice()) {
bool virtual_device_enabled =
settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool();
bool virtual_device_force_bike =
settings.value(QZSettings::virtual_device_force_bike, QZSettings::default_virtual_device_force_bike)
.toBool();
if (virtual_device_enabled) {
if (!virtual_device_force_bike) {
debug("creating virtual treadmill interface...");
auto virtualTreadMill = new virtualtreadmill(this, noHeartService);
connect(virtualTreadMill, &virtualtreadmill::debug, this, &sunnyfitstepper::debug);
connect(virtualTreadMill, &virtualtreadmill::changeInclination, this,
&sunnyfitstepper::changeInclinationRequested);
this->setVirtualDevice(virtualTreadMill, VIRTUAL_DEVICE_MODE::PRIMARY);
} else {
debug("creating virtual bike interface...");
auto virtualBike = new virtualbike(this);
connect(virtualBike, &virtualbike::changeInclination, this,
&sunnyfitstepper::changeInclinationRequested);
this->setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::ALTERNATIVE);
}
}
}
// ************************************************************
update_metrics(true, watts(settings.value(QZSettings::weight, QZSettings::default_weight).toFloat()));
// Send poll every 2 seconds
if (sec1Update++ >= (2000 / refresh->interval())) {
sec1Update = 0;
//sendPoll();
}
}
}
void sunnyfitstepper::serviceDiscovered(const QBluetoothUuid &gatt) {
emit debug(QStringLiteral("serviceDiscovered ") + gatt.toString());
}
void sunnyfitstepper::characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
emit debug(QStringLiteral(" << ") + newValue.toHex(' '));
// Handle command responses (Notify 1)
if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("fd710003-e950-458e-8a4d-a1cbc5aa4cce"))) {
qDebug() << "Command response:" << newValue.toHex(' ');
emit packetReceived();
return;
}
// Handle main data stream (Notify 4) - SPLIT FRAME LOGIC
if (characteristic.uuid() == QBluetoothUuid(QStringLiteral("fd710006-e950-458e-8a4d-a1cbc5aa4cce"))) {
// First part: 20 bytes starting with 0x5a
if (newValue.length() == 20 && (uint8_t)newValue.at(0) == 0x5a) {
frameBuffer.clear();
frameBuffer.append(newValue);
expectingSecondPart = true;
qDebug() << "First part of frame received (20 bytes)";
return;
}
// Second part: 12 bytes
if (newValue.length() == 12 && expectingSecondPart) {
frameBuffer.append(newValue);
expectingSecondPart = false;
if (frameBuffer.length() == 32) {
emit debug(QStringLiteral(" << COMPLETE FRAME >> ") + frameBuffer.toHex(' '));
processDataFrame(frameBuffer);
frameBuffer.clear();
} else {
qDebug() << "ERROR: Complete frame size mismatch:" << frameBuffer.length();
frameBuffer.clear();
}
return;
}
// Unexpected frame structure
qDebug() << "Unexpected frame - length:" << newValue.length() << "expecting second part:" << expectingSecondPart;
frameBuffer.clear();
expectingSecondPart = false;
}
}
void sunnyfitstepper::btinit() {
uint8_t init1[] = {0x5a, 0x02, 0x00, 0x08, 0x07, 0xa0, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xe6, 0xa5};
uint8_t init2[] = {0x5a, 0x02, 0x00, 0x03, 0x02, 0xa3, 0x00, 0xaa, 0xa5};
uint8_t init3[] = {0x5a, 0x02, 0x00, 0x03, 0x02, 0xb4, 0x00, 0xbb, 0xa5};
uint8_t init4[] = {0x5a, 0x04, 0x00, 0x03, 0x02, 0xf1, 0x00, 0xfa, 0xa5};
writeCharacteristic(init1, sizeof(init1), QStringLiteral("init1"), false, true);
writeCharacteristic(init2, sizeof(init2), QStringLiteral("init2"), false, true);
writeCharacteristic(init3, sizeof(init3), QStringLiteral("init3"), false, false);
writeCharacteristic(init4, sizeof(init4), QStringLiteral("init4"), false, false);
initDone = true;
}
void sunnyfitstepper::stateChanged(QLowEnergyService::ServiceState state) {
QBluetoothUuid _gattWriteCharacteristicId(QStringLiteral("fd710002-e950-458e-8a4d-a1cbc5aa4cce"));
QBluetoothUuid _gattNotify1CharacteristicId(QStringLiteral("fd710003-e950-458e-8a4d-a1cbc5aa4cce"));
QBluetoothUuid _gattNotify4CharacteristicId(QStringLiteral("fd710006-e950-458e-8a4d-a1cbc5aa4cce"));
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceState>();
qDebug() << QStringLiteral("BTLE stateChanged ") + QString::fromLocal8Bit(metaEnum.valueToKey(state));
if (state == QLowEnergyService::ServiceDiscovered) {
gattWriteCharacteristic = gattCommunicationChannelService->characteristic(_gattWriteCharacteristicId);
gattNotify1Characteristic = gattCommunicationChannelService->characteristic(_gattNotify1CharacteristicId);
gattNotify4Characteristic = gattCommunicationChannelService->characteristic(_gattNotify4CharacteristicId);
Q_ASSERT(gattWriteCharacteristic.isValid());
Q_ASSERT(gattNotify1Characteristic.isValid());
Q_ASSERT(gattNotify4Characteristic.isValid());
// establish hook into notifications
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicChanged, this,
&sunnyfitstepper::characteristicChanged);
connect(gattCommunicationChannelService, &QLowEnergyService::characteristicWritten, this,
&sunnyfitstepper::characteristicWritten);
connect(gattCommunicationChannelService, SIGNAL(error(QLowEnergyService::ServiceError)), this,
SLOT(errorService(QLowEnergyService::ServiceError)));
connect(gattCommunicationChannelService, &QLowEnergyService::descriptorWritten, this,
&sunnyfitstepper::descriptorWritten);
QByteArray descriptor;
descriptor.append((char)0x01);
descriptor.append((char)0x00);
gattCommunicationChannelService->writeDescriptor(
gattNotify1Characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
gattCommunicationChannelService->writeDescriptor(
gattNotify4Characteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
initRequest = true;
}
}
void sunnyfitstepper::descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue) {
emit debug(QStringLiteral("descriptorWritten ") + descriptor.name() + " " + newValue.toHex(' '));
emit connectedAndDiscovered();
}
void sunnyfitstepper::characteristicWritten(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
Q_UNUSED(characteristic);
emit debug(QStringLiteral("characteristicWritten ") + newValue.toHex(' '));
}
void sunnyfitstepper::serviceScanDone(void) {
qDebug() << QStringLiteral("serviceScanDone");
auto services_list = m_control->services();
for (const QBluetoothUuid &s : qAsConst(services_list)) {
qDebug() << s << "service found!";
}
QBluetoothUuid _gattCommunicationChannelServiceId(QStringLiteral("fd710001-e950-458e-8a4d-a1cbc5aa4cce"));
gattCommunicationChannelService = m_control->createServiceObject(_gattCommunicationChannelServiceId);
if (gattCommunicationChannelService == nullptr) {
qDebug() << "invalid service";
return;
}
connect(gattCommunicationChannelService, &QLowEnergyService::stateChanged, this, &sunnyfitstepper::stateChanged);
gattCommunicationChannelService->discoverDetails();
}
void sunnyfitstepper::errorService(QLowEnergyService::ServiceError err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceError>();
emit debug(QStringLiteral("sunnyfitstepper::errorService ") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void sunnyfitstepper::error(QLowEnergyController::Error err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyController::Error>();
emit debug(QStringLiteral("sunnyfitstepper::error ") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void sunnyfitstepper::controllerStateChanged(QLowEnergyController::ControllerState state) {
qDebug() << QStringLiteral("sunnyfitstepper::controllerStateChanged") << state;
if (state == QLowEnergyController::UnconnectedState) {
emit disconnected();
}
}
void sunnyfitstepper::deviceDiscovered(const QBluetoothDeviceInfo &device) {
{
bluetoothDevice = device;
m_control = QLowEnergyController::createCentral(bluetoothDevice, this);
connect(m_control, &QLowEnergyController::serviceDiscovered, this, &sunnyfitstepper::serviceDiscovered);
connect(m_control, &QLowEnergyController::discoveryFinished, this, &sunnyfitstepper::serviceScanDone);
connect(m_control, SIGNAL(error(QLowEnergyController::Error)), this, SLOT(error(QLowEnergyController::Error)));
connect(m_control, &QLowEnergyController::stateChanged, this, &sunnyfitstepper::controllerStateChanged);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, [this](QLowEnergyController::Error error) {
Q_UNUSED(error);
Q_UNUSED(this);
emit debug(QStringLiteral("Cannot connect to remote device."));
emit disconnected();
});
connect(m_control, &QLowEnergyController::connected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("Controller connected. Search services..."));
m_control->discoverServices();
});
connect(m_control, &QLowEnergyController::disconnected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("QLowEnergyController disconnected"));
emit disconnected();
});
m_control->connectToDevice();
}
}
void sunnyfitstepper::startDiscover() {
m_control->discoverServices();
}

View File

@@ -0,0 +1,99 @@
#ifndef SUNNYFITSTEPPER_H
#define SUNNYFITSTEPPER_H
#include <QBluetoothDeviceDiscoveryAgent>
#include <QtBluetooth/qlowenergyadvertisingdata.h>
#include <QtBluetooth/qlowenergyadvertisingparameters.h>
#include <QtBluetooth/qlowenergycharacteristic.h>
#include <QtBluetooth/qlowenergycharacteristicdata.h>
#include <QtBluetooth/qlowenergycontroller.h>
#include <QtBluetooth/qlowenergydescriptordata.h>
#include <QtBluetooth/qlowenergyservice.h>
#include <QtBluetooth/qlowenergyservicedata.h>
#include <QtCore/qbytearray.h>
#ifndef Q_OS_ANDROID
#include <QtCore/qcoreapplication.h>
#else
#include <QtGui/qguiapplication.h>
#endif
#include <QtCore/qlist.h>
#include <QtCore/qmutex.h>
#include <QtCore/qscopedpointer.h>
#include <QtCore/qtimer.h>
#include <QDateTime>
#include <QObject>
#include <QString>
#include "stairclimber.h"
#ifdef Q_OS_IOS
#include "ios/lockscreen.h"
#endif
class sunnyfitstepper : public stairclimber {
Q_OBJECT
public:
sunnyfitstepper(uint32_t pollDeviceTime = 200, bool noConsole = false, bool noHeartService = false,
double forceInitSpeed = 0.0, double forceInitInclination = 0.0);
bool connected() override;
private:
void btinit();
void sendPoll();
void writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log = false,
bool wait_for_response = false);
void processDataFrame(const QByteArray &completeFrame);
void startDiscover();
// Bluetooth
QLowEnergyService *gattCommunicationChannelService = nullptr;
QLowEnergyCharacteristic gattWriteCharacteristic;
QLowEnergyCharacteristic gattNotify1Characteristic;
QLowEnergyCharacteristic gattNotify4Characteristic;
// Split-frame handling (CRITICAL)
QByteArray frameBuffer;
bool expectingSecondPart = false;
// State
QTimer *refresh;
uint8_t sec1Update = 0;
uint8_t counterPoll = 0;
bool initDone = false;
bool initRequest = false;
bool noConsole = false;
bool noHeartService = false;
uint32_t pollDeviceTime = 200;
QDateTime lastTimeCharacteristicChanged;
bool firstCharacteristicChanged = true;
#ifdef Q_OS_IOS
lockscreen *h = 0;
#endif
signals:
void disconnected();
void debug(QString string);
void speedChanged(double speed);
void packetReceived();
public slots:
void deviceDiscovered(const QBluetoothDeviceInfo &device);
private slots:
void characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void characteristicWritten(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue);
void stateChanged(QLowEnergyService::ServiceState state);
void controllerStateChanged(QLowEnergyController::ControllerState state);
void changeInclinationRequested(double grade, double percentage);
void serviceDiscovered(const QBluetoothUuid &gatt);
void serviceScanDone(void);
void update();
void error(QLowEnergyController::Error err);
void errorService(QLowEnergyService::ServiceError);
};
#endif // SUNNYFITSTEPPER_H

View File

@@ -0,0 +1,225 @@
#include "thinkridercontroller.h"
#include "homeform.h"
#include <QBluetoothLocalDevice>
#include <QDateTime>
#include <QEventLoop>
#include <QFile>
#include <QMetaEnum>
#include <QSettings>
#include <QThread>
using namespace std::chrono_literals;
// Thinkrider VS200 UUIDs
const QBluetoothUuid thinkridercontroller::SERVICE_UUID =
QBluetoothUuid(QStringLiteral("0000fea0-0000-1000-8000-00805f9b34fb"));
const QBluetoothUuid thinkridercontroller::CHARACTERISTIC_UUID =
QBluetoothUuid(QStringLiteral("0000fea1-0000-1000-8000-00805f9b34fb"));
// Button patterns (from swiftcontrol implementation)
const QByteArray thinkridercontroller::SHIFT_UP_PATTERN = QByteArray::fromHex("f3050301fc");
const QByteArray thinkridercontroller::SHIFT_DOWN_PATTERN = QByteArray::fromHex("f3050300fb");
thinkridercontroller::thinkridercontroller(bluetoothdevice *parentDevice) {
this->parentDevice = parentDevice;
}
void thinkridercontroller::serviceDiscovered(const QBluetoothUuid &gatt) {
emit debug(QStringLiteral("serviceDiscovered ") + gatt.toString());
}
void thinkridercontroller::disconnectBluetooth() {
qDebug() << QStringLiteral("thinkridercontroller::disconnect") << m_control;
if (m_control) {
m_control->disconnectFromDevice();
}
}
void thinkridercontroller::characteristicChanged(const QLowEnergyCharacteristic &characteristic,
const QByteArray &newValue) {
Q_UNUSED(characteristic);
qDebug() << QStringLiteral("thinkridercontroller << ") << newValue.toHex(' ');
// Check for shift up pattern
if (newValue == SHIFT_UP_PATTERN) {
qDebug() << QStringLiteral("Thinkrider: Shift UP detected");
emit plus();
}
// Check for shift down pattern
else if (newValue == SHIFT_DOWN_PATTERN) {
qDebug() << QStringLiteral("Thinkrider: Shift DOWN detected");
emit minus();
}
}
void thinkridercontroller::stateChanged(QLowEnergyService::ServiceState state) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceState>();
emit debug(QStringLiteral("BTLE stateChanged ") + QString::fromLocal8Bit(metaEnum.valueToKey(state)));
for (QLowEnergyService *s : qAsConst(gattCommunicationChannelService)) {
qDebug() << QStringLiteral("stateChanged") << s->serviceUuid() << s->state();
if (s->state() != QLowEnergyService::ServiceDiscovered && s->state() != QLowEnergyService::InvalidService) {
qDebug() << QStringLiteral("not all services discovered");
return;
}
}
if (state != QLowEnergyService::ServiceState::ServiceDiscovered) {
qDebug() << QStringLiteral("ignoring this state");
return;
}
qDebug() << QStringLiteral("all services discovered!");
for (QLowEnergyService *s : qAsConst(gattCommunicationChannelService)) {
if (s->state() == QLowEnergyService::ServiceDiscovered) {
// establish hook into notifications
connect(s, &QLowEnergyService::characteristicChanged, this, &thinkridercontroller::characteristicChanged);
connect(s, &QLowEnergyService::characteristicRead, this, &thinkridercontroller::characteristicChanged);
connect(
s, static_cast<void (QLowEnergyService::*)(QLowEnergyService::ServiceError)>(&QLowEnergyService::error),
this, &thinkridercontroller::errorService);
connect(s, &QLowEnergyService::descriptorWritten, this, &thinkridercontroller::descriptorWritten);
qDebug() << s->serviceUuid() << QStringLiteral("connected!");
auto characteristics_list = s->characteristics();
for (const QLowEnergyCharacteristic &c : qAsConst(characteristics_list)) {
qDebug() << QStringLiteral("char uuid") << c.uuid() << QStringLiteral("handle") << c.handle();
auto descriptors_list = c.descriptors();
for (const QLowEnergyDescriptor &d : qAsConst(descriptors_list)) {
qDebug() << QStringLiteral("descriptor uuid") << d.uuid() << QStringLiteral("handle") << d.handle();
}
if ((c.properties() & QLowEnergyCharacteristic::Notify) == QLowEnergyCharacteristic::Notify) {
QByteArray descriptor;
descriptor.append((char)0x01);
descriptor.append((char)0x00);
if (c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).isValid()) {
s->writeDescriptor(c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
} else {
qDebug() << QStringLiteral("ClientCharacteristicConfiguration") << c.uuid()
<< c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).uuid()
<< c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).handle()
<< QStringLiteral(" is not valid");
}
qDebug() << s->serviceUuid() << c.uuid() << QStringLiteral("notification subscribed!");
} else if ((c.properties() & QLowEnergyCharacteristic::Indicate) ==
QLowEnergyCharacteristic::Indicate) {
QByteArray descriptor;
descriptor.append((char)0x02);
descriptor.append((char)0x00);
if (c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).isValid()) {
s->writeDescriptor(c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration), descriptor);
} else {
qDebug() << QStringLiteral("ClientCharacteristicConfiguration") << c.uuid()
<< c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).uuid()
<< c.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration).handle()
<< QStringLiteral(" is not valid");
}
qDebug() << s->serviceUuid() << c.uuid() << QStringLiteral("indication subscribed!");
}
if (c.uuid() == CHARACTERISTIC_UUID) {
qDebug() << QStringLiteral("Thinkrider characteristic found");
gattNotifyCharacteristic = c;
}
}
}
}
initDone = true;
}
void thinkridercontroller::descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue) {
emit debug(QStringLiteral("descriptorWritten ") + descriptor.name() + " " + newValue.toHex(' '));
}
void thinkridercontroller::serviceScanDone(void) {
emit debug(QStringLiteral("serviceScanDone"));
auto services_list = m_control->services();
for (const QBluetoothUuid &s : qAsConst(services_list)) {
gattCommunicationChannelService.append(m_control->createServiceObject(s));
if (gattCommunicationChannelService.constLast()) {
connect(gattCommunicationChannelService.constLast(), &QLowEnergyService::stateChanged, this,
&thinkridercontroller::stateChanged);
gattCommunicationChannelService.constLast()->discoverDetails();
} else {
m_control->disconnectFromDevice();
}
}
}
void thinkridercontroller::errorService(QLowEnergyService::ServiceError err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyService::ServiceError>();
emit debug(QStringLiteral("thinkridercontroller::errorService") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void thinkridercontroller::error(QLowEnergyController::Error err) {
QMetaEnum metaEnum = QMetaEnum::fromType<QLowEnergyController::Error>();
emit debug(QStringLiteral("thinkridercontroller::error") + QString::fromLocal8Bit(metaEnum.valueToKey(err)) +
m_control->errorString());
}
void thinkridercontroller::deviceDiscovered(const QBluetoothDeviceInfo &device) {
emit debug(QStringLiteral("Found new device: ") + device.name() + QStringLiteral(" (") +
device.address().toString() + ')');
{
bluetoothDevice = device;
m_control = QLowEnergyController::createCentral(bluetoothDevice, this);
connect(m_control, &QLowEnergyController::serviceDiscovered, this, &thinkridercontroller::serviceDiscovered);
connect(m_control, &QLowEnergyController::discoveryFinished, this, &thinkridercontroller::serviceScanDone);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, &thinkridercontroller::error);
connect(m_control, &QLowEnergyController::stateChanged, this, &thinkridercontroller::controllerStateChanged);
connect(m_control,
static_cast<void (QLowEnergyController::*)(QLowEnergyController::Error)>(&QLowEnergyController::error),
this, [this](QLowEnergyController::Error error) {
Q_UNUSED(error);
Q_UNUSED(this);
emit debug(QStringLiteral("Cannot connect to remote device."));
emit disconnected();
});
connect(m_control, &QLowEnergyController::connected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("Controller connected. Search services..."));
m_control->discoverServices();
});
connect(m_control, &QLowEnergyController::disconnected, this, [this]() {
Q_UNUSED(this);
emit debug(QStringLiteral("LowEnergy controller disconnected"));
emit disconnected();
});
// Connect
m_control->connectToDevice();
return;
}
}
bool thinkridercontroller::connected() {
if (!m_control) {
return false;
}
return m_control->state() == QLowEnergyController::DiscoveredState;
}
void thinkridercontroller::controllerStateChanged(QLowEnergyController::ControllerState state) {
qDebug() << QStringLiteral("controllerStateChanged") << state;
if (state == QLowEnergyController::UnconnectedState) {
qDebug() << QStringLiteral("trying to connect back again...");
initDone = false;
if (m_control)
m_control->connectToDevice();
}
}

View File

@@ -1,5 +1,5 @@
#ifndef MOBIROWER_H
#define MOBIROWER_H
#ifndef THINKRIDERCONTROLLER_H
#define THINKRIDERCONTROLLER_H
#include <QBluetoothDeviceDiscoveryAgent>
#include <QtBluetooth/qlowenergyadvertisingdata.h>
@@ -22,61 +22,52 @@
#include <QtCore/qscopedpointer.h>
#include <QtCore/qtimer.h>
#include <QDateTime>
#include <QObject>
#include <QString>
#include <QTime>
#include "rower.h"
#include "devices/bluetoothdevice.h"
#ifdef Q_OS_IOS
#include "ios/lockscreen.h"
#endif
class mobirower : public rower {
class thinkridercontroller : public bluetoothdevice {
Q_OBJECT
public:
mobirower(bool noWriteResistance, bool noHeartService);
thinkridercontroller(bluetoothdevice *parentDevice);
bool connected() override;
private:
void startDiscover();
uint16_t watts() override;
// Thinkrider VS200 UUIDs
static const QBluetoothUuid SERVICE_UUID;
static const QBluetoothUuid CHARACTERISTIC_UUID;
QTimer *refresh;
// Button patterns
static const QByteArray SHIFT_UP_PATTERN;
static const QByteArray SHIFT_DOWN_PATTERN;
QLowEnergyService *gattCommunicationChannelService = nullptr;
QList<QLowEnergyService *> gattCommunicationChannelService;
QLowEnergyCharacteristic gattNotifyCharacteristic;
uint8_t firstStateChanged = 0;
uint16_t lastStrokeCount = 0;
QDateTime lastRefreshCharacteristicChanged = QDateTime::currentDateTime();
bluetoothdevice *parentDevice = nullptr;
bool initDone = false;
bool noWriteResistance = false;
bool noHeartService = false;
#ifdef Q_OS_IOS
lockscreen *h = 0;
#endif
Q_SIGNALS:
signals:
void disconnected();
void debug(QString string);
void plus();
void minus();
public slots:
void deviceDiscovered(const QBluetoothDeviceInfo &device);
private slots:
void characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue);
void stateChanged(QLowEnergyService::ServiceState state);
void controllerStateChanged(QLowEnergyController::ControllerState state);
void disconnectBluetooth();
void serviceDiscovered(const QBluetoothUuid &gatt);
void serviceScanDone(void);
void update();
void characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue);
void stateChanged(QLowEnergyService::ServiceState state);
void descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &newValue);
void controllerStateChanged(QLowEnergyController::ControllerState state);
private slots:
void error(QLowEnergyController::Error err);
void errorService(QLowEnergyService::ServiceError);
};
#endif // MOBIROWER_H
#endif // THINKRIDERCONTROLLER_H

View File

@@ -391,6 +391,9 @@ bool GarminConnect::fetchCsrfToken()
bool GarminConnect::performLogin(const QString &email, const QString &password, bool suppressMfaSignal)
{
qDebug() << "GarminConnect: Performing login...";
qDebug() << "GarminConnect: Using domain:" << m_domain;
qDebug() << "GarminConnect: SSO URL:" << ssoUrl();
qDebug() << "GarminConnect: Connect API URL:" << connectApiUrl();
QString ssoEmbedUrl = ssoUrl() + SSO_EMBED_PATH;
@@ -452,15 +455,54 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
qDebug() << "GarminConnect: Login response length:" << response.length();
qDebug() << "GarminConnect: Response snippet:" << response.left(300);
// Check for success title (like Python garth library)
// Check page title (like Python garth library)
// garth checks ONLY the title for MFA detection, not the body
// This is important because some servers (like garmin.cn) may have "MFA" text
// in their Success page HTML body, which would cause false positives
QString pageTitle;
QRegularExpression titleRegex("<title>(.+?)</title>");
QRegularExpressionMatch titleMatch = titleRegex.match(response);
if (titleMatch.hasMatch()) {
QString title = titleMatch.captured(1);
qDebug() << "GarminConnect: Page title:" << title;
if (title == "Success") {
qDebug() << "GarminConnect: Login successful (Success page detected)";
pageTitle = titleMatch.captured(1);
qDebug() << "GarminConnect: Page title:" << pageTitle;
}
// Check if MFA is required by looking at the TITLE (garth approach)
// This is more reliable than checking the body which may contain "MFA" in scripts/URLs
if (pageTitle.contains("MFA", Qt::CaseInsensitive)) {
m_lastError = "MFA Required";
qDebug() << "GarminConnect: MFA detected in page title";
// Extract new CSRF token from MFA page - try multiple patterns
QRegularExpression csrfRegex1("name=\"_csrf\"[^>]*value=\"([^\"]+)\"");
QRegularExpression csrfRegex2("value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
QRegularExpressionMatch match = csrfRegex1.match(response);
if (!match.hasMatch()) {
match = csrfRegex2.match(response);
}
if (match.hasMatch()) {
m_csrfToken = match.captured(1);
qDebug() << "GarminConnect: CSRF token from MFA page:" << m_csrfToken.left(20) << "...";
}
// Update cookies
m_cookies = m_manager->cookieJar()->cookiesForUrl(url);
if (!suppressMfaSignal) {
qDebug() << "GarminConnect: Emitting mfaRequired signal";
emit mfaRequired();
} else {
qDebug() << "GarminConnect: MFA required but signal suppressed (retrying with MFA code)";
}
reply->deleteLater();
return false;
}
// Check if login was successful (title is "Success")
if (pageTitle == "Success") {
qDebug() << "GarminConnect: Login successful (Success page detected)";
// Continue to extract ticket below
}
// Check for error messages in response
@@ -549,39 +591,17 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
return false;
}
// Check if MFA is required (legacy check for non-redirect MFA)
if (response.contains("MFA", Qt::CaseInsensitive) ||
response.contains("Enter MFA Code", Qt::CaseInsensitive)) {
m_lastError = "MFA Required";
qDebug() << "GarminConnect: MFA content detected in response";
// Extract new CSRF token from MFA page - try multiple patterns
QRegularExpression csrfRegex1("name=\"_csrf\"[^>]*value=\"([^\"]+)\"");
QRegularExpression csrfRegex2("value=\"([^\"]+)\"[^>]*name=\"_csrf\"");
QRegularExpressionMatch match = csrfRegex1.match(response);
if (!match.hasMatch()) {
match = csrfRegex2.match(response);
}
if (match.hasMatch()) {
m_csrfToken = match.captured(1);
}
// Update cookies
m_cookies = m_manager->cookieJar()->cookiesForUrl(url);
if (!suppressMfaSignal) {
emit mfaRequired();
}
reply->deleteLater();
return false;
}
// Extract ticket from response URL (already declared above)
if (responseUrl.isEmpty()) {
responseUrl = reply->url();
}
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response URL:" << responseUrl.toString();
qDebug() << "GarminConnect: Response length:" << response.length();
qDebug() << "GarminConnect: Full response body:" << response;
}
QUrlQuery responseQuery(responseUrl);
QString ticket = responseQuery.queryItemValue("ticket");
@@ -599,6 +619,8 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
if (match.hasMatch()) {
ticket = match.captured(1);
qDebug() << "GarminConnect: Found ticket with fallback pattern:" << ticket.left(20) << "...";
} else if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: No ticket patterns matched in response body";
}
}
}
@@ -608,6 +630,9 @@ bool GarminConnect::performLogin(const QString &email, const QString &password,
if (ticket.isEmpty()) {
m_lastError = "Failed to extract ticket from login response";
qDebug() << "GarminConnect:" << m_lastError;
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response snippet:" << response.left(1000);
}
return false;
}
@@ -708,8 +733,12 @@ void GarminConnect::handleMfaReplyFinished()
qDebug() << "GarminConnect: MFA response status code:" << statusCode;
qDebug() << "GarminConnect: MFA response redirect URL:" << responseUrl.toString();
// If no redirect, log response body to understand what happened
if (responseUrl.isEmpty()) {
// Log detailed response information
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: MFA response length:" << response.length();
qDebug() << "GarminConnect: Full MFA response body:" << response;
} else if (responseUrl.isEmpty()) {
// If no redirect, log response body to understand what happened (non-verbose)
qDebug() << "GarminConnect: MFA response body (first 500 chars):" << response.left(500);
}
@@ -748,6 +777,9 @@ void GarminConnect::handleMfaReplyFinished()
// If not found in redirect URL, try response body
if (ticket.isEmpty() && !response.isEmpty()) {
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Attempting to extract ticket from MFA response body";
}
// Try multiple patterns for ticket extraction
QRegularExpression ticketRegex1("embed\\?ticket=([^\"]+)\"");
QRegularExpression ticketRegex2("ticket=([^&\"']+)");
@@ -761,6 +793,16 @@ void GarminConnect::handleMfaReplyFinished()
if (match.hasMatch()) {
ticket = match.captured(1);
qDebug() << "GarminConnect: Found ticket in response body (pattern 2):" << ticket.left(20) << "...";
} else if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: No MFA ticket patterns matched. Checking for other patterns...";
// Check for JSON format
if (response.contains("ticket")) {
qDebug() << "GarminConnect: Response contains 'ticket' keyword, may be JSON or different format";
}
// Check for common response patterns
if (response.contains("\"")) {
qDebug() << "GarminConnect: Response contains quoted strings (may be JSON)";
}
}
}
}
@@ -770,6 +812,9 @@ void GarminConnect::handleMfaReplyFinished()
if (ticket.isEmpty()) {
m_lastError = "Failed to extract ticket after MFA";
qDebug() << "GarminConnect:" << m_lastError;
if (DEBUG_GARMIN_VERBOSE) {
qDebug() << "GarminConnect: Response snippet:" << response.left(1000);
}
emit authenticationFailed(m_lastError);
return;
}
@@ -1401,6 +1446,7 @@ void GarminConnect::loadTokensFromSettings()
m_oauth1Token.oauth_token = settings.value(QZSettings::garmin_oauth1_token, QZSettings::default_garmin_oauth1_token).toString();
m_oauth1Token.oauth_token_secret = settings.value(QZSettings::garmin_oauth1_token_secret, QZSettings::default_garmin_oauth1_token_secret).toString();
m_domain = settings.value(QZSettings::garmin_domain, QZSettings::default_garmin_domain).toString();
qDebug() << "GarminConnect: Loaded Garmin domain from settings:" << m_domain;
if (!m_oauth2Token.access_token.isEmpty()) {
qDebug() << "GarminConnect: Loaded tokens from settings (OAuth1 + OAuth2)";

View File

@@ -176,6 +176,7 @@ private:
static constexpr const char* SSO_URL_PATH = "/sso/signin";
static constexpr const char* SSO_EMBED_PATH = "/sso/embed";
static constexpr const char* OAUTH_CONSUMER_URL = "https://thegarth.s3.amazonaws.com/oauth_consumer.json";
static constexpr bool DEBUG_GARMIN_VERBOSE = false; // Set to true for detailed response logging (may contain sensitive data)
// Private methods
QString ssoUrl() const { return QString("https://sso.%1").arg(m_domain); }

View File

@@ -558,6 +558,10 @@ homeform::homeform(QQmlApplicationEngine *engine, bluetooth *bl) {
&homeform::pelotonOffset_Minus);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::gears_Plus, this, &homeform::gearUp);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::gears_Minus, this, &homeform::gearDown);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::speed_Plus, this, &homeform::speedPlus);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::speed_Minus, this, &homeform::speedMinus);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::inclination_Plus, this, &homeform::inclinationPlus);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::inclination_Minus, this, &homeform::inclinationMinus);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::pelotonOffset, this, &homeform::pelotonOffset);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::pelotonAskStart, this, &homeform::pelotonAskStart);
connect(this->innerTemplateManager, &TemplateInfoSenderBuilder::peloton_start_workout, this,
@@ -1610,6 +1614,22 @@ void homeform::gearDown() {
}
}
void homeform::speedPlus() {
Plus(QStringLiteral("speed"));
}
void homeform::speedMinus() {
Minus(QStringLiteral("speed"));
}
void homeform::inclinationPlus() {
Plus(QStringLiteral("inclination"));
}
void homeform::inclinationMinus() {
Minus(QStringLiteral("inclination"));
}
void homeform::ftmsAccessoryConnected(smartspin2k *d) {
connect(this, &homeform::autoResistanceChanged, d, &smartspin2k::autoResistanceChanged);
connect(d, &smartspin2k::gearUp, this, &homeform::gearUp);
@@ -5396,6 +5416,7 @@ void homeform::update() {
double stepCount = 0;
bool miles = settings.value(QZSettings::miles_unit, QZSettings::default_miles_unit).toBool();
bool weight_kg_unit = settings.value(QZSettings::weight_kg_unit, QZSettings::default_weight_kg_unit).toBool();
double ftpSetting = settings.value(QZSettings::ftp, QZSettings::default_ftp).toDouble();
double unit_conversion = 1.0;
double meter_feet_conversion = 1.0;
@@ -5679,7 +5700,7 @@ void homeform::update() {
datetime->setValue(formattedTime);
watts = bluetoothManager->device()->wattsMetricforUI();
watt->setValue(QString::number(watts, 'f', 0));
weightLoss->setValue(QString::number(miles ? bluetoothManager->device()->weightLoss() * 35.274
weightLoss->setValue(QString::number((miles && !weight_kg_unit) ? bluetoothManager->device()->weightLoss() * 35.274
: bluetoothManager->device()->weightLoss(),
'f', 2));

View File

@@ -1056,6 +1056,10 @@ class homeform : public QObject {
void sortTilesTimeout();
void gearUp();
void gearDown();
void speedPlus();
void speedMinus();
void inclinationPlus();
void inclinationMinus();
void changeTimestamp(QTime source, QTime actual);
void pelotonOffset_Plus();
void pelotonOffset_Minus();

View File

@@ -39,6 +39,10 @@
font-size: 24px;
}
.treadmill-only {
display: none;
}
.overlay {
position: absolute;
top: 0;
@@ -73,20 +77,24 @@
<tr class="speed" sort-order="0">
<td class="icon">🏃</td>
<td style="text-align: left">SPEED</td>
<td class="speed-avg-title"><small>AVG</small></td>
<td class="speed-avg">0.0</td>
<td class="speed-avg-title"><small class="non-treadmill">AVG</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="SpeedMinus()">-</button></td>
<td class="speed-avg"><span class="speed-avg-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">AVG</small></span></td>
<td class="speed-value values"><b>0.0</b></td>
<td class="speed-max-title"><small>MAX</small></td>
<td class="speed-max">0.0</td>
<td class="speed-max-title"><small class="non-treadmill">MAX</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="SpeedPlus()">+</button></td>
<td class="speed-max"><span class="speed-max-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">MAX</small></span></td>
</tr>
<tr class="inclination" sort-order="1">
<td class="icon">📐</td>
<td style="text-align: left">INCLINE</td>
<td><small>AVG</small></td>
<td class="inclination-avg">0.0</td>
<td class="inclination-avg-title"><small class="non-treadmill">AVG</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="InclinationMinus()">-</button></td>
<td class="inclination-avg"><span class="inclination-avg-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">AVG</small></span></td>
<td class="inclination-value values"><b>0.0</b></td>
<td><small>MAX</small></td>
<td class="inclination-max">0.0</td>
<td class="inclination-max-title"><small class="non-treadmill">MAX</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="InclinationPlus()">+</button></td>
<td class="inclination-max"><span class="inclination-max-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">MAX</small></span></td>
</tr>
<tr class="pace" sort-order="2">
<td class="icon">🏃</td>
@@ -306,6 +314,62 @@
});
}
function SpeedPlus() {
let el = new MainWSQueueElement({
msg: 'speed_plus',
}, function (msg) {
if (msg.msg === 'R_speed_plus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function SpeedMinus() {
let el = new MainWSQueueElement({
msg: 'speed_minus',
}, function (msg) {
if (msg.msg === 'R_speed_minus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function InclinationPlus() {
let el = new MainWSQueueElement({
msg: 'inclination_plus',
}, function (msg) {
if (msg.msg === 'R_inclination_plus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function InclinationMinus() {
let el = new MainWSQueueElement({
msg: 'inclination_minus',
}, function (msg) {
if (msg.msg === 'R_inclination_minus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function Lap() {
let el = new MainWSQueueElement({
msg: 'lap',
@@ -611,6 +675,7 @@
'speed_color', 'pace_color', 'power_zone_color', 'target_power_zone_color', 'cadence_color', 'heart_color', 'watts_color',
'peloton_resistance_color', 'target_resistance', 'target_peloton_resistance',
'target_cadence', 'target_power', 'peloton_offset', 'peloton_ask_start', 'target_speed', 'target_pace_h', 'target_pace_m', 'target_pace_s',
'deviceType', 'TREADMILL_TYPE',
'inclination', 'inclination_lapavg',
'inclination_lapmax', 'target_inclination', 'power_zone', 'power_zone_lapavg', 'power_zone_lapmax', 'target_power_zone', 'jouls',
'row_remaining_time_s', 'row_remaining_time_m', 'row_remaining_time_h' , 'autoresistance', 'gears', 'elevation', 'pace_s' , 'pace_m',
@@ -673,6 +738,8 @@
var peloton_offset = 0;
var gears = 0;
var nextrow = "";
var deviceType = -1;
var TREADMILL_TYPE = -1;
for (let key of keys_arr) {
if (msg.content[key] === undefined || msg.content[key] === null)
@@ -789,6 +856,10 @@
peloton_offset = msg.content[key];
} else if (key === 'gears') {
gears = msg.content[key];
} else if (key === 'deviceType') {
deviceType = msg.content[key];
} else if (key === 'TREADMILL_TYPE') {
TREADMILL_TYPE = msg.content[key];
} else if (key === 'peloton_resistance_color') {
$('.pelotonresistance-value').css('color', msg.content[key]);
} else if (key === 'heart_color') {
@@ -837,14 +908,24 @@
$('.speed-value').html("<b>" + speed.toFixed(1) + "</b>");
}
$('.speed-avg').html(speed_lapavg.toFixed(1));
$('.speed-max').html(speed_lapmax.toFixed(1));
$('.speed-avg-value').html(speed_lapavg.toFixed(1));
$('.speed-max-value').html(speed_lapmax.toFixed(1));
if (tile_target_inclination_enabled && target_inclination > 0)
$('.inclination-value').html("<b>" + inclination.toFixed(1) + "/" + target_inclination.toFixed(1) + "</b>");
else
$('.inclination-value').html("<b>" + inclination.toFixed(1) + "</b>");
$('.inclination-avg').html(inclination_lapavg.toFixed(1));
$('.inclination-max').html(inclination_lapmax.toFixed(1));
$('.inclination-avg-value').html(inclination_lapavg.toFixed(1));
$('.inclination-max-value').html(inclination_lapmax.toFixed(1));
// Show/hide treadmill-only controls based on device type
if (deviceType === TREADMILL_TYPE && TREADMILL_TYPE !== -1) {
$('.treadmill-only').show();
$('.non-treadmill').hide();
} else {
$('.treadmill-only').hide();
$('.non-treadmill').show();
}
$('.elevation-value').html("<b>" + elevation.toFixed(1) + "</b>");
if (tile_target_cadence_enabled && target_cadence > 0)
$('.cadence-value').html("<b>" + cadence.toFixed(0) + "/" + target_cadence.toFixed(0) + "</b>");

View File

@@ -197,6 +197,10 @@
font-size: 24px;
}
.treadmill-only {
display: none;
}
/* Metric selector panel */
.metric-selector-panel {
display: none;
@@ -247,20 +251,24 @@
<tr class="speed" sort-order="0">
<td class="icon">🏃</td>
<td style="text-align: left">SPEED</td>
<td class="speed-avg-title"><small>AVG</small></td>
<td class="speed-avg">0.0</td>
<td class="speed-avg-title"><small class="non-treadmill">AVG</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="SpeedMinus()">-</button></td>
<td class="speed-avg"><span class="speed-avg-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">AVG</small></span></td>
<td class="speed-value values"><b>0.0</b></td>
<td class="speed-max-title"><small>MAX</small></td>
<td class="speed-max">0.0</td>
<td class="speed-max-title"><small class="non-treadmill">MAX</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="SpeedPlus()">+</button></td>
<td class="speed-max"><span class="speed-max-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">MAX</small></span></td>
</tr>
<tr class="inclination" sort-order="1">
<td class="icon">📐</td>
<td style="text-align: left">INCLINE</td>
<td><small>AVG</small></td>
<td class="inclination-avg">0.0</td>
<td class="inclination-avg-title"><small class="non-treadmill">AVG</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="InclinationMinus()">-</button></td>
<td class="inclination-avg"><span class="inclination-avg-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">AVG</small></span></td>
<td class="inclination-value values"><b>0.0</b></td>
<td><small>MAX</small></td>
<td class="inclination-max">0.0</td>
<td class="inclination-max-title"><small class="non-treadmill">MAX</small><button class="treadmill-only" style="width: 30px; font-size: 18px; color: white; background-color:#4C70BF; border: none; cursor: pointer;"
onclick="InclinationPlus()">+</button></td>
<td class="inclination-max"><span class="inclination-max-value">0.0</span><span class="treadmill-only"><br><small style="font-size: 10px; color: #888;">MAX</small></span></td>
</tr>
<tr class="pace" sort-order="2">
<td class="icon">🏃</td>
@@ -826,6 +834,62 @@
});
}
function SpeedPlus() {
let el = new MainWSQueueElement({
msg: 'speed_plus',
}, function (msg) {
if (msg.msg === 'R_speed_plus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function SpeedMinus() {
let el = new MainWSQueueElement({
msg: 'speed_minus',
}, function (msg) {
if (msg.msg === 'R_speed_minus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function InclinationPlus() {
let el = new MainWSQueueElement({
msg: 'inclination_plus',
}, function (msg) {
if (msg.msg === 'R_inclination_plus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
function InclinationMinus() {
let el = new MainWSQueueElement({
msg: 'inclination_minus',
}, function (msg) {
if (msg.msg === 'R_inclination_minus') {
return msg.content;
}
return null;
}, 15000, 1);
el.enqueue().catch(function (err) {
console.error('Error is ' + err);
});
}
// Function to clear/lap
function Lap() {
let el = new MainWSQueueElement({
@@ -1072,6 +1136,7 @@
'speed_color', 'pace_color', 'power_zone_color', 'target_power_zone_color', 'cadence_color', 'heart_color', 'watts_color',
'peloton_resistance_color', 'target_resistance', 'target_peloton_resistance',
'target_cadence', 'target_power', 'peloton_offset', 'peloton_ask_start', 'target_speed', 'target_pace_h', 'target_pace_m', 'target_pace_s',
'deviceType', 'TREADMILL_TYPE',
'inclination', 'inclination_lapavg',
'inclination_lapmax', 'target_inclination', 'power_zone', 'power_zone_lapavg', 'power_zone_lapmax', 'target_power_zone', 'jouls',
'row_remaining_time_s', 'row_remaining_time_m', 'row_remaining_time_h' , 'autoresistance', 'gears', 'elevation', 'pace_s' , 'pace_m',
@@ -1137,6 +1202,8 @@
var peloton_offset = 0;
var gears = 0;
var nextrow = "";
var deviceType = -1;
var TREADMILL_TYPE = -1;
// Get values from message
for (let key of keys_arr) {
@@ -1255,6 +1322,10 @@
peloton_offset = msg.content[key];
} else if (key === 'gears') {
gears = msg.content[key];
} else if (key === 'deviceType') {
deviceType = msg.content[key];
} else if (key === 'TREADMILL_TYPE') {
TREADMILL_TYPE = msg.content[key];
} else if (key === 'peloton_resistance_color') {
$('.pelotonresistance-value').css('color', msg.content[key]);
} else if (key === 'heart_color') {
@@ -1336,7 +1407,9 @@
target_power: target_power,
peloton_offset: peloton_offset,
gears: gears,
nextrow: nextrow
nextrow: nextrow,
deviceType: deviceType,
TREADMILL_TYPE: TREADMILL_TYPE
});
}
return null;
@@ -1356,8 +1429,8 @@
} else {
$('.speed-value').html("<b>" + data.speed.toFixed(1) + "</b>");
}
$('.speed-avg').html(data.speed_lapavg.toFixed(1));
$('.speed-max').html(data.speed_lapmax.toFixed(1));
$('.speed-avg-value').html(data.speed_lapavg.toFixed(1));
$('.speed-max-value').html(data.speed_lapmax.toFixed(1));
// Inclination
if (tile_target_inclination_enabled && data.target_inclination > 0) {
@@ -1365,8 +1438,17 @@
} else {
$('.inclination-value').html("<b>" + data.inclination.toFixed(1) + "</b>");
}
$('.inclination-avg').html(data.inclination_lapavg.toFixed(1));
$('.inclination-max').html(data.inclination_lapmax.toFixed(1));
$('.inclination-avg-value').html(data.inclination_lapavg.toFixed(1));
$('.inclination-max-value').html(data.inclination_lapmax.toFixed(1));
// Show/hide treadmill-only controls based on device type
if (data.deviceType === data.TREADMILL_TYPE && data.TREADMILL_TYPE !== undefined) {
$('.treadmill-only').show();
$('.non-treadmill').hide();
} else {
$('.treadmill-only').hide();
$('.non-treadmill').show();
}
// Elevation
$('.elevation-value').html("<b>" + data.elevation.toFixed(1) + "</b>");

View File

@@ -71,13 +71,13 @@ viewer.trackedEntity = bike;
</body>
<body>
<div id="cesiumContainer" class="cesiumContainer"></div>
<div id="metricsContainer" style="position: absolute; bottom: 0px; right: 0px; width: 200px; height: 250px; touch-action: none; user-select: none;">
<div class="metrics" style="color: #FFFFFF; width: 100%; height: 100%; margin: 0; border-radius: 25px; border: 2px solid #73AD21; background: #73AD21; padding: 8px; box-sizing: border-box; overflow: hidden; position: relative;">
<div id="metricsContainer" style="position: absolute; bottom: 0px; right: 0px; width: 200px; height: 250px; touch-action: none; user-select: none; z-index: 1000;">
<div class="metrics" style="color: #FFFFFF; width: 100%; height: 100%; margin: 0; border-radius: 25px; border: 2px solid #73AD21; background: #73AD21; padding: 8px; box-sizing: border-box; overflow: hidden; position: relative; touch-action: none;">
<div id="metricsText" style="font-size: 12px; line-height: 1.4;">🏃Speed: 0.00<br>🚴Cadence:0<br>💓Heart:0<br>🔥Calories:0.0<br>📏Odometer:0.00<br>⚡Watt:0<br>Elapsed:0:00:00<br>📐Inclination:0.0<br>🧲Resistance:0<br>Altitude:0.0<br>Elevation:0.0</div>
<div id="resizeHandle" style="position: absolute; bottom: 0; right: 0; width: 20px; height: 20px; background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%); cursor: nwse-resize; border-bottom-right-radius: 23px;"></div>
<div id="resizeHandle" style="position: absolute; bottom: 0; right: 0; width: 50px; height: 50px; background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.8) 50%); cursor: nwse-resize; border-bottom-right-radius: 23px; display: flex; align-items: flex-end; justify-content: flex-end; font-size: 24px; color: rgba(0,0,0,0.6); padding-bottom: 2px; padding-right: 4px;"></div>
</div>
</div>
<div style="border: 0px solid #aaa; border-radius: 10px; overflow: hidden; position:absolute; bottom: 0px; right: 150px; width=150px; height=75px"><canvas id="canvasChart" style="width=150px; height=75px; border-right: 0pt solid #ffff00;"></canvas></div>
<div id="chartContainer" style="border: 0px solid #aaa; border-radius: 10px; overflow: hidden; position:absolute; bottom: 0px; right: 150px; width=150px; height=75px; z-index: 999; touch-action: none;"><canvas id="canvasChart" style="width=150px; height=75px; border-right: 0pt solid #ffff00;"></canvas></div>
<script type="text/javascript">
let cameraComplete = true
let lastAzimuth = 0
@@ -325,12 +325,29 @@ console.error('Error is ' + err);
const container = document.getElementById('metricsContainer');
const resizeHandle = document.getElementById('resizeHandle');
const metricsText = document.getElementById('metricsText');
const chartContainer = document.getElementById('chartContainer');
let isDragging = false;
let isResizing = false;
let startX, startY, startLeft, startTop, startWidth, startHeight;
let resizeTimeout = null;
// Update chart position to follow metrics container
function updateChartPosition() {
// Position chart to the left of metrics container, aligned at bottom
const metricsLeft = container.offsetLeft;
const metricsTop = container.offsetTop;
const metricsHeight = container.offsetHeight;
const chartWidth = chartContainer.offsetWidth;
const chartHeight = chartContainer.offsetHeight;
// Position chart: 10px to the left of metrics, aligned at bottom
chartContainer.style.left = (metricsLeft - chartWidth - 10) + 'px';
chartContainer.style.top = (metricsTop + metricsHeight - chartHeight) + 'px';
chartContainer.style.right = 'auto';
chartContainer.style.bottom = 'auto';
}
// Load saved position and size
function loadState() {
const saved = localStorage.getItem('metricsContainerState');
@@ -343,6 +360,7 @@ console.error('Error is ' + err);
if (state.bottom !== undefined) container.style.bottom = state.bottom + 'px';
if (state.width) container.style.width = state.width + 'px';
if (state.height) container.style.height = state.height + 'px';
updateChartPosition();
updateFontSize();
} catch (e) {
console.error('Error loading metrics state:', e);
@@ -438,6 +456,18 @@ console.error('Error is ' + err);
return { x: e.clientX, y: e.clientY };
}
// Check if touch/click is in resize handle area (bottom-right 50x50px)
function isInResizeHandle(x, y) {
const rect = container.getBoundingClientRect();
const handleSize = 50;
return (
x >= rect.right - handleSize &&
x <= rect.right &&
y >= rect.bottom - handleSize &&
y <= rect.bottom
);
}
// Start dragging
function startDrag(e) {
if (isResizing) return;
@@ -449,6 +479,15 @@ console.error('Error is ' + err);
startTop = container.offsetTop;
container.style.right = 'auto';
container.style.bottom = 'auto';
// Add global listeners
document.addEventListener('mousemove', handleMove);
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('mouseup', endDragOrResize);
document.addEventListener('touchend', endDragOrResize);
document.addEventListener('touchcancel', endDragOrResize);
e.stopPropagation();
e.preventDefault();
}
@@ -460,12 +499,25 @@ console.error('Error is ' + err);
startY = coords.y;
startWidth = container.offsetWidth;
startHeight = container.offsetHeight;
// Add global listeners
document.addEventListener('mousemove', handleMove);
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('mouseup', endDragOrResize);
document.addEventListener('touchend', endDragOrResize);
document.addEventListener('touchcancel', endDragOrResize);
e.stopPropagation();
e.preventDefault();
}
// Handle move
function handleMove(e) {
// Only handle if we're actually dragging or resizing
if (!isDragging && !isResizing) {
return;
}
const coords = getCoordinates(e);
if (isDragging) {
@@ -473,6 +525,8 @@ console.error('Error is ' + err);
const deltaY = coords.y - startY;
container.style.left = (startLeft + deltaX) + 'px';
container.style.top = (startTop + deltaY) + 'px';
updateChartPosition();
e.stopPropagation();
e.preventDefault();
} else if (isResizing) {
const deltaX = coords.x - startX;
@@ -481,17 +535,34 @@ console.error('Error is ' + err);
const newHeight = Math.max(100, startHeight + deltaY);
container.style.width = newWidth + 'px';
container.style.height = newHeight + 'px';
updateChartPosition();
debouncedUpdateFontSize();
e.stopPropagation();
e.preventDefault();
}
}
// End drag or resize
function endDragOrResize(e) {
if (isDragging || isResizing) {
const wasDragging = isDragging;
const wasResizing = isResizing;
// Always reset state first
isDragging = false;
isResizing = false;
// Always remove listeners to prevent leaks
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('mouseup', endDragOrResize);
document.removeEventListener('touchend', endDragOrResize);
document.removeEventListener('touchcancel', endDragOrResize);
// Only save state if we were actually dragging/resizing
if (wasDragging || wasResizing) {
saveState();
// If we were resizing, clear pending timeout and update immediately
if (isResizing) {
if (wasResizing) {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
resizeTimeout = null;
@@ -499,31 +570,49 @@ console.error('Error is ' + err);
updateFontSize();
}
}
isDragging = false;
isResizing = false;
}
// Add event listeners for dragging (on container, but not on resize handle)
// Check if touch/click is inside container
function isInsideContainer(x, y) {
const rect = container.getBoundingClientRect();
return (
x >= rect.left &&
x <= rect.right &&
y >= rect.top &&
y <= rect.bottom
);
}
// Add event listeners for dragging/resizing on container
container.addEventListener('mousedown', function(e) {
if (e.target !== resizeHandle) startDrag(e);
const coords = getCoordinates(e);
// Double check we're actually inside the container
if (!isInsideContainer(coords.x, coords.y)) {
return;
}
if (isInResizeHandle(coords.x, coords.y)) {
startResize(e);
} else {
startDrag(e);
}
});
container.addEventListener('touchstart', function(e) {
if (e.target !== resizeHandle) startDrag(e);
});
// Add event listeners for resizing (on resize handle)
resizeHandle.addEventListener('mousedown', startResize);
resizeHandle.addEventListener('touchstart', startResize);
// Add global move and end listeners
document.addEventListener('mousemove', handleMove);
document.addEventListener('touchmove', handleMove, { passive: false });
document.addEventListener('mouseup', endDragOrResize);
document.addEventListener('touchend', endDragOrResize);
const coords = getCoordinates(e);
// Double check we're actually inside the container
if (!isInsideContainer(coords.x, coords.y)) {
return;
}
if (isInResizeHandle(coords.x, coords.y)) {
startResize(e);
} else {
startDrag(e);
}
}, { passive: false });
// Load saved state and set initial font size
loadState();
updateFontSize();
updateChartPosition();
})();
</script>
</body>

View File

@@ -818,7 +818,8 @@
if (field.max !== undefined) input.max = field.max;
input.value = value !== undefined ? value : '';
}
input.addEventListener(field.type === 'duration' || field.type === 'pace' ? 'change' : 'input', handleFieldChange);
// Use 'change' event for duration, pace, and number fields to prevent keyboard from closing during typing
input.addEventListener(field.type === 'duration' || field.type === 'pace' || field.type === 'number' ? 'change' : 'input', handleFieldChange);
// Add +/- buttons for duration, number, and pace fields
if (field.type === 'duration' || field.type === 'number' || field.type === 'pace') {
@@ -921,7 +922,7 @@
state.intervals[index][field.syncWith] = speed;
}
}
// Re-render to update both fields
// Re-render to update speed field (pace uses 'change' event so keyboard is already closed)
renderIntervals();
updateChart();
updateStatus();
@@ -929,7 +930,7 @@
} else if (type === 'number') {
const raw = target.value;
state.intervals[index][key] = raw === '' ? undefined : Number(raw);
// If this is a speed field, re-render to update pace
// If this is a speed field, re-render to update pace (uses 'change' event so keyboard is already closed)
if (key === 'speed') {
renderIntervals();
}

View File

@@ -38,7 +38,9 @@ class lockscreen {
// virtualrower
void virtualrower_ios();
void virtualrower_ios_pm5(bool pm5Mode);
void virtualrower_setHeartRate(unsigned char heartRate);
void virtualrower_setPM5Mode(bool enabled);
bool virtualrower_updateFTMS(unsigned short normalizeSpeed, unsigned char currentResistance,
unsigned short currentCadence, unsigned short currentWatt,
unsigned short CrankRevolutions, unsigned short LastCrankEventTime,

View File

@@ -226,6 +226,11 @@ void lockscreen::virtualrower_ios()
_virtualrower = [[virtualrower_zwift alloc] init];
}
void lockscreen::virtualrower_ios_pm5(bool pm5Mode)
{
_virtualrower = [[virtualrower_zwift alloc] initWithPm5Mode:pm5Mode];
}
double lockscreen::virtualbike_getCurrentSlope()
{
if(_virtualbike_zwift != nil)
@@ -288,6 +293,12 @@ void lockscreen::virtualrower_setHeartRate(unsigned char heartRate)
[_virtualrower updateHeartRateWithHeartRate:heartRate];
}
void lockscreen::virtualrower_setPM5Mode(bool enabled)
{
if(_virtualrower != nil)
[_virtualrower setPM5ModeWithEnabled:enabled];
}
// virtual treadmill
void lockscreen::virtualtreadmill_zwift_ios(bool garmin_bluetooth_compatibility, bool bike_cadence_sensor)

View File

@@ -2,19 +2,51 @@ import CoreBluetooth
let rowerUuid = CBUUID(string: "0x2AD1");
// PM5 Concept2 UUIDs
let PM5_DISCOVERY_SERVICE_UUID = CBUUID(string: "CE060000-43E5-11E4-916C-0800200C9A66")
let PM5_DEVICE_INFO_SERVICE_UUID = CBUUID(string: "CE060010-43E5-11E4-916C-0800200C9A66")
let PM5_CONTROL_SERVICE_UUID = CBUUID(string: "CE060020-43E5-11E4-916C-0800200C9A66")
let PM5_ROWING_SERVICE_UUID = CBUUID(string: "CE060030-43E5-11E4-916C-0800200C9A66")
// PM5 Device Info characteristics
let PM5_MODEL_UUID = CBUUID(string: "CE060011-43E5-11E4-916C-0800200C9A66")
let PM5_SERIAL_UUID = CBUUID(string: "CE060012-43E5-11E4-916C-0800200C9A66")
let PM5_HARDWARE_REV_UUID = CBUUID(string: "CE060013-43E5-11E4-916C-0800200C9A66")
let PM5_FIRMWARE_REV_UUID = CBUUID(string: "CE060014-43E5-11E4-916C-0800200C9A66")
let PM5_MANUFACTURER_UUID = CBUUID(string: "CE060015-43E5-11E4-916C-0800200C9A66")
let PM5_ERG_MACHINE_TYPE_UUID = CBUUID(string: "CE060016-43E5-11E4-916C-0800200C9A66")
// PM5 Control characteristics
let PM5_CONTROL_RECEIVE_UUID = CBUUID(string: "CE060021-43E5-11E4-916C-0800200C9A66")
let PM5_CONTROL_TRANSMIT_UUID = CBUUID(string: "CE060022-43E5-11E4-916C-0800200C9A66")
// PM5 Rowing characteristics
let PM5_GENERAL_STATUS_UUID = CBUUID(string: "CE060031-43E5-11E4-916C-0800200C9A66")
let PM5_ADDITIONAL_STATUS_UUID = CBUUID(string: "CE060032-43E5-11E4-916C-0800200C9A66")
let PM5_ADDITIONAL_STATUS2_UUID = CBUUID(string: "CE060033-43E5-11E4-916C-0800200C9A66")
let PM5_SAMPLE_RATE_UUID = CBUUID(string: "CE060034-43E5-11E4-916C-0800200C9A66")
let PM5_STROKE_DATA_UUID = CBUUID(string: "CE060035-43E5-11E4-916C-0800200C9A66")
let PM5_ADDITIONAL_STROKE_DATA_UUID = CBUUID(string: "CE060036-43E5-11E4-916C-0800200C9A66")
let PM5_MULTIPLEXED_INFO_UUID = CBUUID(string: "CE060080-43E5-11E4-916C-0800200C9A66")
@objc public class virtualrower_zwift: NSObject {
private var peripheralManager: rowerBLEPeripheralManagerZwift!
@objc public override init() {
super.init()
peripheralManager = rowerBLEPeripheralManagerZwift()
peripheralManager = rowerBLEPeripheralManagerZwift(pm5Mode: false)
}
@objc public init(pm5Mode: Bool) {
super.init()
peripheralManager = rowerBLEPeripheralManagerZwift(pm5Mode: pm5Mode)
}
@objc public func updateHeartRate(HeartRate: UInt8)
{
peripheralManager.heartRate = HeartRate
}
@objc public func readCurrentSlope() -> Double
{
return peripheralManager.CurrentSlope;
@@ -24,7 +56,12 @@ let rowerUuid = CBUUID(string: "0x2AD1");
{
return peripheralManager.PowerRequested;
}
@objc public func setPM5Mode(enabled: Bool)
{
peripheralManager.pm5Mode = enabled
}
@objc public func updateFTMS(normalizeSpeed: UInt16, currentCadence: UInt16, currentResistance: UInt8, currentWatt: UInt16, CrankRevolutions: UInt16, LastCrankEventTime: UInt16, StrokesCount: UInt16, Distance: UInt32, KCal: UInt16, Pace: UInt16 ) -> Bool
{
peripheralManager.NormalizeSpeed = normalizeSpeed
@@ -40,7 +77,7 @@ let rowerUuid = CBUUID(string: "0x2AD1");
return peripheralManager.connected;
}
@objc public func getLastFTMSMessage() -> Data? {
peripheralManager.LastFTMSMessageReceivedAndPassed = peripheralManager.LastFTMSMessageReceived
peripheralManager.LastFTMSMessageReceived?.removeAll()
@@ -72,7 +109,7 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
public var Distance: UInt32! = 0
public var KCal: UInt16! = 0
public var StrokesCount: UInt16! = 0
private var CSCService: CBMutableService!
private var CSCFeatureCharacteristic: CBMutableCharacteristic!
private var SensorLocationCharacteristic: CBMutableCharacteristic!
@@ -80,19 +117,59 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
private var SCControlPointCharacteristic: CBMutableCharacteristic!
public var crankRevolutions: UInt16! = 0
public var lastCrankEventTime: UInt16! = 0
public var LastFTMSMessageReceived: Data?
public var LastFTMSMessageReceivedAndPassed: Data?
public var serviceToggle: UInt8 = 0
public var pm5ServiceToggle: UInt8 = 0
public var connected: Bool = false
private var notificationTimer: Timer! = nil
//var delegate: BLEPeripheralManagerDelegate?
// PM5 Mode
public var pm5Mode: Bool = false
private var startTime: Date = Date()
private var pm5SampleRate: UInt8 = 0x01
// PM5 Services
private var PM5DeviceInfoService: CBMutableService!
private var PM5ControlService: CBMutableService!
private var PM5RowingService: CBMutableService!
// PM5 Device Info Characteristics
private var PM5ModelCharacteristic: CBMutableCharacteristic!
private var PM5SerialCharacteristic: CBMutableCharacteristic!
private var PM5HardwareRevCharacteristic: CBMutableCharacteristic!
private var PM5FirmwareRevCharacteristic: CBMutableCharacteristic!
private var PM5ManufacturerCharacteristic: CBMutableCharacteristic!
private var PM5ErgMachineTypeCharacteristic: CBMutableCharacteristic!
// PM5 Control Characteristics
private var PM5ControlReceiveCharacteristic: CBMutableCharacteristic!
private var PM5ControlTransmitCharacteristic: CBMutableCharacteristic!
// PM5 Rowing Characteristics
private var PM5GeneralStatusCharacteristic: CBMutableCharacteristic!
private var PM5AdditionalStatusCharacteristic: CBMutableCharacteristic!
private var PM5AdditionalStatus2Characteristic: CBMutableCharacteristic!
private var PM5SampleRateCharacteristic: CBMutableCharacteristic!
private var PM5StrokeDataCharacteristic: CBMutableCharacteristic!
private var PM5AdditionalStrokeDataCharacteristic: CBMutableCharacteristic!
private var PM5MultiplexedInfoCharacteristic: CBMutableCharacteristic!
override init() {
super.init()
pm5Mode = false
startTime = Date()
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
init(pm5Mode: Bool) {
super.init()
self.pm5Mode = pm5Mode
startTime = Date()
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
@@ -100,28 +177,42 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
switch peripheral.state {
case .poweredOn:
print("Peripheral manager is up and running")
// Heart Rate Service (always added)
self.heartRateService = CBMutableService(type: heartRateServiceUUID, primary: true)
let characteristicProperties: CBCharacteristicProperties = [.notify, .read, .write]
let characteristicPermissions: CBAttributePermissions = [.readable]
self.heartRateCharacteristic = CBMutableCharacteristic(type: heartRateCharacteristicUUID,
self.heartRateCharacteristic = CBMutableCharacteristic(type: heartRateCharacteristicUUID,
properties: characteristicProperties,
value: nil,
permissions: characteristicPermissions)
heartRateService.characteristics = [heartRateCharacteristic]
self.peripheralManager.add(heartRateService)
if pm5Mode {
// PM5 Mode - Setup PM5 Services
setupPM5Services()
} else {
// FTMS Mode - Original implementation
setupFTMSServices()
}
default:
print("Peripheral manager is down")
}
}
func setupFTMSServices() {
self.FitnessMachineService = CBMutableService(type: FitnessMachineServiceUuid, primary: true)
let FitnessMachineFeatureProperties: CBCharacteristicProperties = [.read]
let FitnessMachineFeaturePermissions: CBAttributePermissions = [.readable]
self.FitnessMachineFeatureCharacteristic = CBMutableCharacteristic(type: FitnessMachineFeatureCharacteristicUuid,
properties: FitnessMachineFeatureProperties,
value: Data (bytes: [0x83, 0x14, 0x00, 0x00, 0x0c, 0xe0, 0x00, 0x00]),
value: Data (bytes: [0x83, 0x14, 0x00, 0x00, 0x0c, 0xe0, 0x00, 0x00]),
permissions: FitnessMachineFeaturePermissions)
let supported_resistance_level_rangeProperties: CBCharacteristicProperties = [.read]
let supported_resistance_level_rangePermissions: CBAttributePermissions = [.readable]
self.supported_resistance_level_rangeCharacteristic = CBMutableCharacteristic(type: supported_resistance_level_rangeCharacteristicUuid,
@@ -142,14 +233,14 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
properties: rowerProperties,
value: nil,
permissions: rowerPermissions)
let FitnessMachinestatusProperties: CBCharacteristicProperties = [.notify]
let FitnessMachinestatusPermissions: CBAttributePermissions = [.readable]
self.FitnessMachinestatusCharacteristic = CBMutableCharacteristic(type: FitnessMachinestatusUuid,
properties: FitnessMachinestatusProperties,
value: nil,
permissions: FitnessMachinestatusPermissions)
let TrainingStatusProperties: CBCharacteristicProperties = [.read]
let TrainingStatusPermissions: CBAttributePermissions = [.readable]
self.TrainingStatusCharacteristic = CBMutableCharacteristic(type: TrainingStatusUuid,
@@ -163,9 +254,9 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
rowerCharacteristic,
FitnessMachinestatusCharacteristic,
TrainingStatusCharacteristic ]
self.peripheralManager.add(FitnessMachineService)
self.CSCService = CBMutableService(type: CSCServiceUUID, primary: true)
let CSCFeatureProperties: CBCharacteristicProperties = [.read]
@@ -201,10 +292,108 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
CSCMeasurementCharacteristic,
SCControlPointCharacteristic]
self.peripheralManager.add(CSCService)
}
default:
print("Peripheral manager is down")
}
func setupPM5Services() {
print("Setting up PM5 services")
// PM5 Device Info Service (CE060010)
self.PM5DeviceInfoService = CBMutableService(type: PM5_DEVICE_INFO_SERVICE_UUID, primary: true)
self.PM5ModelCharacteristic = CBMutableCharacteristic(type: PM5_MODEL_UUID,
properties: [.read],
value: "PM5".data(using: .utf8),
permissions: [.readable])
self.PM5SerialCharacteristic = CBMutableCharacteristic(type: PM5_SERIAL_UUID,
properties: [.read],
value: "430000000".data(using: .utf8),
permissions: [.readable])
self.PM5HardwareRevCharacteristic = CBMutableCharacteristic(type: PM5_HARDWARE_REV_UUID,
properties: [.read],
value: "802".data(using: .utf8),
permissions: [.readable])
self.PM5FirmwareRevCharacteristic = CBMutableCharacteristic(type: PM5_FIRMWARE_REV_UUID,
properties: [.read],
value: "2.18".data(using: .utf8),
permissions: [.readable])
self.PM5ManufacturerCharacteristic = CBMutableCharacteristic(type: PM5_MANUFACTURER_UUID,
properties: [.read],
value: "Concept2".data(using: .utf8),
permissions: [.readable])
self.PM5ErgMachineTypeCharacteristic = CBMutableCharacteristic(type: PM5_ERG_MACHINE_TYPE_UUID,
properties: [.read],
value: Data([0x00]), // 0 = Rower
permissions: [.readable])
PM5DeviceInfoService.characteristics = [PM5ModelCharacteristic, PM5SerialCharacteristic,
PM5HardwareRevCharacteristic, PM5FirmwareRevCharacteristic,
PM5ManufacturerCharacteristic, PM5ErgMachineTypeCharacteristic]
self.peripheralManager.add(PM5DeviceInfoService)
// PM5 Control Service (CE060020)
self.PM5ControlService = CBMutableService(type: PM5_CONTROL_SERVICE_UUID, primary: true)
self.PM5ControlReceiveCharacteristic = CBMutableCharacteristic(type: PM5_CONTROL_RECEIVE_UUID,
properties: [.write, .writeWithoutResponse],
value: nil,
permissions: [.writeable])
self.PM5ControlTransmitCharacteristic = CBMutableCharacteristic(type: PM5_CONTROL_TRANSMIT_UUID,
properties: [.indicate],
value: nil,
permissions: [.readable])
PM5ControlService.characteristics = [PM5ControlReceiveCharacteristic, PM5ControlTransmitCharacteristic]
self.peripheralManager.add(PM5ControlService)
// PM5 Rowing Service (CE060030)
self.PM5RowingService = CBMutableService(type: PM5_ROWING_SERVICE_UUID, primary: true)
self.PM5GeneralStatusCharacteristic = CBMutableCharacteristic(type: PM5_GENERAL_STATUS_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
self.PM5AdditionalStatusCharacteristic = CBMutableCharacteristic(type: PM5_ADDITIONAL_STATUS_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
self.PM5AdditionalStatus2Characteristic = CBMutableCharacteristic(type: PM5_ADDITIONAL_STATUS2_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
self.PM5SampleRateCharacteristic = CBMutableCharacteristic(type: PM5_SAMPLE_RATE_UUID,
properties: [.read, .write],
value: nil,
permissions: [.readable, .writeable])
self.PM5StrokeDataCharacteristic = CBMutableCharacteristic(type: PM5_STROKE_DATA_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
self.PM5AdditionalStrokeDataCharacteristic = CBMutableCharacteristic(type: PM5_ADDITIONAL_STROKE_DATA_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
self.PM5MultiplexedInfoCharacteristic = CBMutableCharacteristic(type: PM5_MULTIPLEXED_INFO_UUID,
properties: [.notify],
value: nil,
permissions: [.readable])
PM5RowingService.characteristics = [PM5GeneralStatusCharacteristic, PM5AdditionalStatusCharacteristic,
PM5AdditionalStatus2Characteristic, PM5SampleRateCharacteristic,
PM5StrokeDataCharacteristic, PM5AdditionalStrokeDataCharacteristic,
PM5MultiplexedInfoCharacteristic]
self.peripheralManager.add(PM5RowingService)
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
@@ -212,10 +401,19 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
print("Failed to add service with error: \(uwError.localizedDescription)")
return
}
let advertisementData = [CBAdvertisementDataLocalNameKey: "QZ",
CBAdvertisementDataServiceUUIDsKey: [heartRateServiceUUID, FitnessMachineServiceUuid, CSCServiceUUID]] as [String : Any]
var advertisementData: [String: Any]
if pm5Mode {
// PM5 advertising - device name + PM5 discovery service UUID
advertisementData = [CBAdvertisementDataLocalNameKey: "PM5 430000000",
CBAdvertisementDataServiceUUIDsKey: [PM5_DISCOVERY_SERVICE_UUID]] as [String : Any]
} else {
// FTMS advertising
advertisementData = [CBAdvertisementDataLocalNameKey: "QZ",
CBAdvertisementDataServiceUUIDsKey: [heartRateServiceUUID, FitnessMachineServiceUuid, CSCServiceUUID]] as [String : Any]
}
peripheralManager.startAdvertising(advertisementData)
print("Successfully added service")
}
@@ -232,6 +430,14 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
if requests.first!.characteristic == self.PM5SampleRateCharacteristic {
if let value = requests.first!.value, value.count > 0 {
self.pm5SampleRate = value[0]
print("PM5 sample rate set to \(self.pm5SampleRate)")
}
self.peripheralManager.respond(to: requests.first!, withResult: .success)
return
}
if requests.first!.characteristic == self.FitnessMachineControlPointCharacteristic {
if(LastFTMSMessageReceived == nil || LastFTMSMessageReceived?.count == 0) {
LastFTMSMessageReceived = requests.first!.value
@@ -274,6 +480,11 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
self.peripheralManager.respond(to: request, withResult: .success)
print("Responded successfully to a read request")
}
else if request.characteristic == self.PM5SampleRateCharacteristic {
request.value = Data([self.pm5SampleRate])
self.peripheralManager.respond(to: request, withResult: .success)
print("Responded successfully to PM5 sample rate read request")
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
@@ -326,10 +537,18 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
}
@objc func updateSubscribers() {
if pm5Mode {
updatePM5Subscribers()
} else {
updateFTMSSubscribers()
}
}
func updateFTMSSubscribers() {
let heartRateData = self.calculateHeartRate()
let rowerData = self.calculateRower()
let cadenceData = self.calculateCadence()
if(self.serviceToggle == 2)
{
let ok = self.peripheralManager.updateValue(cadenceData, for: self.CSCMeasurementCharacteristic, onSubscribedCentrals: nil)
@@ -352,5 +571,337 @@ class rowerBLEPeripheralManagerZwift: NSObject, CBPeripheralManagerDelegate {
}
}
}
func updatePM5Subscribers() {
guard PM5GeneralStatusCharacteristic != nil else { return }
var ok = false
switch pm5ServiceToggle {
case 0:
// Send General Status
let generalStatus = buildPM5GeneralStatus()
ok = self.peripheralManager.updateValue(generalStatus, for: self.PM5GeneralStatusCharacteristic, onSubscribedCentrals: nil)
if ok && PM5MultiplexedInfoCharacteristic != nil {
var muxGeneralStatus = Data([0x31])
muxGeneralStatus.append(generalStatus)
_ = self.peripheralManager.updateValue(muxGeneralStatus, for: self.PM5MultiplexedInfoCharacteristic, onSubscribedCentrals: nil)
}
case 1:
// Send Additional Status
let additionalStatus = buildPM5AdditionalStatus()
ok = self.peripheralManager.updateValue(additionalStatus, for: self.PM5AdditionalStatusCharacteristic, onSubscribedCentrals: nil)
if ok && PM5MultiplexedInfoCharacteristic != nil {
var muxAdditionalStatus = Data([0x32])
muxAdditionalStatus.append(additionalStatus)
_ = self.peripheralManager.updateValue(muxAdditionalStatus, for: self.PM5MultiplexedInfoCharacteristic, onSubscribedCentrals: nil)
}
case 2:
// Send Additional Status 2
let additionalStatus2 = buildPM5AdditionalStatus2()
ok = self.peripheralManager.updateValue(additionalStatus2, for: self.PM5AdditionalStatus2Characteristic, onSubscribedCentrals: nil)
if ok && PM5MultiplexedInfoCharacteristic != nil {
var muxAdditionalStatus2 = Data([0x33])
muxAdditionalStatus2.append(additionalStatus2)
_ = self.peripheralManager.updateValue(muxAdditionalStatus2, for: self.PM5MultiplexedInfoCharacteristic, onSubscribedCentrals: nil)
}
case 3:
// Send Stroke Data
let strokeData = buildPM5StrokeData()
ok = self.peripheralManager.updateValue(strokeData, for: self.PM5StrokeDataCharacteristic, onSubscribedCentrals: nil)
if ok && PM5MultiplexedInfoCharacteristic != nil {
var muxStrokeData = Data([0x35])
muxStrokeData.append(strokeData)
_ = self.peripheralManager.updateValue(muxStrokeData, for: self.PM5MultiplexedInfoCharacteristic, onSubscribedCentrals: nil)
}
case 4:
// Send Additional Stroke Data
let additionalStrokeData = buildPM5AdditionalStrokeData()
ok = self.peripheralManager.updateValue(additionalStrokeData, for: self.PM5AdditionalStrokeDataCharacteristic, onSubscribedCentrals: nil)
if ok && PM5MultiplexedInfoCharacteristic != nil {
var muxAdditionalStrokeData = Data([0x36])
muxAdditionalStrokeData.append(additionalStrokeData)
_ = self.peripheralManager.updateValue(muxAdditionalStrokeData, for: self.PM5MultiplexedInfoCharacteristic, onSubscribedCentrals: nil)
}
default:
break
}
// Advance to next characteristic if update was successful
if ok {
pm5ServiceToggle += 1
if pm5ServiceToggle > 4 {
pm5ServiceToggle = 0
}
}
}
func getElapsedCentiseconds() -> UInt32 {
let elapsed = Date().timeIntervalSince(startTime)
return UInt32(elapsed * 100)
}
func buildPM5GeneralStatus() -> Data {
// 19 bytes
var value = Data(count: 19)
let elapsed = getElapsedCentiseconds()
// Elapsed time (24-bit LE)
value[0] = UInt8(elapsed & 0xFF)
value[1] = UInt8((elapsed >> 8) & 0xFF)
value[2] = UInt8((elapsed >> 16) & 0xFF)
// Distance in 0.1m units (24-bit LE)
let distanceDecimeters = UInt32(Double(Distance) / 100.0) // Distance is in mm, convert to 0.1m
value[3] = UInt8(distanceDecimeters & 0xFF)
value[4] = UInt8((distanceDecimeters >> 8) & 0xFF)
value[5] = UInt8((distanceDecimeters >> 16) & 0xFF)
// Workout Type - 0 = Just Row Free
value[6] = 0x00
// Interval Type - 0 = None
value[7] = 0x00
// Workout State - 1 = Working
value[8] = (CurrentWatt > 0 || CurrentCadence > 0) ? 0x01 : 0x00
// Rowing State - 1 = Active
value[9] = (CurrentWatt > 0 || CurrentCadence > 0) ? 0x01 : 0x00
// Stroke State - 1 = Driving
value[10] = (CurrentWatt > 0) ? 0x01 : 0x00
// Total Work Distance in meters (24-bit LE)
let totalDistanceMeters = UInt32(Double(Distance) / 1000.0)
value[11] = UInt8(totalDistanceMeters & 0xFF)
value[12] = UInt8((totalDistanceMeters >> 8) & 0xFF)
value[13] = UInt8((totalDistanceMeters >> 16) & 0xFF)
// Workout Duration (target) - 0
value[14] = 0x00
value[15] = 0x00
value[16] = 0x00
value[17] = 0x00 // Duration type
value[18] = 110 // Drag factor
return value
}
func buildPM5AdditionalStatus() -> Data {
// 19 bytes
var value = Data(count: 19)
let elapsed = getElapsedCentiseconds()
// Elapsed time (24-bit LE)
value[0] = UInt8(elapsed & 0xFF)
value[1] = UInt8((elapsed >> 8) & 0xFF)
value[2] = UInt8((elapsed >> 16) & 0xFF)
// Speed in 0.001 m/s units (16-bit LE)
let speedMmPerSec = UInt16(Double(NormalizeSpeed) * 1000.0 / 3600.0)
value[3] = UInt8(speedMmPerSec & 0xFF)
value[4] = UInt8((speedMmPerSec >> 8) & 0xFF)
// Stroke Rate (SPM)
value[5] = UInt8(CurrentCadence & 0xFF)
// Heart Rate
value[6] = heartRate
// Current Pace in 0.01 sec/500m (16-bit LE)
value[7] = UInt8(Pace & 0xFF)
value[8] = UInt8((Pace >> 8) & 0xFF)
// Average Pace (same as current)
value[9] = value[7]
value[10] = value[8]
// Rest Distance - 0
value[11] = 0x00
value[12] = 0x00
// Rest Time - 0
value[13] = 0x00
value[14] = 0x00
value[15] = 0x00
// Average Power (16-bit LE)
value[16] = UInt8(CurrentWatt & 0xFF)
value[17] = UInt8((CurrentWatt >> 8) & 0xFF)
// Erg Machine Type - 0 = Rower
value[18] = 0x00
return value
}
func buildPM5AdditionalStatus2() -> Data {
// 20 bytes
var value = Data(count: 20)
let elapsed = getElapsedCentiseconds()
// Elapsed time (24-bit LE)
value[0] = UInt8(elapsed & 0xFF)
value[1] = UInt8((elapsed >> 8) & 0xFF)
value[2] = UInt8((elapsed >> 16) & 0xFF)
// Interval Count
value[3] = 0x01
// Average Power (16-bit LE)
value[4] = UInt8(CurrentWatt & 0xFF)
value[5] = UInt8((CurrentWatt >> 8) & 0xFF)
// Total Calories (16-bit LE)
value[6] = UInt8(KCal & 0xFF)
value[7] = UInt8((KCal >> 8) & 0xFF)
// Split Average Pace (16-bit LE)
value[8] = UInt8(Pace & 0xFF)
value[9] = UInt8((Pace >> 8) & 0xFF)
// Split Average Power (16-bit LE)
value[10] = value[4]
value[11] = value[5]
// Split Average Calories (kCal/hour) - 0
value[12] = 0x00
value[13] = 0x00
// Last Split Time - 0
value[14] = 0x00
value[15] = 0x00
value[16] = 0x00
// Last Split Distance - 0
value[17] = 0x00
value[18] = 0x00
value[19] = 0x00
return value
}
func buildPM5StrokeData() -> Data {
// 20 bytes
var value = Data(count: 20)
let elapsed = getElapsedCentiseconds()
// Elapsed time (24-bit LE)
value[0] = UInt8(elapsed & 0xFF)
value[1] = UInt8((elapsed >> 8) & 0xFF)
value[2] = UInt8((elapsed >> 16) & 0xFF)
// Distance in 0.1m units (24-bit LE)
let distanceDecimeters = UInt32(Double(Distance) / 100.0)
value[3] = UInt8(distanceDecimeters & 0xFF)
value[4] = UInt8((distanceDecimeters >> 8) & 0xFF)
value[5] = UInt8((distanceDecimeters >> 16) & 0xFF)
// Drive Length (0.01m) - typical 1.4m
value[6] = 140
// Drive Time (0.01s) - typical 0.8s
value[7] = 80
// Stroke Recovery Time (16-bit LE, 0.01s)
var recoveryTime: UInt16 = 170 // default 1.7s
if CurrentCadence > 0 {
let strokeTime = 60.0 / Double(CurrentCadence)
let recoveryTimeDouble = max(0.5, strokeTime - 0.8) * 100.0
recoveryTime = UInt16(recoveryTimeDouble)
if recoveryTime < 50 { recoveryTime = 50 }
}
value[8] = UInt8(recoveryTime & 0xFF)
value[9] = UInt8((recoveryTime >> 8) & 0xFF)
// Stroke Distance (16-bit LE, 0.01m)
var strokeDistance: UInt16 = 1000 // default 10m
if CurrentCadence > 0 && NormalizeSpeed > 0 {
let speedMs = Double(NormalizeSpeed) / 3.6
let strokeTime = 60.0 / Double(CurrentCadence)
strokeDistance = UInt16(speedMs * strokeTime * 100.0)
}
value[10] = UInt8(strokeDistance & 0xFF)
value[11] = UInt8((strokeDistance >> 8) & 0xFF)
// Peak Drive Force (16-bit LE, 0.1 lbs) - estimate from power
var peakForce: UInt16 = 0
if CurrentWatt > 0 && NormalizeSpeed > 0 {
let speedMs = Double(NormalizeSpeed) / 3.6
let forceN = Double(CurrentWatt) / speedMs
peakForce = UInt16(forceN * 0.2248 * 10.0 * 1.5)
}
value[12] = UInt8(peakForce & 0xFF)
value[13] = UInt8((peakForce >> 8) & 0xFF)
// Average Drive Force (16-bit LE, 0.1 lbs)
let avgForce = peakForce / 2
value[14] = UInt8(avgForce & 0xFF)
value[15] = UInt8((avgForce >> 8) & 0xFF)
// Work Per Stroke (16-bit LE, Joules)
var workPerStroke: UInt16 = 0
if CurrentCadence > 0 && CurrentWatt > 0 {
let strokeTime = 60.0 / Double(CurrentCadence)
workPerStroke = UInt16(Double(CurrentWatt) * strokeTime)
}
value[16] = UInt8(workPerStroke & 0xFF)
value[17] = UInt8((workPerStroke >> 8) & 0xFF)
// Stroke Count (16-bit LE)
value[18] = UInt8(StrokesCount & 0xFF)
value[19] = UInt8((StrokesCount >> 8) & 0xFF)
return value
}
func buildPM5AdditionalStrokeData() -> Data {
// 17 bytes
var value = Data(count: 17)
let elapsed = getElapsedCentiseconds()
// Elapsed time (24-bit LE)
value[0] = UInt8(elapsed & 0xFF)
value[1] = UInt8((elapsed >> 8) & 0xFF)
value[2] = UInt8((elapsed >> 16) & 0xFF)
// Stroke Power (16-bit LE, watts)
value[3] = UInt8(CurrentWatt & 0xFF)
value[4] = UInt8((CurrentWatt >> 8) & 0xFF)
// Stroke Calories (16-bit LE)
var strokeCalories: UInt16 = 0
if CurrentCadence > 0 && StrokesCount > 0 {
strokeCalories = UInt16(Double(KCal) * 1000.0 / Double(StrokesCount))
}
value[5] = UInt8(strokeCalories & 0xFF)
value[6] = UInt8((strokeCalories >> 8) & 0xFF)
// Stroke Count (16-bit LE)
value[7] = UInt8(StrokesCount & 0xFF)
value[8] = UInt8((StrokesCount >> 8) & 0xFF)
// Projected Work Time - 0
value[9] = 0x00
value[10] = 0x00
value[11] = 0x00
// Projected Work Distance - 0
value[12] = 0x00
value[13] = 0x00
value[14] = 0x00
// Work Per Stroke (16-bit LE, Joules)
var workPerStroke: UInt16 = 0
if CurrentCadence > 0 && CurrentWatt > 0 {
let strokeTime = 60.0 / Double(CurrentCadence)
workPerStroke = UInt16(Double(CurrentWatt) * strokeTime)
}
value[15] = UInt8(workPerStroke & 0xFF)
value[16] = UInt8((workPerStroke >> 8) & 0xFF)
return value
}
} /// class-end

View File

@@ -657,7 +657,7 @@ int main(int argc, char *argv[]) {
qInstallMessageHandler(myMessageOutput);
qDebug() << QStringLiteral("version ") << app->applicationVersion();
foreach (QString s, settings.allKeys()) {
if (!s.contains(QStringLiteral("password")) && !s.contains("user_email") && !s.contains("username") && !s.contains("token")) {
if (!s.contains(QStringLiteral("password")) && !s.contains("user_email") && !s.contains("username") && !s.contains("token") && !s.contains("garmin_device_serial") && !s.contains("garmin_email")) {
qDebug() << s << settings.value(s);
}

View File

@@ -935,7 +935,7 @@ ApplicationWindow {
}
ItemDelegate {
text: "version 2.20.23"
text: "version 2.20.26"
width: parent.width
}

View File

@@ -101,11 +101,12 @@ SOURCES += \
$$PWD/devices/pitpatbike/pitpatbike.cpp \
$$PWD/devices/speraxtreadmill/speraxtreadmill.cpp \
$$PWD/devices/sportsplusrower/sportsplusrower.cpp \
$$PWD/devices/mobirower/mobirower.cpp \
$$PWD/devices/sportstechelliptical/sportstechelliptical.cpp \
$$PWD/devices/sramAXSController/sramAXSController.cpp \
$$PWD/devices/thinkridercontroller/thinkridercontroller.cpp \
$$PWD/devices/stairclimber.cpp \
$$PWD/devices/echelonstairclimber/echelonstairclimber.cpp \
$$PWD/devices/sunnyfitstepper/sunnyfitstepper.cpp \
$$PWD/devices/technogymbike/technogymbike.cpp \
$$PWD/devices/trxappgateusbelliptical/trxappgateusbelliptical.cpp \
$$PWD/fitdatabaseprocessor.cpp \
@@ -367,6 +368,7 @@ HEADERS += \
$$PWD/devices/cycleopsphantombike/cycleopsphantombike.h \
$$PWD/devices/deeruntreadmill/deerruntreadmill.h \
$$PWD/devices/echelonstairclimber/echelonstairclimber.h \
$$PWD/devices/sunnyfitstepper/sunnyfitstepper.h \
$$PWD/devices/elitesquarecontroller/elitesquarecontroller.h \
$$PWD/devices/focustreadmill/focustreadmill.h \
$$PWD/devices/jumprope.h \
@@ -379,9 +381,9 @@ HEADERS += \
$$PWD/devices/pitpatbike/pitpatbike.h \
$$PWD/devices/speraxtreadmill/speraxtreadmill.h \
$$PWD/devices/sportsplusrower/sportsplusrower.h \
$$PWD/devices/mobirower/mobirower.h \
$$PWD/devices/sportstechelliptical/sportstechelliptical.h \
$$PWD/devices/sramAXSController/sramAXSController.h \
$$PWD/devices/thinkridercontroller/thinkridercontroller.h \
$$PWD/devices/stairclimber.h \
$$PWD/devices/technogymbike/technogymbike.h \
$$PWD/devices/trxappgateusbelliptical/trxappgateusbelliptical.h \
@@ -1006,4 +1008,4 @@ INCLUDEPATH += purchasing/inapp
WINRT_MANIFEST = AppxManifest.xml
VERSION = 2.20.23
VERSION = 2.20.26

View File

@@ -389,6 +389,9 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
sessionMesg.SetTotalMovingTime(session.last().elapsedTime);
sessionMesg.SetTotalAscent(session.last().elevationGain); // Total elevation gain (meters)
sessionMesg.SetTotalDescent(session.last().negativeElevationGain); // Total elevation loss/descent (meters)
if (speed_avg > 0) {
sessionMesg.SetAvgSpeed(speed_avg / 3.6); // Convert from km/h to m/s
}
sessionMesg.SetMinAltitude(min_alt);
sessionMesg.SetMaxAltitude(max_alt);
sessionMesg.SetEvent(FIT_EVENT_SESSION);
@@ -454,7 +457,7 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
sessionMesg.SetAvgStrokeDistance(session.last().avgStrokesLength);
} else if (type == STAIRCLIMBER) {
sessionMesg.SetSport(FIT_SPORT_GENERIC);
sessionMesg.SetSport(FIT_SPORT_FITNESS_EQUIPMENT);
sessionMesg.SetSubSport(FIT_SUB_SPORT_STAIR_CLIMBING);
} else if (type == JUMPROPE) {
@@ -699,7 +702,7 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
lapMesg.SetSport(FIT_SPORT_JUMP_ROPE);
} else if (type == STAIRCLIMBER) {
lapMesg.SetSport(FIT_SPORT_GENERIC);
lapMesg.SetSport(FIT_SPORT_FITNESS_EQUIPMENT);
lapMesg.SetSubSport(FIT_SUB_SPORT_STAIR_CLIMBING);
} else {
@@ -814,7 +817,7 @@ void qfit::save(const QString &filename, QList<SessionLine> session, BLUETOOTH_T
lapMesg.SetMessageIndex(lap_index++);
lapMesg.SetLapTrigger(FIT_LAP_TRIGGER_DISTANCE);
if (type == JUMPROPE)
lapMesg.SetRepetitionNum(session.at(i - 1).inclination);
lapMesg.SetRepetitionNum(lap_index);
lastLapTimer = sl.elapsedTime;
lastLapOdometer = sl.distance;

View File

@@ -92,6 +92,7 @@ const QString QZSettings::default_user_email = QLatin1String("");
const QString QZSettings::user_nickname = QStringLiteral("user_nickname");
const QString QZSettings::default_user_nickname = QStringLiteral("");
const QString QZSettings::miles_unit = QStringLiteral("miles_unit");
const QString QZSettings::weight_kg_unit = QStringLiteral("weight_kg_unit");
const QString QZSettings::pause_on_start = QStringLiteral("pause_on_start");
const QString QZSettings::treadmill_force_speed = QStringLiteral("treadmill_force_speed");
const QString QZSettings::pause_on_start_treadmill = QStringLiteral("pause_on_start_treadmill");
@@ -355,6 +356,7 @@ const QString QZSettings::virtual_device_onlyheart = QStringLiteral("virtual_dev
const QString QZSettings::virtual_device_echelon = QStringLiteral("virtual_device_echelon");
const QString QZSettings::virtual_device_ifit = QStringLiteral("virtual_device_ifit");
const QString QZSettings::virtual_device_rower = QStringLiteral("virtual_device_rower");
const QString QZSettings::virtual_device_rower_pm5 = QStringLiteral("virtual_device_rower_pm5");
const QString QZSettings::virtual_device_force_bike = QStringLiteral("virtual_device_force_bike");
const QString QZSettings::virtual_device_force_treadmill = QStringLiteral("virtual_device_force_treadmill");
const QString QZSettings::volume_change_gears = QStringLiteral("volume_change_gears");
@@ -776,6 +778,7 @@ const QString QZSettings::proform_treadmill_505_cst = QStringLiteral("proform_tr
const QString QZSettings::nordictrack_treadmill_t8_5s = QStringLiteral("nordictrack_treadmill_t8_5s");
const QString QZSettings::proform_treadmill_705_cst = QStringLiteral("proform_treadmill_705_cst");
const QString QZSettings::zwift_click = QStringLiteral("zwift_click");
const QString QZSettings::thinkrider_controller = QStringLiteral("thinkrider_controller");
const QString QZSettings::hop_sport_hs_090h_bike = QStringLiteral("hop_sport_hs_090h_bike");
const QString QZSettings::zwift_play = QStringLiteral("zwift_play");
const QString QZSettings::zwift_play_vibration = QStringLiteral("zwift_play_vibration");
@@ -1045,12 +1048,13 @@ const QString QZSettings::confirm_stop_workout = QStringLiteral("confirm_stop_wo
const QString QZSettings::height = QStringLiteral("height");
const QString QZSettings::taurua_ic90 = QStringLiteral("taurua_ic90");
const QString QZSettings::proform_csx210 = QStringLiteral("proform_csx210");
const QString QZSettings::nordictrack_vr21 = QStringLiteral("nordictrack_vr21");
const QString QZSettings::skandika_wiri_x2000_protocol = QStringLiteral("skandika_wiri_x2000_protocol");
const QString QZSettings::trainprogram_auto_lap_on_segment = QStringLiteral("trainprogram_auto_lap_on_segment");
const QString QZSettings::kingsmith_r2_enable_hw_buttons = QStringLiteral("kingsmith_r2_enable_hw_buttons");
const uint32_t allSettingsCount = 856;
const uint32_t allSettingsCount = 860;
QVariant allSettings[allSettingsCount][2] = {
{QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles},
@@ -1110,6 +1114,7 @@ QVariant allSettings[allSettingsCount][2] = {
{QZSettings::user_email, QZSettings::default_user_email},
{QZSettings::user_nickname, QZSettings::default_user_nickname},
{QZSettings::miles_unit, QZSettings::default_miles_unit},
{QZSettings::weight_kg_unit, QZSettings::default_weight_kg_unit},
{QZSettings::pause_on_start, QZSettings::default_pause_on_start},
{QZSettings::treadmill_force_speed, QZSettings::default_treadmill_force_speed},
{QZSettings::pause_on_start_treadmill, QZSettings::default_pause_on_start_treadmill},
@@ -1339,6 +1344,7 @@ QVariant allSettings[allSettingsCount][2] = {
{QZSettings::virtual_device_echelon, QZSettings::default_virtual_device_echelon},
{QZSettings::virtual_device_ifit, QZSettings::default_virtual_device_ifit},
{QZSettings::virtual_device_rower, QZSettings::default_virtual_device_rower},
{QZSettings::virtual_device_rower_pm5, QZSettings::default_virtual_device_rower_pm5},
{QZSettings::virtual_device_force_bike, QZSettings::default_virtual_device_force_bike},
{QZSettings::virtual_device_force_treadmill, QZSettings::default_virtual_device_force_treadmill},
{QZSettings::volume_change_gears, QZSettings::default_volume_change_gears},
@@ -1696,6 +1702,7 @@ QVariant allSettings[allSettingsCount][2] = {
{QZSettings::nordictrack_treadmill_t8_5s, QZSettings::default_nordictrack_treadmill_t8_5s},
{QZSettings::proform_treadmill_705_cst, QZSettings::default_proform_treadmill_705_cst},
{QZSettings::zwift_click, QZSettings::default_zwift_click},
{QZSettings::thinkrider_controller, QZSettings::default_thinkrider_controller},
{QZSettings::hop_sport_hs_090h_bike, QZSettings::default_hop_sport_hs_090h_bike},
{QZSettings::zwift_play, QZSettings::default_zwift_play},
{QZSettings::zwift_play_vibration, QZSettings::default_zwift_play_vibration},

View File

@@ -272,6 +272,12 @@ class QZSettings {
static const QString miles_unit;
static constexpr bool default_miles_unit = false;
/**
*@brief Use kg for weight even when miles_unit is true (for UK users).
*/
static const QString weight_kg_unit;
static constexpr bool default_weight_kg_unit = false;
static const QString pause_on_start;
static constexpr bool default_pause_on_start = false;
@@ -1052,6 +1058,12 @@ class QZSettings {
*/
static const QString virtual_device_rower;
static constexpr bool default_virtual_device_rower = false;
/**
*@brief When virtual_device_rower is enabled, use the Concept2 PM5 protocol instead of FTMS.
* This enables compatibility with apps like Mywhoosh that only support PM5 rowers.
*/
static const QString virtual_device_rower_pm5;
static constexpr bool default_virtual_device_rower_pm5 = false;
/**
*@brief Used to force a non-bike device to be presented to client apps as a bike.
*/
@@ -2140,6 +2152,9 @@ class QZSettings {
static const QString zwift_click;
static constexpr bool default_zwift_click = false;
static const QString thinkrider_controller;
static constexpr bool default_thinkrider_controller = false;
static const QString proform_treadmill_705_cst;
static constexpr bool default_proform_treadmill_705_cst = false;
@@ -2855,6 +2870,9 @@ class QZSettings {
static const QString proform_csx210;
static constexpr bool default_proform_csx210 = false;
static const QString nordictrack_vr21;
static constexpr bool default_nordictrack_vr21 = false;
/**
* @brief Enable X-2000 protocol for Skandika Wiri bike (true for X-2000, false for standard protocol)
*/

View File

@@ -1273,6 +1273,10 @@ import Qt.labs.platform 1.1
property bool kingsmith_r2_enable_hw_buttons: false
property bool treadmill_direct_distance: false
property bool domyos_treadmill_ts100: false
property bool thinkrider_controller: false
property bool weight_kg_unit: false
property bool virtual_device_rower_pm5: false
property bool nordictrack_vr21: false
}
@@ -1359,12 +1363,12 @@ import Qt.labs.platform 1.1
spacing: 10
Label {
id: labelWeight
text: qsTr("Player Weight") + "(" + (settings.miles_unit?"lbs":"kg") + ")"
text: qsTr("Player Weight") + "(" + ((settings.miles_unit && !settings.weight_kg_unit)?"lbs":"kg") + ")"
Layout.fillWidth: true
}
TextField {
id: weightTextField
text: (settings.miles_unit?settings.weight * 2.20462:settings.weight)
text: ((settings.miles_unit && !settings.weight_kg_unit)?settings.weight * 2.20462:settings.weight)
horizontalAlignment: Text.AlignRight
Layout.fillHeight: false
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
@@ -1376,11 +1380,11 @@ import Qt.labs.platform 1.1
id: okWeightButton
text: "OK"
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
onClicked: { settings.weight = (settings.miles_unit?weightTextField.text / 2.20462:weightTextField.text); toast.show("Setting saved!"); }
onClicked: { settings.weight = ((settings.miles_unit && !settings.weight_kg_unit)?weightTextField.text / 2.20462:weightTextField.text); toast.show("Setting saved!"); }
}
}
Label {
text: qsTr("Enter your weight in kilograms so QZ can more accurately calculate calories burned. NOTE: If you choose to use miles as the unit for distance traveled, you will be asked to enter your weight in pounds (lbs).")
text: qsTr("Enter your weight in kilograms so QZ can more accurately calculate calories burned. NOTE: If you choose to use miles as the unit for distance traveled, you will be asked to enter your weight in pounds (lbs) unless you enable 'Use kg for weight'.")
font.bold: true
font.italic: true
font.pixelSize: Qt.application.font.pixelSize - 2
@@ -1706,6 +1710,36 @@ import Qt.labs.platform 1.1
color: Material.color(Material.Lime)
}
IndicatorOnlySwitch {
id: weightKgUnitDelegate
text: qsTr("Use kg for weight")
spacing: 0
bottomPadding: 0
topPadding: 0
rightPadding: 0
leftPadding: 0
clip: false
checked: settings.weight_kg_unit
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
onClicked: settings.weight_kg_unit = checked
visible: settings.miles_unit
}
Label {
text: qsTr("Turn on if you want to use kilograms (kg) for weight instead of pounds (lbs). Useful for UK users who use miles for distance but kg for weight.")
font.bold: true
font.italic: true
font.pixelSize: Qt.application.font.pixelSize - 2
textFormat: Text.PlainText
wrapMode: Text.WordWrap
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
color: Material.color(Material.Lime)
visible: settings.miles_unit
}
IndicatorOnlySwitch {
id: pauseOnStartDelegate
text: qsTr("Pause when App Starts")
@@ -2498,12 +2532,12 @@ import Qt.labs.platform 1.1
spacing: 10
Label {
id: labelBikeWeight
text: qsTr("Bike Weight") + "(" + (settings.miles_unit?"lbs":"kg") + ")"
text: qsTr("Bike Weight") + "(" + ((settings.miles_unit && !settings.weight_kg_unit)?"lbs":"kg") + ")"
Layout.fillWidth: true
}
TextField {
id: bikeweightTextField
text: (settings.miles_unit?settings.bike_weight * 2.20462:settings.bike_weight)
text: ((settings.miles_unit && !settings.weight_kg_unit)?settings.bike_weight * 2.20462:settings.bike_weight)
horizontalAlignment: Text.AlignRight
Layout.fillHeight: false
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
@@ -2515,12 +2549,12 @@ import Qt.labs.platform 1.1
id: okBikeWeightButton
text: "OK"
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
onClicked: { settings.bike_weight = (settings.miles_unit?bikeweightTextField.text / 2.20462:bikeweightTextField.text); toast.show("Setting saved!"); }
onClicked: { settings.bike_weight = ((settings.miles_unit && !settings.weight_kg_unit)?bikeweightTextField.text / 2.20462:bikeweightTextField.text); toast.show("Setting saved!"); }
}
}
Label {
text: qsTr("Enables QZ to include the weight of your bike when calculating speed. For example, if you are competing against yourself on VZfit, adding bike weight will level the playing field against your virtual self. If you have set QZ to calculate distance in miles, enter the bike weight in pounds (lbs). Default unit is kilograms (kgs).")
text: qsTr("Enables QZ to include the weight of your bike when calculating speed. For example, if you are competing against yourself on VZfit, adding bike weight will 'level the playing field' against your virtual self. If you have set QZ to calculate distance in miles, enter the bike weight in pounds (lbs) unless you enable 'Use kg for weight'. Default unit is kilograms (kgs).")
font.bold: true
font.italic: true
font.pixelSize: Qt.application.font.pixelSize - 2
@@ -4170,7 +4204,8 @@ import Qt.labs.platform 1.1
"TDF 1.0 PFEVEX71316.0",
"Proform XBike",
"Proform 225 CSX PFEX32925 INT.0",
"Proform CSX210"
"Proform CSX210",
"NordicTrack VR21"
]
// Initialize when the accordion content becomes visible
@@ -4206,7 +4241,8 @@ import Qt.labs.platform 1.1
settings.proform_bike_PFEVEX71316_0 ? 16 :
settings.proform_xbike ? 17 :
settings.proform_225_csx_PFEX32925_INT_0 ? 18 :
settings.proform_csx210 ? 19 : 0;
settings.proform_csx210 ? 19 :
settings.nordictrack_vr21 ? 20 : 0;
console.log("bikeModelComboBox selected model: " + selectedModel);
if (selectedModel >= 0) {
@@ -4240,6 +4276,7 @@ import Qt.labs.platform 1.1
settings.proform_xbike = false;
settings.proform_225_csx_PFEX32925_INT_0 = false;
settings.proform_csx210 = false;
settings.nordictrack_vr21 = false;
// Set corresponding setting for selected model
switch (currentIndex) {
@@ -4262,6 +4299,7 @@ import Qt.labs.platform 1.1
case 17: settings.proform_xbike = true; break;
case 18: settings.proform_225_csx_PFEX32925_INT_0 = true; break;
case 19: settings.proform_csx210 = true; break;
case 20: settings.nordictrack_vr21 = true; break;
}
window.settings_restart_to_apply = true;
@@ -6726,6 +6764,29 @@ import Qt.labs.platform 1.1
}
}
RowLayout {
spacing: 10
Label {
text: qsTr("Garmin Server:")
Layout.fillWidth: true
}
ComboBox {
id: garminServerComboBox
Layout.fillHeight: false
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
model: ["Global (garmin.com)", "China (garmin.cn)"]
currentIndex: settings.garmin_domain === "garmin.cn" ? 1 : 0
onCurrentIndexChanged: {
var newDomain = currentIndex === 1 ? "garmin.cn" : "garmin.com";
if (newDomain !== settings.garmin_domain) {
rootItem.garmin_connect_logout();
settings.garmin_domain = newDomain;
window.settings_restart_to_apply = true;
}
}
}
}
Button {
text: "Test Garmin Login"
Layout.alignment: Qt.AlignHCenter
@@ -12744,6 +12805,43 @@ import Qt.labs.platform 1.1
}
}*/
AccordionElement {
title: qsTr("Thinkrider Options")
indicatRectColor: Material.color(Material.Grey)
textColor: Material.color(Material.Yellow)
color: Material.backgroundColor
accordionContent: ColumnLayout {
spacing: 0
IndicatorOnlySwitch {
text: qsTr("Thinkrider Controller")
spacing: 0
bottomPadding: 0
topPadding: 0
rightPadding: 0
leftPadding: 0
clip: false
checked: settings.thinkrider_controller
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
onClicked: { settings.thinkrider_controller = checked; window.settings_restart_to_apply = true; }
}
Label {
text: qsTr("Thinkrider VS200 remote controller. Use it to change gears on QZ!")
font.bold: true
font.italic: true
font.pixelSize: Qt.application.font.pixelSize - 2
textFormat: Text.PlainText
wrapMode: Text.WordWrap
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
color: Material.color(Material.Lime)
}
}
}
AccordionElement {
title: qsTr("Zwift Devices Options")
indicatRectColor: Material.color(Material.Grey)
@@ -13262,6 +13360,36 @@ import Qt.labs.platform 1.1
color: Material.color(Material.Lime)
}
IndicatorOnlySwitch {
id: virtualDeviceRowerPm5Delegate
text: qsTr("Virtual Rower as PM5")
spacing: 0
bottomPadding: 0
topPadding: 0
rightPadding: 0
leftPadding: 0
clip: false
checked: settings.virtual_device_rower_pm5
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
visible: settings.virtual_device_rower
onClicked: { settings.virtual_device_rower_pm5 = checked; window.settings_restart_to_apply = true; }
}
Label {
text: qsTr("When enabled, the virtual rower will use the Concept2 PM5 protocol instead of FTMS. This provides compatibility with apps like Mywhoosh that only support PM5 rowers. Default is off.")
font.bold: true
font.italic: true
font.pixelSize: Qt.application.font.pixelSize - 2
textFormat: Text.PlainText
wrapMode: Text.WordWrap
verticalAlignment: Text.AlignVCenter
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.fillWidth: true
visible: settings.virtual_device_rower
color: Material.color(Material.Lime)
}
IndicatorOnlySwitch {
id: virtualDeviceForceTreadmillDelegate
text: qsTr("Force Virtual Treadmill")

View File

@@ -1084,6 +1084,42 @@ void TemplateInfoSenderBuilder::onGearsMinus(const QJsonValue &msgContent, Templ
tempSender->send(out.toJson());
}
void TemplateInfoSenderBuilder::onSpeedPlus(const QJsonValue &msgContent, TemplateInfoSender *tempSender) {
Q_UNUSED(msgContent);
QJsonObject main, outObj;
emit speed_Plus();
main[QStringLiteral("msg")] = QStringLiteral("R_speed_plus");
QJsonDocument out(main);
tempSender->send(out.toJson());
}
void TemplateInfoSenderBuilder::onSpeedMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender) {
Q_UNUSED(msgContent);
QJsonObject main, outObj;
emit speed_Minus();
main[QStringLiteral("msg")] = QStringLiteral("R_speed_minus");
QJsonDocument out(main);
tempSender->send(out.toJson());
}
void TemplateInfoSenderBuilder::onInclinationPlus(const QJsonValue &msgContent, TemplateInfoSender *tempSender) {
Q_UNUSED(msgContent);
QJsonObject main, outObj;
emit inclination_Plus();
main[QStringLiteral("msg")] = QStringLiteral("R_inclination_plus");
QJsonDocument out(main);
tempSender->send(out.toJson());
}
void TemplateInfoSenderBuilder::onInclinationMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender) {
Q_UNUSED(msgContent);
QJsonObject main, outObj;
emit inclination_Minus();
main[QStringLiteral("msg")] = QStringLiteral("R_inclination_minus");
QJsonDocument out(main);
tempSender->send(out.toJson());
}
void TemplateInfoSenderBuilder::onPelotonStartWorkout(const QJsonValue &msgContent, TemplateInfoSender *tempSender) {
Q_UNUSED(msgContent);
QJsonObject main, outObj;
@@ -1255,6 +1291,18 @@ void TemplateInfoSenderBuilder::onDataReceived(const QByteArray &data) {
} else if (msg == QStringLiteral("gears_minus")) {
onGearsMinus(jsonObject[QStringLiteral("content")], sender);
return;
} else if (msg == QStringLiteral("speed_plus")) {
onSpeedPlus(jsonObject[QStringLiteral("content")], sender);
return;
} else if (msg == QStringLiteral("speed_minus")) {
onSpeedMinus(jsonObject[QStringLiteral("content")], sender);
return;
} else if (msg == QStringLiteral("inclination_plus")) {
onInclinationPlus(jsonObject[QStringLiteral("content")], sender);
return;
} else if (msg == QStringLiteral("inclination_minus")) {
onInclinationMinus(jsonObject[QStringLiteral("content")], sender);
return;
} else if (msg == QStringLiteral("peloton_start_workout")) {
onPelotonStartWorkout(jsonObject[QStringLiteral("content")], sender);
return;

View File

@@ -34,6 +34,10 @@ class TemplateInfoSenderBuilder : public QObject {
void pelotonOffset_Minus();
void gears_Plus();
void gears_Minus();
void speed_Plus();
void speed_Minus();
void inclination_Plus();
void inclination_Minus();
int pelotonOffset();
bool pelotonAskStart();
void peloton_start_workout();
@@ -79,6 +83,10 @@ class TemplateInfoSenderBuilder : public QObject {
void onPelotonOffsetMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onGearsPlus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onGearsMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onSpeedPlus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onSpeedMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onInclinationPlus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onInclinationMinus(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onPelotonStartWorkout(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onPelotonAbortWorkout(const QJsonValue &msgContent, TemplateInfoSender *tempSender);
void onFloatingClose(const QJsonValue &msgContent, TemplateInfoSender *tempSender);

File diff suppressed because it is too large Load Diff

View File

@@ -40,12 +40,22 @@ class virtualrower : public virtualdevice {
QLowEnergyService *serviceHR = nullptr;
QLowEnergyService *serviceBattery = nullptr;
QLowEnergyService *serviceFIT = nullptr;
QLowEnergyService *servicePM5Rowing = nullptr;
QLowEnergyService *servicePM5DeviceInfo = nullptr;
QLowEnergyService *servicePM5GAP = nullptr;
QLowEnergyService *servicePM5Control = nullptr;
QLowEnergyAdvertisingData advertisingData;
QLowEnergyServiceData serviceDataHR;
QLowEnergyServiceData serviceDataFIT;
QLowEnergyServiceData serviceDataPM5Rowing;
QLowEnergyServiceData serviceDataPM5DeviceInfo;
QLowEnergyServiceData serviceDataPM5GAP;
QLowEnergyServiceData serviceDataPM5Control;
QTimer rowerTimer;
bluetoothdevice *Rower;
bool pm5Mode = false;
uint16_t lastWheelTime = 0;
uint32_t wheelRevs = 0;
qint64 lastFTMSFrameReceived = 0;
@@ -54,6 +64,12 @@ class virtualrower : public virtualdevice {
void writeCharacteristic(QLowEnergyService *service, const QLowEnergyCharacteristic &characteristic,
const QByteArray &value);
void setupPM5Services();
QByteArray buildPM5GeneralStatus();
QByteArray buildPM5AdditionalStatus();
QByteArray buildPM5AdditionalStatus2();
QByteArray buildPM5StrokeData();
QByteArray buildPM5AdditionalStrokeData();
#ifdef Q_OS_IOS
lockscreen *h = 0;

View File

@@ -0,0 +1,225 @@
#pragma once
#include "gtest/gtest.h"
#include <QByteArray>
#include <vector>
/**
* @brief Sunnyfit Mini Stepper (SF-S) BLE Packet Test Data
*
* Extracted from btsnoop_hci.log capture of actual device communication.
* These are the 20-byte data frames (0x5a 0x05) from the capture file.
*/
class SunnyfitStepperTestData {
public:
/**
* @brief Raw 20-byte data frames captured from actual device
* Format: 0x5a (start) + 0x05 (command) + 18 bytes of data
*
* Byte positions:
* [0]: 0x5a (start marker)
* [1]: 0x05 (command type - data frame)
* [6]: Cadence (SPM) - single byte
* [16]: Step Counter (increments 0, 1, 2, 3...)
*/
static const std::vector<QByteArray> getTestFrames() {
return {
// Frame 0: cadence=0, step=0
QByteArray::fromHex("5a05001a032200000524000000000003260000052900"),
// Frame 1: cadence=0, step=1
QByteArray::fromHex("5a05001a032200000524010000000003260100052900"),
// Frame 2: cadence=0, step=2
QByteArray::fromHex("5a05001a032200000524020000000003260200052900"),
// Frame 3: cadence=32, step=3
QByteArray::fromHex("5a05001a032220000524020000000003260300052900"),
// Frame 4: cadence=67, step=4
QByteArray::fromHex("5a05001a032243000524040000000003260400052900"),
// Frame 5: cadence=67, step=5
QByteArray::fromHex("5a05001a032243000524040000000003260500052900"),
// Frame 6: cadence=67, step=6
QByteArray::fromHex("5a05001a032243000524040000000003260600052900"),
// Frame 7: cadence=20, step=7
QByteArray::fromHex("5a05001a032214000524050000000003260700052900"),
// Frame 8: cadence=53, step=8
QByteArray::fromHex("5a05001a032235000524070000000003260800052900"),
// Frame 9: cadence=63, step=9
QByteArray::fromHex("5a05001a03223f000524080000000003260900052900"),
// Frame 10: cadence=63, step=10
QByteArray::fromHex("5a05001a03223f000524080000000003260a00052900"),
};
}
/**
* @brief Expected extracted values from each test frame
*/
struct ExpectedMetrics {
int frameIndex;
double expectedCadence;
int expectedStepCount;
double expectedSpeed; // cadence / 3.2
};
static const std::vector<ExpectedMetrics> getExpectedValues() {
return {
{0, 0.0, 0, 0.0}, // cadence=0, step=0
{1, 0.0, 1, 0.0}, // cadence=0, step=1
{2, 0.0, 2, 0.0}, // cadence=0, step=2
{3, 32.0, 3, 10.0}, // cadence=32, step=3, speed=32/3.2=10
{4, 67.0, 4, 20.9375}, // cadence=67, step=4, speed=67/3.2≈20.94
{5, 67.0, 5, 20.9375}, // cadence=67, step=5
{6, 67.0, 6, 20.9375}, // cadence=67, step=6
{7, 20.0, 7, 6.25}, // cadence=20, step=7, speed=20/3.2=6.25
{8, 53.0, 8, 16.5625}, // cadence=53, step=8, speed=53/3.2≈16.56
{9, 63.0, 9, 19.6875}, // cadence=63, step=9, speed=63/3.2≈19.69
{10, 63.0, 10, 19.6875}, // cadence=63, step=10
};
}
/**
* @brief Parse a single 20-byte frame and extract metrics
* @return pair<cadence, stepCount> or returns {-1, -1} on error
*/
static std::pair<double, int> parseFrame(const QByteArray& frame) {
if (frame.length() != 20) {
return {-1, -1};
}
if ((uint8_t)frame[0] != 0x5a) {
return {-1, -1};
}
// Extract cadence from byte 6 (single byte)
double cadence = (double)(uint8_t)frame[6];
// Extract step counter from byte 16 (single byte, little-endian)
int stepCount = (uint8_t)frame[16];
return {cadence, stepCount};
}
};
/**
* @brief Test suite for Sunnyfit Stepper frame parsing
*/
class SunnyfitStepperParsingTest : public testing::Test {
protected:
SunnyfitStepperTestData testData;
};
/**
* @brief Test parsing of individual frames
*/
TEST_F(SunnyfitStepperParsingTest, ParseFrames) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
ASSERT_EQ(frames.size(), expectedValues.size())
<< "Test data mismatch: frames and expected values should have same size";
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
EXPECT_EQ(cadence, expectedValues[i].expectedCadence)
<< "Frame " << i << ": Cadence mismatch";
EXPECT_EQ(stepCount, expectedValues[i].expectedStepCount)
<< "Frame " << i << ": Step count mismatch";
}
}
/**
* @brief Test speed calculation from cadence
*/
TEST_F(SunnyfitStepperParsingTest, CalculateSpeed) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
double calculatedSpeed = cadence / 3.2;
EXPECT_DOUBLE_EQ(calculatedSpeed, expectedValues[i].expectedSpeed)
<< "Frame " << i << ": Speed calculation mismatch (cadence=" << cadence << ")";
}
}
/**
* @brief Test step counter increments
*/
TEST_F(SunnyfitStepperParsingTest, StepCounterIncrement) {
auto frames = SunnyfitStepperTestData::getTestFrames();
auto expectedValues = SunnyfitStepperTestData::getExpectedValues();
int previousSteps = -1;
for (size_t i = 0; i < frames.size(); ++i) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frames[i]);
if (previousSteps >= 0) {
int increment = stepCount - previousSteps;
EXPECT_EQ(increment, 1)
<< "Frame " << i << ": Step counter should increment by 1 (was "
<< previousSteps << ", now " << stepCount << ")";
}
previousSteps = stepCount;
}
}
/**
* @brief Test cadence variation detection
*/
TEST_F(SunnyfitStepperParsingTest, CadenceVariation) {
auto frames = SunnyfitStepperTestData::getTestFrames();
std::vector<double> cadences;
for (const auto& frame : frames) {
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(frame);
cadences.push_back(cadence);
}
// Verify we have cadence variation in the test data
double minCadence = *std::min_element(cadences.begin(), cadences.end());
double maxCadence = *std::max_element(cadences.begin(), cadences.end());
EXPECT_LT(minCadence, maxCadence)
<< "Test data should have cadence variation";
EXPECT_EQ(minCadence, 0.0)
<< "Minimum cadence should be 0";
EXPECT_EQ(maxCadence, 67.0)
<< "Maximum cadence should be 67";
}
/**
* @brief Test frame validation
*/
TEST_F(SunnyfitStepperParsingTest, FrameValidation) {
// Invalid length
QByteArray shortFrame = QByteArray::fromHex("5a05001a0322");
auto [cadence, stepCount] = SunnyfitStepperTestData::parseFrame(shortFrame);
EXPECT_EQ(cadence, -1) << "Should reject short frames";
EXPECT_EQ(stepCount, -1) << "Should reject short frames";
// Invalid start marker
QByteArray invalidStart = QByteArray::fromHex("0105001a032200000524000000000003260000052900");
std::tie(cadence, stepCount) = SunnyfitStepperTestData::parseFrame(invalidStart);
EXPECT_EQ(cadence, -1) << "Should reject frames with invalid start marker";
EXPECT_EQ(stepCount, -1) << "Should reject frames with invalid start marker";
// Valid frame
QByteArray validFrame = SunnyfitStepperTestData::getTestFrames()[3];
std::tie(cadence, stepCount) = SunnyfitStepperTestData::parseFrame(validFrame);
EXPECT_EQ(cadence, 32.0) << "Should parse valid frame";
EXPECT_EQ(stepCount, 3) << "Should parse valid frame";
}

View File

@@ -54,6 +54,7 @@ HEADERS += \
Devices/deviceindex.h \
Devices/devicenamepatterngroup.h \
Devices/devicetestdataindex.h \
Devices/TestSunnyfitStepper.h \
Erg/ergtabletestsuite.h \
GarminConnect/garminconnecttestsuite.h \
ToolTests/qfittestsuite.h \