Compare commits

..

13 Commits

Author SHA1 Message Date
Roberto Viola
0e728ef3aa Update project.pbxproj 2026-01-31 20:22:07 +01:00
Roberto Viola
2e500e9c09 Merge branch 'claude/add-thinkrider-device-7YXzY' of https://github.com/cagnulein/qdomyos-zwift into claude/add-thinkrider-device-7YXzY 2026-01-31 19:20:27 +00:00
Roberto Viola
9efd62d3e9 Merge branch 'master' into claude/add-thinkrider-device-7YXzY 2026-01-31 19:20:07 +00: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
Claude
edf89fd44c 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
2026-01-31 17:23:59 +00: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
Roberto Viola
802d6f58fa Update project.pbxproj 2026-01-30 15:13:49 +01:00
Roberto Viola
35115da097 Merge branch 'claude/add-thinkrider-device-7YXzY' of https://github.com/cagnulein/qdomyos-zwift into claude/add-thinkrider-device-7YXzY 2026-01-30 14:13:06 +00:00
Roberto Viola
c859a3e3af Update project.pbxproj 2026-01-30 15:05:35 +01:00
Roberto Viola
a8f1fc076b Update project.pbxproj 2026-01-30 14:48:16 +01:00
Roberto Viola
99dac32de4 Merge branch 'master' into claude/add-thinkrider-device-7YXzY 2026-01-30 13:13:47 +00:00
Claude
c770ab6b80 Update allSettingsCount to 857 for thinkrider_controller setting
https://claude.ai/code/session_01DK5qQY9wKyHTKfYhAkGECS
2026-01-29 17:19:10 +00:00
Claude
89cd6b93e9 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
2026-01-29 16:53:03 +00:00
14 changed files with 469 additions and 455 deletions

View File

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

View File

@@ -557,6 +557,8 @@
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 */; };
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 +1662,9 @@
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>"; };
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 +2340,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 */,
@@ -3892,6 +3900,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 */,
@@ -4198,6 +4207,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 +4583,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 = 1276;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = NO;
@@ -4774,7 +4784,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 = 1276;
DEBUG_INFORMATION_FORMAT = dwarf;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_TEAM = 6335M7T29D;
@@ -5011,7 +5021,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 = 1276;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -5107,7 +5117,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 = 1276;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_BITCODE = YES;
@@ -5199,7 +5209,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 = 1276;
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
ENABLE_PREVIEWS = YES;
@@ -5315,7 +5325,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 = 1276;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "\"watchkit Extension/Preview Content\"";
ENABLE_BITCODE = YES;
@@ -5425,7 +5435,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1276;
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -5516,7 +5526,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = QZWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1274;
CURRENT_PROJECT_VERSION = 1276;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6335M7T29D;
ENABLE_NS_ASSERTIONS = NO;

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("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) {
@@ -2094,15 +2112,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 +3133,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("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 +3641,6 @@ void bluetooth::restart() {
delete echelonRower;
echelonRower = nullptr;
}
if (mobiRower) {
delete mobiRower;
mobiRower = nullptr;
}
if (echelonStride) {
delete echelonStride;
@@ -4054,8 +4076,6 @@ bluetoothdevice *bluetooth::device() {
return echelonConnectSport;
} else if (echelonRower) {
return echelonRower;
} else if (mobiRower) {
return mobiRower;
} else if (echelonStride) {
return echelonStride;
} else if (echelonStairclimber) {

View File

@@ -154,7 +154,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 +270,6 @@ class bluetooth : public QObject, public SignalHandler {
echelonrower *echelonRower = nullptr;
ftmsrower *ftmsRower = nullptr;
smartrowrower *smartrowRower = nullptr;
mobirower *mobiRower = nullptr;
echelonstride *echelonStride = nullptr;
echelonstairclimber *echelonStairclimber = nullptr;
lifefitnesstreadmill *lifefitnessTreadmill = nullptr;
@@ -308,6 +307,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 +345,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,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

@@ -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

@@ -101,9 +101,9 @@ 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/technogymbike/technogymbike.cpp \
@@ -379,9 +379,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 \

View File

@@ -814,7 +814,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

@@ -776,6 +776,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");
@@ -1050,7 +1051,7 @@ const QString QZSettings::trainprogram_auto_lap_on_segment = QStringLiteral("tra
const QString QZSettings::kingsmith_r2_enable_hw_buttons = QStringLiteral("kingsmith_r2_enable_hw_buttons");
const uint32_t allSettingsCount = 856;
const uint32_t allSettingsCount = 857;
QVariant allSettings[allSettingsCount][2] = {
{QZSettings::cryptoKeySettingsProfiles, QZSettings::default_cryptoKeySettingsProfiles},
@@ -1696,6 +1697,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

@@ -2140,6 +2140,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;

View File

@@ -1273,6 +1273,7 @@ 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
}
@@ -6726,6 +6727,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 +12768,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)