Skip to content

Commit fc14ac2

Browse files
committed
Add proprietary XCX bike support
1 parent bb64b62 commit fc14ac2

11 files changed

Lines changed: 372 additions & 2 deletions

src/devices/bluetooth.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1851,6 +1851,16 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
18511851
// connect(tacxneo2Bike, SIGNAL(inclinationChanged(double)), this, SLOT(inclinationChanged(double)));
18521852
tacxneo2Bike->deviceDiscovered(b);
18531853
this->signalBluetoothDeviceConnected(tacxneo2Bike);
1854+
} else if (b.name().toUpper().startsWith(QStringLiteral("XCX-")) && !xcxBike && filter) {
1855+
// XCX exposes FTMS, CSC and Cycling Power services, but its working telemetry is
1856+
// proprietary FFF6. This name-specific branch must remain before generic FTMS.
1857+
this->setLastBluetoothDevice(b);
1858+
this->stopDiscovery();
1859+
xcxBike = new xcxbike(noWriteResistance, noHeartService, bikeResistanceOffset, bikeResistanceGain);
1860+
emit deviceConnected(b);
1861+
connect(xcxBike, &bluetoothdevice::connectedAndDiscovered, this, &bluetooth::connectedAndDiscovered);
1862+
xcxBike->deviceDiscovered(b);
1863+
this->signalBluetoothDeviceConnected(xcxBike);
18541864
} else if (((b.name().toUpper().startsWith("INDOORCYCLE")) ||
18551865
((b.name().toUpper().startsWith("MAGNUS ")) && !deviceHasService(b, QBluetoothUuid((quint16)0x1826)))) &&
18561866
!cycleopsphantomBike && filter) {
@@ -1982,7 +1992,6 @@ void bluetooth::deviceDiscovered(const QBluetoothDeviceInfo &device) {
19821992
(b.name().toUpper().startsWith("ROBX")) ||
19831993
(b.name().toUpper().startsWith("ORLAUF_ARES")) ||
19841994
(b.name().toUpper().startsWith("SPEEDMAGPRO")) ||
1985-
(b.name().toUpper().startsWith("XCX-")) ||
19861995
(b.name().toUpper().startsWith("SMARTBIKE-")) ||
19871996
(b.name().toUpper().startsWith("D500V2")) ||
19881997
(b.name().toUpper().startsWith("FBIKE-HEAVY-PRO")) ||

src/devices/bluetooth.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@
156156
#include "devices/ultrasportbike/ultrasportbike.h"
157157
#include "devices/wahookickrheadwind/wahookickrheadwind.h"
158158
#include "devices/wahookickrsnapbike/wahookickrsnapbike.h"
159+
#include "devices/xcxbike/xcxbike.h"
159160
#include "devices/yesoulbike/yesoulbike.h"
160161
#include "devices/ypooelliptical/ypooelliptical.h"
161162
#include "devices/ziprotreadmill/ziprotreadmill.h"
@@ -309,6 +310,7 @@ class bluetooth : public QObject, public SignalHandler {
309310
stagesbike *powerBike = nullptr;
310311
ultrasportbike *ultraSportBike = nullptr;
311312
wahookickrsnapbike *wahooKickrSnapBike = nullptr;
313+
xcxbike *xcxBike = nullptr;
312314
ypooelliptical *ypooElliptical = nullptr;
313315
ziprotreadmill *ziproTreadmill = nullptr;
314316
kineticinroadbike *kineticInroadBike = nullptr;

src/devices/xcxbike/xcxbike.cpp

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#include "xcxbike.h"
2+
3+
#ifdef Q_OS_ANDROID
4+
#include "keepawakehelper.h"
5+
#endif
6+
#include "qzsettings.h"
7+
#include "virtualdevices/virtualbike.h"
8+
9+
#include <QDebug>
10+
#include <QSettings>
11+
12+
#include <chrono>
13+
#include <cmath>
14+
15+
using namespace std::chrono_literals;
16+
17+
#ifdef Q_OS_IOS
18+
extern quint8 QZ_EnableDiscoveryCharsAndDescripttors;
19+
#endif
20+
21+
namespace {
22+
const QBluetoothUuid fff0Uuid(QStringLiteral("0000fff0-0000-1000-8000-00805f9b34fb"));
23+
const QBluetoothUuid fff5Uuid(QStringLiteral("0000fff5-0000-1000-8000-00805f9b34fb"));
24+
const QBluetoothUuid fff6Uuid(QStringLiteral("0000fff6-0000-1000-8000-00805f9b34fb"));
25+
26+
quint8 byteAt(const QByteArray &packet, int offset) { return static_cast<quint8>(packet.at(offset)); }
27+
28+
quint16 le16(const QByteArray &packet, int offset) {
29+
return static_cast<quint16>(byteAt(packet, offset)) | (static_cast<quint16>(byteAt(packet, offset + 1)) << 8);
30+
}
31+
32+
quint32 le24(const QByteArray &packet, int offset) {
33+
return static_cast<quint32>(byteAt(packet, offset)) | (static_cast<quint32>(byteAt(packet, offset + 1)) << 8) |
34+
(static_cast<quint32>(byteAt(packet, offset + 2)) << 16);
35+
}
36+
} // namespace
37+
38+
xcxbike::xcxbike(bool noWriteResistance, bool noHeartService, int8_t bikeResistanceOffset, double bikeResistanceGain)
39+
: noWriteResistance(noWriteResistance), noHeartService(noHeartService), bikeResistanceOffset(bikeResistanceOffset),
40+
bikeResistanceGain(bikeResistanceGain) {
41+
#ifdef Q_OS_IOS
42+
QZ_EnableDiscoveryCharsAndDescripttors = true;
43+
#endif
44+
m_watt.setType(metric::METRIC_WATT, deviceType());
45+
Speed.setType(metric::METRIC_SPEED);
46+
connect(&refresh, &QTimer::timeout, this, &xcxbike::update);
47+
refresh.start(300ms);
48+
}
49+
50+
bool xcxbike::parseTelemetry(const QByteArray &packet, XcxTelemetry *telemetry) {
51+
if (!telemetry || packet.size() != 20 || byteAt(packet, 0) != 0xfa || byteAt(packet, 1) != 0x05)
52+
return false;
53+
54+
telemetry->state1 = byteAt(packet, 2);
55+
telemetry->state2 = byteAt(packet, 3);
56+
telemetry->timer = le16(packet, 4);
57+
telemetry->speedKph = le16(packet, 6) / 10.0;
58+
telemetry->distanceRaw = le24(packet, 8);
59+
telemetry->energy = le16(packet, 11);
60+
telemetry->cadence = le16(packet, 13);
61+
telemetry->unknown15 = le16(packet, 15);
62+
// The capture only proves that byte 17 contains power up to 255 W. A new capture is required
63+
// before treating any other byte as a high byte for larger power values.
64+
telemetry->power = byteAt(packet, 17);
65+
telemetry->resistance = le16(packet, 18);
66+
return true;
67+
}
68+
69+
void xcxbike::update() {
70+
if (m_control && m_control->state() == QLowEnergyController::UnconnectedState)
71+
emit disconnected();
72+
if (notificationEnabled)
73+
update_metrics(false, watts());
74+
75+
// This protocol is telemetry-only: no FFF5 command format was present in the capture.
76+
requestResistance = -1;
77+
if (requestStart != -1) {
78+
requestStart = -1;
79+
emit bikeStarted();
80+
}
81+
requestStop = -1;
82+
}
83+
84+
void xcxbike::characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &value) {
85+
if (characteristic.uuid() != fff6Uuid)
86+
return;
87+
88+
XcxTelemetry telemetry;
89+
if (!parseTelemetry(value, &telemetry)) {
90+
qDebug() << QStringLiteral("XCX unexpected FFF6 packet") << value.size() << value.toHex(' ');
91+
return;
92+
}
93+
94+
qDebug() << QStringLiteral("XCX << raw:") << value.toHex(' ') << QStringLiteral("state1:") << telemetry.state1
95+
<< QStringLiteral("state2:") << telemetry.state2 << QStringLiteral("timer:") << telemetry.timer
96+
<< QStringLiteral("speed:") << telemetry.speedKph << QStringLiteral("distanceRaw:")
97+
<< telemetry.distanceRaw << QStringLiteral("energy:") << telemetry.energy << QStringLiteral("cadence:")
98+
<< telemetry.cadence << QStringLiteral("unknown15:") << telemetry.unknown15 << QStringLiteral("power:")
99+
<< telemetry.power << QStringLiteral("resistance:") << telemetry.resistance;
100+
101+
if (!receivedState || telemetry.state1 != lastState1 || telemetry.state2 != lastState2) {
102+
qDebug() << QStringLiteral("XCX state changed") << lastState1 << lastState2 << QStringLiteral("->")
103+
<< telemetry.state1 << telemetry.state2;
104+
lastState1 = telemetry.state1;
105+
lastState2 = telemetry.state2;
106+
receivedState = true;
107+
}
108+
109+
const QDateTime now = QDateTime::currentDateTime();
110+
const qint64 elapsedMs = lastTelemetryTime.isValid() ? lastTelemetryTime.msecsTo(now) : 0;
111+
Speed = telemetry.speedKph;
112+
Cadence = telemetry.cadence;
113+
m_watt = telemetry.power;
114+
Resistance = telemetry.resistance;
115+
KCal = telemetry.energy;
116+
// The proprietary counter advances plausibly in 0.1 km units. It must not be interpreted as
117+
// metres like the device's broken FTMS mirror does.
118+
Distance = telemetry.distanceRaw / 10.0;
119+
120+
if (elapsedMs > 0 && elapsedMs < 10000 && telemetry.cadence > 0) {
121+
partialCrankRevolution += telemetry.cadence * (elapsedMs / 60000.0);
122+
const quint32 completedRevolutions = static_cast<quint32>(std::floor(partialCrankRevolution));
123+
if (completedRevolutions) {
124+
CrankRevs += completedRevolutions;
125+
LastCrankEventTime += static_cast<quint16>(completedRevolutions * 1024.0 * 60.0 / telemetry.cadence);
126+
partialCrankRevolution -= completedRevolutions;
127+
}
128+
}
129+
lastTelemetryTime = now;
130+
131+
#ifdef Q_OS_ANDROID
132+
QSettings settings;
133+
if (settings.value(QZSettings::ant_heart, QZSettings::default_ant_heart).toBool())
134+
Heart = static_cast<uint8_t>(KeepAwakeHelper::heart());
135+
else
136+
#endif
137+
update_hr_from_external();
138+
}
139+
140+
void xcxbike::serviceStateChanged(QLowEnergyService::ServiceState state) {
141+
qDebug() << QStringLiteral("XCX FFF0 service state") << state;
142+
if (state != QLowEnergyService::ServiceDiscovered)
143+
return;
144+
145+
fff5WriteCharacteristic = fff0Service->characteristic(fff5Uuid);
146+
fff6NotifyCharacteristic = fff0Service->characteristic(fff6Uuid);
147+
if (!fff6NotifyCharacteristic.isValid()) {
148+
qDebug() << QStringLiteral("XCX FFF6 notification characteristic not found");
149+
return;
150+
}
151+
qDebug() << QStringLiteral("XCX FFF5 present for future protocol research:") << fff5WriteCharacteristic.isValid();
152+
connect(fff0Service, &QLowEnergyService::characteristicChanged, this, &xcxbike::characteristicChanged);
153+
connect(fff0Service, &QLowEnergyService::descriptorWritten, this, &xcxbike::descriptorWritten);
154+
createVirtualBike();
155+
156+
const QLowEnergyDescriptor cccd =
157+
fff6NotifyCharacteristic.descriptor(QBluetoothUuid::ClientCharacteristicConfiguration);
158+
if (!cccd.isValid()) {
159+
qDebug() << QStringLiteral("XCX FFF6 CCCD not found");
160+
return;
161+
}
162+
fff0Service->writeDescriptor(cccd, QByteArray::fromHex("0100"));
163+
}
164+
165+
void xcxbike::descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &value) {
166+
qDebug() << QStringLiteral("XCX descriptor written") << descriptor.uuid() << value.toHex(' ');
167+
if (descriptor.uuid() == QBluetoothUuid::ClientCharacteristicConfiguration &&
168+
value == QByteArray::fromHex("0100")) {
169+
notificationEnabled = true;
170+
emit connectedAndDiscovered();
171+
}
172+
}
173+
174+
void xcxbike::createVirtualBike() {
175+
if (hasVirtualDevice())
176+
return;
177+
QSettings settings;
178+
if (settings.value(QZSettings::virtual_device_enabled, QZSettings::default_virtual_device_enabled).toBool()) {
179+
auto *virtualBike =
180+
new virtualbike(this, noWriteResistance, noHeartService, bikeResistanceOffset, bikeResistanceGain);
181+
connect(virtualBike, &virtualbike::changeInclination, this, &xcxbike::changeInclination);
182+
setVirtualDevice(virtualBike, VIRTUAL_DEVICE_MODE::PRIMARY);
183+
}
184+
}
185+
186+
void xcxbike::serviceScanDone() {
187+
fff0Service = m_control->createServiceObject(fff0Uuid, this);
188+
if (!fff0Service) {
189+
qDebug() << QStringLiteral("XCX proprietary FFF0 service not found");
190+
return;
191+
}
192+
connect(fff0Service, &QLowEnergyService::stateChanged, this, &xcxbike::serviceStateChanged);
193+
fff0Service->discoverDetails();
194+
}
195+
196+
void xcxbike::deviceDiscovered(const QBluetoothDeviceInfo &device) {
197+
bluetoothDevice = device;
198+
m_control = QLowEnergyController::createCentral(bluetoothDevice, this);
199+
connect(m_control, &QLowEnergyController::discoveryFinished, this, &xcxbike::serviceScanDone);
200+
connect(m_control, &QLowEnergyController::stateChanged, this, &xcxbike::controllerStateChanged);
201+
connect(m_control, &QLowEnergyController::connected, this, [this]() { m_control->discoverServices(); });
202+
connect(m_control, &QLowEnergyController::disconnected, this, &xcxbike::disconnected);
203+
m_control->connectToDevice();
204+
}
205+
206+
void xcxbike::controllerStateChanged(QLowEnergyController::ControllerState state) {
207+
qDebug() << QStringLiteral("XCX controller state") << state;
208+
if (state == QLowEnergyController::UnconnectedState && m_control) {
209+
notificationEnabled = false;
210+
lastTelemetryTime = QDateTime();
211+
m_control->connectToDevice();
212+
}
213+
}
214+
215+
bool xcxbike::connected() {
216+
return m_control && m_control->state() == QLowEnergyController::DiscoveredState && notificationEnabled;
217+
}
218+
219+
uint16_t xcxbike::watts() { return Cadence.value() == 0 ? 0 : static_cast<uint16_t>(m_watt.value()); }

src/devices/xcxbike/xcxbike.h

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#ifndef XCXBIKE_H
2+
#define XCXBIKE_H
3+
4+
#include "devices/bike.h"
5+
6+
#include <QBluetoothDeviceInfo>
7+
#include <QByteArray>
8+
#include <QDateTime>
9+
#include <QLowEnergyCharacteristic>
10+
#include <QLowEnergyController>
11+
#include <QLowEnergyService>
12+
#include <QTimer>
13+
14+
struct XcxTelemetry {
15+
XcxTelemetry(quint8 state1 = 0, quint8 state2 = 0, quint16 timer = 0, double speedKph = 0, quint32 distanceRaw = 0,
16+
quint16 energy = 0, quint16 cadence = 0, quint16 unknown15 = 0, quint16 power = 0,
17+
quint16 resistance = 0)
18+
: state1(state1), state2(state2), timer(timer), speedKph(speedKph), distanceRaw(distanceRaw), energy(energy),
19+
cadence(cadence), unknown15(unknown15), power(power), resistance(resistance) {}
20+
21+
quint8 state1 = 0;
22+
quint8 state2 = 0;
23+
quint16 timer = 0;
24+
double speedKph = 0;
25+
quint32 distanceRaw = 0;
26+
quint16 energy = 0;
27+
quint16 cadence = 0;
28+
quint16 unknown15 = 0;
29+
quint16 power = 0;
30+
quint16 resistance = 0;
31+
};
32+
33+
class xcxbike : public bike {
34+
Q_OBJECT
35+
36+
public:
37+
xcxbike(bool noWriteResistance, bool noHeartService, int8_t bikeResistanceOffset, double bikeResistanceGain);
38+
39+
static bool parseTelemetry(const QByteArray &packet, XcxTelemetry *telemetry);
40+
bool connected() override;
41+
42+
public slots:
43+
void deviceDiscovered(const QBluetoothDeviceInfo &device);
44+
45+
signals:
46+
void disconnected();
47+
48+
private slots:
49+
void characteristicChanged(const QLowEnergyCharacteristic &characteristic, const QByteArray &value);
50+
void descriptorWritten(const QLowEnergyDescriptor &descriptor, const QByteArray &value);
51+
void serviceScanDone();
52+
void serviceStateChanged(QLowEnergyService::ServiceState state);
53+
void controllerStateChanged(QLowEnergyController::ControllerState state);
54+
void update();
55+
56+
private:
57+
void createVirtualBike();
58+
uint16_t watts() override;
59+
60+
QTimer refresh;
61+
QLowEnergyService *fff0Service = nullptr;
62+
QLowEnergyCharacteristic fff5WriteCharacteristic;
63+
QLowEnergyCharacteristic fff6NotifyCharacteristic;
64+
bool noWriteResistance;
65+
bool noHeartService;
66+
int8_t bikeResistanceOffset;
67+
double bikeResistanceGain;
68+
bool notificationEnabled = false;
69+
quint8 lastState1 = 0;
70+
quint8 lastState2 = 0;
71+
bool receivedState = false;
72+
QDateTime lastTelemetryTime;
73+
double partialCrankRevolution = 0;
74+
};
75+
76+
#endif // XCXBIKE_H

src/qdomyos-zwift.pri

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ devices/treadmill.cpp \
339339
devices/truetreadmill/truetreadmill.cpp \
340340
devices/trxappgateusbbike/trxappgateusbbike.cpp \
341341
devices/ultrasportbike/ultrasportbike.cpp \
342+
devices/xcxbike/xcxbike.cpp \
342343
virtualdevices/virtualrower.cpp \
343344
devices/wahookickrsnapbike/wahookickrsnapbike.cpp \
344345
devices/yesoulbike/yesoulbike.cpp \
@@ -875,6 +876,7 @@ devices/truetreadmill/truetreadmill.h \
875876
devices/trxappgateusbbike/trxappgateusbbike.h \
876877
devices/trxappgateusbtreadmill/trxappgateusbtreadmill.h \
877878
devices/ultrasportbike/ultrasportbike.h \
879+
devices/xcxbike/xcxbike.h \
878880
virtualdevices/virtualbike.h \
879881
virtualdevices/virtualrower.h \
880882
virtualdevices/virtualtreadmill.h \

tst/Devices/TestXcxBikeParser.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#include "TestXcxBikeParser.h"
2+
3+
TEST_P(XcxBikeParserTest, DecodesCapturedFff6Telemetry) {
4+
XcxTelemetry actual;
5+
ASSERT_TRUE(xcxbike::parseTelemetry(std::get<0>(GetParam()), &actual));
6+
const XcxTelemetry &expected = std::get<1>(GetParam());
7+
EXPECT_EQ(expected.state1, actual.state1);
8+
EXPECT_EQ(expected.state2, actual.state2);
9+
EXPECT_EQ(expected.timer, actual.timer);
10+
EXPECT_DOUBLE_EQ(expected.speedKph, actual.speedKph);
11+
EXPECT_EQ(expected.distanceRaw, actual.distanceRaw);
12+
EXPECT_EQ(expected.energy, actual.energy);
13+
EXPECT_EQ(expected.cadence, actual.cadence);
14+
EXPECT_EQ(expected.unknown15, actual.unknown15);
15+
EXPECT_EQ(expected.power, actual.power);
16+
EXPECT_EQ(expected.resistance, actual.resistance);
17+
}
18+
19+
INSTANTIATE_TEST_SUITE_P(
20+
CapturedPackets, XcxBikeParserTest,
21+
testing::Values(
22+
std::make_tuple(
23+
QByteArray::fromHex("fa0504003b00780000000001002e0000006e3c00"),
24+
XcxTelemetry{4, 0, 59, 12.0, 0, 1, 46, 0, 110, 60}),
25+
std::make_tuple(
26+
QByteArray::fromHex("fa05040021009300010000200038000100823c00"),
27+
XcxTelemetry{4, 0, 33, 14.7, 1, 32, 56, 1, 130, 60}),
28+
std::make_tuple(
29+
QByteArray::fromHex("fa0504000100d6000200004f0051000200b43c00"),
30+
XcxTelemetry{4, 0, 1, 21.4, 2, 79, 81, 2, 180, 60}),
31+
std::make_tuple(
32+
QByteArray::fromHex("fa05000100002800050000a50010000500323c00"),
33+
XcxTelemetry{0, 1, 0, 4.0, 5, 165, 16, 5, 50, 60})));
34+
35+
TEST(XcxBikeParserRejectionTest, RejectsWrongLengthAndHeaders) {
36+
XcxTelemetry telemetry;
37+
EXPECT_FALSE(
38+
xcxbike::parseTelemetry(QByteArray::fromHex("fa05"), &telemetry));
39+
EXPECT_FALSE(xcxbike::parseTelemetry(
40+
QByteArray::fromHex("fb0504003b00780000000001002e0000006e3c00"),
41+
&telemetry));
42+
EXPECT_FALSE(xcxbike::parseTelemetry(
43+
QByteArray::fromHex("fa0604003b00780000000001002e0000006e3c00"),
44+
&telemetry));
45+
}

0 commit comments

Comments
 (0)