Skip to content

Commit 24b9332

Browse files
committed
Older Concept2 PM5 -0 distance reported (Issue #4609)
1 parent 23d4d1a commit 24b9332

6 files changed

Lines changed: 357 additions & 51 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ tst/Devices/
206206
- Configuration-based test scenarios
207207
- XML output support for CI/CD integration
208208
- Tests must be built after main application (links against libqdomyos-zwift.a)
209+
- When changing shared protocol parsing logic (FTMS, PM5/Concept2, characteristic parsers reused across devices), add or update regression tests in `tst/Devices/` using real user debug logs whenever possible. Cover both the new bugfix scenario and at least one nearby scenario that must keep working to prevent regressions in GitHub Actions.
210+
- Prefer tests that call the production parser path directly, or a shared parser helper extracted from production code. Do not duplicate protocol parsing logic inside the test itself, or the regression test can drift with the bug.
211+
- A regression test that duplicates protocol parsing logic in the test body is not sufficient protection. If the parser needs to become testable, first extract the parsing logic into a shared production helper and have both runtime code and tests use that same implementation.
209212

210213
## Configuration & Settings
211214

src/devices/ftmsrower/ftmsrower.cpp

Lines changed: 170 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -143,56 +143,176 @@ void ftmsrower::serviceDiscovered(const QBluetoothUuid &gatt) {
143143
emit debug(QStringLiteral("serviceDiscovered ") + gatt.toString());
144144
}
145145

146+
void ftmsrower::processPm5ParserState(ParserRegressionState &state, const QString &charUuid,
147+
const QByteArray &newValue, qint64 nowMs) {
148+
if (charUuid == QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}")) {
149+
if (!state.hasFtmsService && newValue.length() >= 6) {
150+
const uint32_t distance_dm =
151+
((((uint32_t)(uint8_t)newValue.at(5)) << 16) | (((uint32_t)(uint8_t)newValue.at(4)) << 8) |
152+
(uint32_t)(uint8_t)newValue.at(3));
153+
154+
if (distance_dm > 0 || state.distanceKm == 0.0) {
155+
state.distanceKm = distance_dm / 10000.0;
156+
state.distanceReceivedFromPm5 = (distance_dm > 0);
157+
state.lastPm5DistanceUpdateMs = nowMs;
158+
}
159+
}
160+
161+
if (newValue.length() >= 10) {
162+
state.rowState = (uint8_t)newValue.at(9);
163+
state.rowStateReceived = true;
164+
}
165+
return;
166+
}
167+
168+
if (charUuid == QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}")) {
169+
if (newValue.length() < 7) {
170+
return;
171+
}
172+
173+
const uint8_t spm = (uint8_t)newValue.at(5);
174+
if (spm > 0 && (!state.rowStateReceived || state.rowState != 0)) {
175+
state.cadence = spm;
176+
}
177+
if (state.rowStateReceived && state.rowState == 0) {
178+
state.cadence = 0;
179+
}
180+
181+
const uint16_t speedRaw = ((uint8_t)newValue.at(4) << 8) | (uint8_t)newValue.at(3);
182+
if (speedRaw > 0 && (!state.rowStateReceived || state.rowState != 0)) {
183+
state.speedKmh = (speedRaw * 0.001) * 3.6;
184+
}
185+
if (state.rowStateReceived && state.rowState == 0) {
186+
state.speedKmh = 0;
187+
}
188+
189+
if (!state.hasFtmsService && !state.distanceReceivedFromPm5 && state.lastPm5DistanceUpdateMs > 0) {
190+
state.distanceKm += ((state.speedKmh / 3600000.0) * (nowMs - state.lastPm5DistanceUpdateMs));
191+
}
192+
if (!state.hasFtmsService) {
193+
state.lastPm5DistanceUpdateMs = nowMs;
194+
}
195+
}
196+
}
197+
198+
void ftmsrower::processFtmsParserState(ParserRegressionState &state, const QByteArray &newValue, qint64 nowMs,
199+
bool iconsolePlus, bool fitshow, bool mrkR11s, bool whipr, bool kingsmith,
200+
bool dfitLR) {
201+
Q_UNUSED(dfitLR);
202+
union flags {
203+
struct {
204+
uint16_t moreData : 1;
205+
uint16_t avgStroke : 1;
206+
uint16_t totDistance : 1;
207+
uint16_t instantPace : 1;
208+
uint16_t avgPace : 1;
209+
uint16_t instantPower : 1;
210+
uint16_t avgPower : 1;
211+
uint16_t resistanceLvl : 1;
212+
uint16_t expEnergy : 1;
213+
uint16_t heartRate : 1;
214+
uint16_t metabolic : 1;
215+
uint16_t elapsedTime : 1;
216+
uint16_t remainingTime : 1;
217+
uint16_t spare : 3;
218+
};
219+
220+
uint16_t word_flags;
221+
};
222+
223+
flags Flags;
224+
int index = 0;
225+
Q_UNUSED(whipr);
226+
Q_UNUSED(kingsmith);
227+
228+
Flags.word_flags = (newValue.at(1) << 8) | newValue.at(0);
229+
index += 2;
230+
231+
if (!Flags.moreData) {
232+
index += 3;
233+
}
234+
235+
if (Flags.avgStroke) {
236+
index += 1;
237+
}
238+
239+
if (Flags.totDistance) {
240+
if (iconsolePlus || fitshow || mrkR11s) {
241+
if (state.lastRefreshMs > 0) {
242+
state.distanceKm += ((state.speedKmh / 3600000.0) * (nowMs - state.lastRefreshMs));
243+
}
244+
} else if (newValue.length() >= index + 3) {
245+
state.distanceKm = ((double)((((uint32_t)((uint8_t)newValue.at(index + 2)) << 16) |
246+
(uint32_t)((uint8_t)newValue.at(index + 1)) << 8) |
247+
(uint32_t)((uint8_t)newValue.at(index)))) /
248+
1000.0;
249+
}
250+
index += 3;
251+
} else if (state.lastRefreshMs > 0) {
252+
state.distanceKm += ((state.speedKmh / 3600000.0) * (nowMs - state.lastRefreshMs));
253+
}
254+
255+
if (Flags.instantPace && newValue.length() >= index + 2) {
256+
const double instantPace =
257+
((double)(((uint16_t)((uint8_t)newValue.at(index + 1)) << 8) | (uint16_t)((uint8_t)newValue.at(index))));
258+
if (instantPace == 0 || instantPace == 65535) {
259+
state.speedKmh = 0;
260+
} else {
261+
state.speedKmh = (60.0 / instantPace) * 30.0;
262+
}
263+
}
264+
265+
state.lastRefreshMs = nowMs;
266+
}
267+
146268
void ftmsrower::parseConcept2Data(const QLowEnergyCharacteristic &characteristic, const QByteArray &newValue) {
147269
QDateTime now = QDateTime::currentDateTime();
148270
QSettings settings;
149271

150272
QString charUuid = characteristic.uuid().toString();
273+
274+
ParserRegressionState parserState;
275+
parserState.distanceKm = Distance.value();
276+
parserState.speedKmh = Speed.value();
277+
parserState.cadence = Cadence.value();
278+
parserState.rowState = pm5RowState;
279+
parserState.rowStateReceived = pm5RowStateReceived;
280+
parserState.hasFtmsService = pm5HasFTMSService;
281+
parserState.distanceReceivedFromPm5 = pm5DistanceReceived;
282+
parserState.lastRefreshMs = lastRefreshCharacteristicChanged.toMSecsSinceEpoch();
283+
parserState.lastPm5DistanceUpdateMs = lastPm5DistanceUpdate.toMSecsSinceEpoch();
284+
285+
processPm5ParserState(parserState, charUuid, newValue, now.toMSecsSinceEpoch());
286+
287+
Distance = parserState.distanceKm;
288+
Speed = parserState.speedKmh;
289+
Cadence = parserState.cadence;
290+
pm5RowState = parserState.rowState;
291+
pm5RowStateReceived = parserState.rowStateReceived;
292+
pm5DistanceReceived = parserState.distanceReceivedFromPm5;
293+
if (parserState.lastPm5DistanceUpdateMs > 0) {
294+
lastPm5DistanceUpdate = QDateTime::fromMSecsSinceEpoch(parserState.lastPm5DistanceUpdateMs);
295+
}
151296

152297
if (charUuid == QStringLiteral("{ce060031-43e5-11e4-916c-0800200c9a66}")) {
153298
// Parse characteristic CE060031 - Based on go-row implementation
154299
if (newValue.length() >= 10) {
155-
// Extract RowState from byte 9 - this indicates if user is actively rowing
156-
pm5RowState = (uint8_t)newValue.at(9);
157-
pm5RowStateReceived = true; // Mark that we've received RowState at least once
158-
159300
emit debug(QStringLiteral("PM5 CE060031 RAW: ") + newValue.toHex(' ') +
301+
QStringLiteral(" Distance: ") + QString::number(Distance.value()) +
160302
QStringLiteral(" RowState: ") + QString::number(pm5RowState));
161303
}
162304
}
163305
else if (charUuid == QStringLiteral("{ce060032-43e5-11e4-916c-0800200c9a66}")) {
164306
// Parse characteristic CE060032 - Based on go-row implementation
165307
if (newValue.length() >= 7) {
166-
// Extract cadence (SPM) from byte 5
167-
uint8_t spm = (uint8_t)newValue.at(5);
168-
if (spm > 0) {
169-
// Only check RowState if we've received it at least once
170-
if (!pm5RowStateReceived || pm5RowState != 0) {
171-
Cadence = spm;
172-
lastStroke = now;
173-
}
174-
}
175-
// Zero cadence if RowState indicates not rowing (and we've received RowState)
176-
if (pm5RowStateReceived && pm5RowState == 0) {
177-
Cadence = 0;
178-
}
179-
180-
// Extract speed from bytes 3-4 (little endian) in 0.001m/s
181-
uint16_t speedRaw = ((uint8_t)newValue.at(4) << 8) | (uint8_t)newValue.at(3);
182-
if (speedRaw > 0) {
183-
// Only check RowState if we've received it at least once
184-
if (!pm5RowStateReceived || pm5RowState != 0) {
185-
Speed = (speedRaw * 0.001) * 3.6; // Convert m/s to km/h
186-
}
187-
}
188-
// Zero speed if RowState indicates not rowing (and we've received RowState)
189-
if (pm5RowStateReceived && pm5RowState == 0) {
190-
Speed = 0;
308+
if (Cadence.value() > 0) {
309+
lastStroke = now;
191310
}
192311

193312
emit debug(QStringLiteral("PM5 CE060032 RAW: ") + newValue.toHex(' ') +
194313
QStringLiteral(" Cadence: ") + QString::number(Cadence.value()) +
195314
QStringLiteral(" Speed: ") + QString::number(Speed.value()) +
315+
QStringLiteral(" Distance: ") + QString::number(Distance.value()) +
196316
QStringLiteral(" RowState: ") + QString::number(pm5RowState));
197317
}
198318
}
@@ -273,6 +393,9 @@ void ftmsrower::parseConcept2Data(const QLowEnergyCharacteristic &characteristic
273393
m_watt = 0;
274394
Cadence = 0;
275395
Speed = 0;
396+
if (!pm5HasFTMSService) {
397+
lastPm5DistanceUpdate = now;
398+
}
276399
}
277400

278401
// Update metrics for virtual device
@@ -395,22 +518,25 @@ void ftmsrower::characteristicChanged(const QLowEnergyCharacteristic &characteri
395518
emit debug(QStringLiteral("Current Average Stroke: ") + QString::number(avgStroke));
396519
}
397520

521+
ParserRegressionState parserState;
522+
parserState.distanceKm = Distance.value();
523+
parserState.speedKmh = Speed.value();
524+
parserState.cadence = Cadence.value();
525+
parserState.rowState = pm5RowState;
526+
parserState.rowStateReceived = pm5RowStateReceived;
527+
parserState.hasFtmsService = pm5HasFTMSService;
528+
parserState.distanceReceivedFromPm5 = pm5DistanceReceived;
529+
parserState.lastRefreshMs = lastRefreshCharacteristicChanged.toMSecsSinceEpoch();
530+
parserState.lastPm5DistanceUpdateMs = lastPm5DistanceUpdate.toMSecsSinceEpoch();
531+
532+
processFtmsParserState(parserState, newValue, now.toMSecsSinceEpoch(), ICONSOLE_PLUS, FITSHOW, MRK_R11S, WHIPR,
533+
KINGSMITH, DFIT_L_R);
534+
535+
Distance = parserState.distanceKm;
536+
Speed = parserState.speedKmh;
537+
398538
if (Flags.totDistance) {
399-
if (ICONSOLE_PLUS || FITSHOW || MRK_R11S) {
400-
// For ICONSOLE+/FITSHOW/MRK_R11S, always calculate distance from speed instead of using characteristic data
401-
Distance += ((Speed.value() / 3600000.0) *
402-
((double)lastRefreshCharacteristicChanged.msecsTo(now)));
403-
} else {
404-
// For other devices, use the distance from characteristic data
405-
Distance = ((double)((((uint32_t)((uint8_t)newValue.at(index + 2)) << 16) |
406-
(uint32_t)((uint8_t)newValue.at(index + 1)) << 8) |
407-
(uint32_t)((uint8_t)newValue.at(index)))) /
408-
1000.0;
409-
}
410539
index += 3;
411-
} else {
412-
Distance += ((Speed.value() / 3600000.0) *
413-
((double)lastRefreshCharacteristicChanged.msecsTo(now)));
414540
}
415541

416542
emit debug(QStringLiteral("Current Distance: ") + QString::number(Distance.value()));
@@ -423,14 +549,6 @@ void ftmsrower::characteristicChanged(const QLowEnergyCharacteristic &characteri
423549
index += 2;
424550
emit debug(QStringLiteral("Current Pace: ") + QString::number(instantPace));
425551

426-
// Always handle invalid pace values to prevent division by zero
427-
if(instantPace == 0 || instantPace == 65535) {
428-
Speed = 0;
429-
} else {
430-
if((DFIT_L_R && Cadence.value() > 0) || !DFIT_L_R)
431-
Speed = (60.0 / instantPace) * 30.0; // translating pace (min/500m) to km/h in order to match the pace function in the rower.cpp
432-
}
433-
434552
emit debug(QStringLiteral("Current Speed: ") + QString::number(Speed.value()));
435553
}
436554

@@ -768,6 +886,7 @@ void ftmsrower::serviceScanDone(void) {
768886
break;
769887
}
770888
}
889+
pm5HasFTMSService = hasFTMSService;
771890

772891
// If no FTMS service, check for Concept2 PM5 services
773892
if (!hasFTMSService && PM5) {

src/devices/ftmsrower/ftmsrower.h

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,25 @@
3737
class ftmsrower : public rower {
3838
Q_OBJECT
3939
public:
40+
struct ParserRegressionState {
41+
double distanceKm = 0.0;
42+
double speedKmh = 0.0;
43+
double cadence = 0.0;
44+
uint8_t rowState = 0;
45+
bool rowStateReceived = false;
46+
bool hasFtmsService = false;
47+
bool distanceReceivedFromPm5 = false;
48+
qint64 lastRefreshMs = 0;
49+
qint64 lastPm5DistanceUpdateMs = 0;
50+
};
51+
4052
ftmsrower(bool noWriteResistance, bool noHeartService);
4153
bool connected() override;
54+
static void processPm5ParserState(ParserRegressionState &state, const QString &charUuid, const QByteArray &newValue,
55+
qint64 nowMs);
56+
static void processFtmsParserState(ParserRegressionState &state, const QByteArray &newValue, qint64 nowMs,
57+
bool iconsolePlus, bool fitshow, bool mrkR11s, bool whipr, bool kingsmith,
58+
bool dfitLR);
4259

4360
private:
4461
void writeCharacteristic(uint8_t *data, uint8_t data_len, const QString &info, bool disable_log = false,
@@ -88,6 +105,9 @@ class ftmsrower : public rower {
88105
// PM5 specific variables
89106
uint8_t pm5RowState = 0;
90107
bool pm5RowStateReceived = false;
108+
bool pm5HasFTMSService = false;
109+
bool pm5DistanceReceived = false;
110+
QDateTime lastPm5DistanceUpdate = QDateTime::currentDateTime();
91111

92112
#ifdef Q_OS_IOS
93113
lockscreen *h = 0;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#include "TestFtmsRowerPm5Regression.h"

0 commit comments

Comments
 (0)