Skip to content

Commit 41ebd07

Browse files
authored
Add XCX bike driver (proprietary FFF6 telemetry) and parser tests (#4913)
1 parent 3c942a7 commit 41ebd07

11 files changed

Lines changed: 386 additions & 2 deletions

src/devices/bluetooth.cpp

Lines changed: 17 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")) ||
@@ -4092,6 +4101,11 @@ void bluetooth::restart() {
40924101
delete ultraSportBike;
40934102
ultraSportBike = nullptr;
40944103
}
4104+
if (xcxBike) {
4105+
4106+
delete xcxBike;
4107+
xcxBike = nullptr;
4108+
}
40954109
if (mepanelBike) {
40964110

40974111
delete mepanelBike;
@@ -4542,6 +4556,8 @@ bluetoothdevice *bluetooth::device() {
45424556
return bkoolBike;
45434557
} else if (ultraSportBike) {
45444558
return ultraSportBike;
4559+
} else if (xcxBike) {
4560+
return xcxBike;
45454561
} else if (horizonTreadmill) {
45464562
return horizonTreadmill;
45474563
} else if (lifefitnessTreadmill) {

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

0 commit comments

Comments
 (0)