Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/devices/bluetooth.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2482,7 +2482,8 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
((b.name().toUpper().startsWith(QStringLiteral("HT")) && b.name().length() == 10) &&
ftms_bike.contains(QZSettings::default_ftms_bike)) ||
(b.name().toUpper().contains(QStringLiteral("CARE")) &&
b.name().length() == 11)) // CARE9040177 - Carefitness CV-351
b.name().length() == 11) ||
sportsplusbike::isCareSportsPlusBike13Name(b.name())) // CARE9040177, CARE113770737
&& !sportsPlusBike && filter) {
this->setLastBluetoothDevice(b);
this->stopDiscovery();
Expand All @@ -2498,7 +2499,8 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
// SLOT(inclinationChanged(double)));
sportsPlusBike->deviceDiscovered(b);
this->signalBluetoothDeviceConnected(sportsPlusBike);
} else if (((b.name().toUpper().contains(QStringLiteral("CARE")) && b.name().length() >= 12) || // CARE968300122, CARE10692135
} else if (((b.name().toUpper().contains(QStringLiteral("CARE")) && b.name().length() >= 12 &&
!sportsplusbike::isCareSportsPlusBike13Name(b.name())) || // CARE968300122, CARE10692135
(b.name().toUpper().startsWith(QStringLiteral("VMAX"))))
&& !sportsPlusRower && filter) {
this->setLastBluetoothDevice(b);
Expand Down
94 changes: 94 additions & 0 deletions src/devices/sportsplusbike/sportsplusbike.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,59 @@ sportsplusbike::sportsplusbike(bool noWriteResistance, bool noHeartService) {
refresh->start(200ms);
}

bool sportsplusbike::isCareSportsPlusBike13Name(const QString &name) {
const QString upperName = name.toUpper();
if (!upperName.startsWith(QStringLiteral("CARE")) || upperName.length() != 13 ||
upperName.at(4) != QLatin1Char('1')) {
return false;
}

for (int i = 4; i < upperName.length(); ++i) {
if (!upperName.at(i).isDigit()) {
return false;
}
}

return true;
}

bool sportsplusbike::isCareSportsPlusBike13Packet(const QByteArray &packet) {
if (packet.size() != 12 || static_cast<uint8_t>(packet.at(0)) != 0x20 ||
(static_cast<uint8_t>(packet.at(1)) != 0x00 && static_cast<uint8_t>(packet.at(1)) != 0x10)) {
return false;
}

uint8_t checksum = 0;
for (int i = 0; i < packet.size() - 1; ++i) {
checksum = static_cast<uint8_t>(checksum + static_cast<uint8_t>(packet.at(i)));
}

return checksum == static_cast<uint8_t>(packet.at(packet.size() - 1));
}

int sportsplusbike::careSportsPlusBike13Cadence(const QByteArray &packet) {
if (!isCareSportsPlusBike13Packet(packet) || static_cast<uint8_t>(packet.at(1)) != 0x10) {
return -1;
}

const uint8_t packedCadence = static_cast<uint8_t>(packet.at(3));
return ((packedCadence & 0xF0) >> 4) * 10 + (packedCadence & 0x0F);
}

int sportsplusbike::careSportsPlusBike13Watts(const QByteArray &packet) {
if (!isCareSportsPlusBike13Packet(packet)) {
return -1;
}

const uint8_t packedWatts = static_cast<uint8_t>(packet.at(10));
return ((packedWatts & 0xF0) >> 4) * 10 + (packedWatts & 0x0F);
}

double sportsplusbike::careSportsPlusBike13Speed(const QByteArray &packet) {
const int cadence = careSportsPlusBike13Cadence(packet);
return cadence < 0 ? 0.0 : 0.37497622 * static_cast<double>(cadence);
}

void sportsplusbike::writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log,
bool wait_for_response) {
QEventLoop loop;
Expand Down Expand Up @@ -218,6 +271,46 @@ void sportsplusbike::characteristicChanged(const QLowEnergyCharacteristic &chara

// double resistance = GetResistanceFromPacket(newValue);
kcal = GetKcalFromPacket(newValue);
} else if (care_sportsplus_bike_13) {
const int packetWatts = careSportsPlusBike13Watts(newValue);
if (settings.value(QZSettings::power_sensor_name, QZSettings::default_power_sensor_name)
.toString()
.startsWith(QStringLiteral("Disabled"))) {
m_watt = packetWatts >= 0 ? packetWatts : wattsFromResistance(currentResistance().value());
}
emit debug(QStringLiteral("Current watt: ") + QString::number(m_watt.value()));

const int packetCadence = careSportsPlusBike13Cadence(newValue);
if (packetCadence >= 0) {
cadence = packetCadence;
cadence_eval = true;
} else {
cadence = currentCadence().value();
cadence_eval = true;
}

const double speed = packetCadence >= 0 ? careSportsPlusBike13Speed(newValue)
: 0.37497622 * cadence;
if (!settings.value(QZSettings::speed_power_based, QZSettings::default_speed_power_based).toBool()) {
Speed = speed;
} else {
Speed = metric::calculateSpeedFromPower(
watts(), Inclination.value(), Speed.value(),
fabs(now.msecsTo(Speed.lastChanged()) / 1000.0), this->speedLimit());
}
emit debug(QStringLiteral("Current speed: ") + QString::number(Speed.value()));

if (!firstCharChanged) {
Distance +=
((Speed.value() / 3600.0) / (1000.0 / (lastTimeCharChanged.msecsTo(now))));
}
lastTimeCharChanged = now;
kcal =
((((0.048 * ((double)watts()) + 1.19) *
settings.value(QZSettings::weight, QZSettings::default_weight).toFloat() * 3.5) /
200.0) /
(60000.0 / ((double)lastRefreshCharacteristicChanged.msecsTo(
now))));
} else if (carefitness_bike) {
if (settings.value(QZSettings::power_sensor_name, QZSettings::default_power_sensor_name)
.toString()
Expand Down Expand Up @@ -518,6 +611,7 @@ void sportsplusbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
{
bluetoothDevice = device;
ht_variant_bike = isSportsPlusHTVariantName(bluetoothDevice.name());
care_sportsplus_bike_13 = isCareSportsPlusBike13Name(bluetoothDevice.name());
if ((bluetoothDevice.name().toUpper().contains(QStringLiteral("CARE")) &&
bluetoothDevice.name().length() >= 11)) // CARE9040177 - Carefitness CV-351)
{
Expand Down
7 changes: 7 additions & 0 deletions src/devices/sportsplusbike/sportsplusbike.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class sportsplusbike : public bike {
resistance_t pelotonToBikeResistance(int pelotonResistance) override;
bool connected() override;

static bool isCareSportsPlusBike13Name(const QString &name);
static bool isCareSportsPlusBike13Packet(const QByteArray &packet);
static int careSportsPlusBike13Cadence(const QByteArray &packet);
static int careSportsPlusBike13Watts(const QByteArray &packet);
static double careSportsPlusBike13Speed(const QByteArray &packet);

private:
double GetSpeedFromPacket(const QByteArray &packet);
double GetKcalFromPacket(const QByteArray &packet);
Expand Down Expand Up @@ -74,6 +80,7 @@ class sportsplusbike : public bike {
bool readyToStart = false;

bool carefitness_bike = false;
bool care_sportsplus_bike_13 = false;
bool ht_variant_bike = false;

const resistance_t max_resistance = 24;
Expand Down
1 change: 1 addition & 0 deletions tst/Devices/TestSportsPlusBikeParser.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#include "TestSportsPlusBikeParser.h"
63 changes: 63 additions & 0 deletions tst/Devices/TestSportsPlusBikeParser.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#pragma once

#include <cmath>

#include <gtest/gtest.h>
#include <QByteArray>

#include "sportsplusbike/sportsplusbike.h"

TEST(CareSportsPlusBikeDetectionTest, UsesLengthAndFirstNumericCharacter) {
EXPECT_TRUE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE113770737")));

// Known CARE rowers must not be reclassified by the 13-character bike rule.
EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE10692135")));
EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE968300122")));

// Existing 11-character CARE bikes are handled by the legacy detection branch.
EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE9040177")));
EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE11377073")));
EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Name(QStringLiteral("CARE2A3770737")));
}

TEST(CareSportsPlusBikeParserTest, ParsesRealFramesFromCare113770737Log) {
struct FrameExpectation {
const char *hex;
int cadence;
int watts;
};

// Frames copied from debug-Mon_Aug_3_08_40_03_2026.log.
const FrameExpectation frames[] = {
{"20 10 00 00 00 00 00 00 00 00 00 30", 0, 0},
{"20 10 00 41 00 02 00 00 00 00 11 84", 41, 11},
{"20 10 00 51 00 21 00 03 00 01 22 c8", 51, 22},
{"20 10 00 60 00 64 00 14 00 00 80 88", 60, 80},
{"20 00 01 53 00 02 00 00 00 00 11 87", -1, 11},
};

for (const auto &expected : frames) {
const QByteArray packet = QByteArray::fromHex(expected.hex);

ASSERT_TRUE(sportsplusbike::isCareSportsPlusBike13Packet(packet)) << expected.hex;
EXPECT_EQ(sportsplusbike::careSportsPlusBike13Cadence(packet), expected.cadence) << expected.hex;
EXPECT_EQ(sportsplusbike::careSportsPlusBike13Watts(packet), expected.watts) << expected.hex;

if (expected.cadence >= 0) {
EXPECT_NEAR(sportsplusbike::careSportsPlusBike13Speed(packet),
0.37497622 * expected.cadence, 1e-9)
<< expected.hex;
} else {
EXPECT_DOUBLE_EQ(sportsplusbike::careSportsPlusBike13Speed(packet), 0.0) << expected.hex;
}
}
}

TEST(CareSportsPlusBikeParserTest, RejectsInvalidFrameChecksum) {
QByteArray packet = QByteArray::fromHex("20 10 00 41 00 02 00 00 00 00 11 84");
packet[11] = static_cast<char>(0x85);

EXPECT_FALSE(sportsplusbike::isCareSportsPlusBike13Packet(packet));
EXPECT_EQ(sportsplusbike::careSportsPlusBike13Cadence(packet), -1);
EXPECT_EQ(sportsplusbike::careSportsPlusBike13Watts(packet), -1);
}
3 changes: 2 additions & 1 deletion tst/Devices/devicetestdataindex.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1154,7 +1154,8 @@ void DeviceTestDataIndex::Initialize() {
// Sports Plus Bike
RegisterNewDeviceTestData(DeviceIndex::SportsPlusBike)
->expectDevice<sportsplusbike>()
->acceptDeviceName("CARDIOFIT", DeviceNameComparison::StartsWithIgnoreCase);
->acceptDeviceName("CARDIOFIT", DeviceNameComparison::StartsWithIgnoreCase)
->acceptDeviceName("CARE113770737", DeviceNameComparison::IgnoreCase);

// Sports Plus Rower
RegisterNewDeviceTestData(DeviceIndex::SportsPlusRower)
Expand Down
2 changes: 2 additions & 0 deletions tst/qdomyos-zwift-tests.pro
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ SOURCES += \
Devices/TestZwiftRideController.cpp \
Devices/TestApexBikeParser.cpp \
Devices/TestKeepBikeParser.cpp \
Devices/TestSportsPlusBikeParser.cpp \
main.cpp

# Avoid the "File too big" error building in Windows. This has happened when a template class is used with Google Test / typed tests
Expand Down Expand Up @@ -63,6 +64,7 @@ HEADERS += \
Devices/TestSchwinn411510EParser.h \
Devices/TestApexBikeParser.h \
Devices/TestKeepBikeParser.h \
Devices/TestSportsPlusBikeParser.h \
Devices/TestOctaneTreadmillZR8.h \
Devices/TestSunnyfitStepper.h \
Erg/ergtabletestsuite.h \
Expand Down
Loading